feat: implement T11, T12 — Authelia adapter, privacyIDEA adapter
- T11: AutheliaAdapter delegating login UI and session; Authelia tokens never leak to profile layer - T12: PrivacyIDEAAdapter delegating MFA 100% — no MFA logic in KeyCape 21 adapter tests pass, vet clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b0adbc5daa
commit
d05c73dc19
8 changed files with 1113 additions and 0 deletions
153
src/internal/adapters/privacyidea/adapter.go
Normal file
153
src/internal/adapters/privacyidea/adapter.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
package privacyidea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"keycape/internal/domain"
|
||||
)
|
||||
|
||||
// PrivacyIDEAAdapter implements domain.MFAProvider by delegating to privacyIDEA's
|
||||
// REST API. No MFA logic is implemented here — every decision is owned by
|
||||
// privacyIDEA. The adapter fails closed: any infrastructure error is returned
|
||||
// as a non-nil error so the caller cannot proceed without a definitive answer.
|
||||
type PrivacyIDEAAdapter struct {
|
||||
cfg Config
|
||||
client HTTPClient
|
||||
}
|
||||
|
||||
// New returns a production-ready PrivacyIDEAAdapter.
|
||||
// If httpClient is nil the default net/http.Client is used.
|
||||
func New(cfg Config, httpClient HTTPClient) *PrivacyIDEAAdapter {
|
||||
if httpClient == nil {
|
||||
httpClient = defaultHTTPClient
|
||||
}
|
||||
return &PrivacyIDEAAdapter{cfg: cfg, client: httpClient}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// domain.MFAProvider implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CheckMFARequired returns true if the user has at least one active MFA token
|
||||
// registered in privacyIDEA. Fails closed: any infrastructure error returns
|
||||
// (false, err) so callers cannot bypass the check.
|
||||
func (a *PrivacyIDEAAdapter) CheckMFARequired(ctx context.Context, userID string) (bool, error) {
|
||||
endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/"
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("user", userID)
|
||||
q.Set("realm", a.cfg.realm())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("privacyidea: build token list request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+a.cfg.AdminToken)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("privacyidea: token list request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("privacyidea: token list returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("privacyidea: read token list response: %w", err)
|
||||
}
|
||||
|
||||
var parsed tokenListResponse
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return false, fmt.Errorf("privacyidea: decode token list response: %w", err)
|
||||
}
|
||||
|
||||
for _, tok := range parsed.Result.Value.Tokens {
|
||||
if tok.Active {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ValidateMFAToken validates the given OTP token for the user via privacyIDEA's
|
||||
// /validate/check endpoint. Returns nil on success, domain.ErrMFAFailed if the
|
||||
// token is invalid, and a wrapped infrastructure error on any network/HTTP failure.
|
||||
// Fails closed: infrastructure errors are surfaced, not swallowed.
|
||||
func (a *PrivacyIDEAAdapter) ValidateMFAToken(ctx context.Context, userID, token string) error {
|
||||
endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/validate/check"
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("user", userID)
|
||||
form.Set("pass", token)
|
||||
form.Set("realm", a.cfg.realm())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("privacyidea: build validate request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer "+a.cfg.AdminToken)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("privacyidea: validate request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("privacyidea: validate endpoint returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("privacyidea: read validate response: %w", err)
|
||||
}
|
||||
|
||||
var parsed validateResponse
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return fmt.Errorf("privacyidea: decode validate response: %w", err)
|
||||
}
|
||||
|
||||
if !parsed.Result.Value {
|
||||
return domain.ErrMFAFailed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON response types (internal to this package)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// tokenListResponse models the privacyIDEA GET /token/ response envelope.
|
||||
type tokenListResponse struct {
|
||||
Result struct {
|
||||
Status bool `json:"status"`
|
||||
Value struct {
|
||||
Tokens []tokenEntry `json:"tokens"`
|
||||
} `json:"value"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
// tokenEntry represents a single token entry in the token list response.
|
||||
type tokenEntry struct {
|
||||
Serial string `json:"serial"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// validateResponse models the privacyIDEA POST /validate/check response envelope.
|
||||
type validateResponse struct {
|
||||
Result struct {
|
||||
Status bool `json:"status"`
|
||||
Value bool `json:"value"`
|
||||
} `json:"result"`
|
||||
}
|
||||
309
src/internal/adapters/privacyidea/adapter_test.go
Normal file
309
src/internal/adapters/privacyidea/adapter_test.go
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
package privacyidea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"keycape/internal/adapters/privacyidea"
|
||||
"keycape/internal/domain"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock HTTP client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// mockHTTPClient implements privacyidea.HTTPClient for test injection.
|
||||
type mockHTTPClient struct {
|
||||
doFn func(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||
if m.doFn != nil {
|
||||
return m.doFn(req)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("{}")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// testConfig returns a minimal Config suitable for tests.
|
||||
func testConfig() privacyidea.Config {
|
||||
return privacyidea.Config{
|
||||
BaseURL: "https://privacyidea.local",
|
||||
AdminToken: "service-jwt",
|
||||
Realm: "netkingdom",
|
||||
}
|
||||
}
|
||||
|
||||
// jsonResponse returns a *http.Response with a JSON body and status 200.
|
||||
func jsonResponse(body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}
|
||||
}
|
||||
|
||||
// tokenListResponse builds a privacyIDEA /token/ JSON response.
|
||||
func tokenListResponse(tokens []map[string]interface{}) string {
|
||||
tokenJSON := "["
|
||||
for i, t := range tokens {
|
||||
if i > 0 {
|
||||
tokenJSON += ","
|
||||
}
|
||||
active, _ := t["active"].(bool)
|
||||
tokenJSON += fmt.Sprintf(`{"serial":"TOK%d","active":%v}`, i, active)
|
||||
}
|
||||
tokenJSON += "]"
|
||||
return fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":%s}}}`, tokenJSON)
|
||||
}
|
||||
|
||||
// validateResponse builds a privacyIDEA /validate/check JSON response.
|
||||
func validateResponse(success bool) string {
|
||||
return fmt.Sprintf(`{"result":{"status":true,"value":%v}}`, success)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CheckMFARequired — tokens present
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckMFARequired_ActiveTokenPresent_ReturnsTrue(t *testing.T) {
|
||||
client := &mockHTTPClient{
|
||||
doFn: func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodGet {
|
||||
t.Errorf("expected GET, got %s", req.Method)
|
||||
}
|
||||
if !strings.Contains(req.URL.String(), "alice") {
|
||||
t.Errorf("expected user in URL, got: %s", req.URL)
|
||||
}
|
||||
return jsonResponse(tokenListResponse([]map[string]interface{}{
|
||||
{"active": true},
|
||||
})), nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := privacyidea.New(testConfig(), client)
|
||||
required, err := adapter.CheckMFARequired(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !required {
|
||||
t.Error("expected MFA required=true when active token present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMFARequired_InactiveTokenOnly_ReturnsFalse(t *testing.T) {
|
||||
client := &mockHTTPClient{
|
||||
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(tokenListResponse([]map[string]interface{}{
|
||||
{"active": false},
|
||||
})), nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := privacyidea.New(testConfig(), client)
|
||||
required, err := adapter.CheckMFARequired(context.Background(), "bob")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if required {
|
||||
t.Error("expected MFA required=false when only inactive tokens present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMFARequired_NoTokens_ReturnsFalse(t *testing.T) {
|
||||
client := &mockHTTPClient{
|
||||
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(tokenListResponse(nil)), nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := privacyidea.New(testConfig(), client)
|
||||
required, err := adapter.CheckMFARequired(context.Background(), "charlie")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if required {
|
||||
t.Error("expected MFA required=false when no tokens")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CheckMFARequired — error cases (fail closed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckMFARequired_HTTPError_ReturnsError(t *testing.T) {
|
||||
client := &mockHTTPClient{
|
||||
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("connection refused")
|
||||
},
|
||||
}
|
||||
|
||||
adapter := privacyidea.New(testConfig(), client)
|
||||
_, err := adapter.CheckMFARequired(context.Background(), "alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on HTTP failure, got nil (must fail closed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMFARequired_Non200Status_ReturnsError(t *testing.T) {
|
||||
client := &mockHTTPClient{
|
||||
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: io.NopCloser(strings.NewReader(`{"result":{"status":false}}`)),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := privacyidea.New(testConfig(), client)
|
||||
_, err := adapter.CheckMFARequired(context.Background(), "alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on non-200 status, got nil (must fail closed)")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CheckMFARequired — Authorization header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCheckMFARequired_SendsAdminToken(t *testing.T) {
|
||||
client := &mockHTTPClient{
|
||||
doFn: func(req *http.Request) (*http.Response, error) {
|
||||
auth := req.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
t.Errorf("expected Bearer token in Authorization, got %q", auth)
|
||||
}
|
||||
if !strings.Contains(auth, "service-jwt") {
|
||||
t.Errorf("expected admin token in Authorization header, got %q", auth)
|
||||
}
|
||||
return jsonResponse(tokenListResponse(nil)), nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := privacyidea.New(testConfig(), client)
|
||||
_, _ = adapter.CheckMFARequired(context.Background(), "alice")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ValidateMFAToken — success
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidateMFAToken_ValidOTP_ReturnsNil(t *testing.T) {
|
||||
client := &mockHTTPClient{
|
||||
doFn: func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", req.Method)
|
||||
}
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
bodyStr := string(body)
|
||||
if !strings.Contains(bodyStr, "alice") {
|
||||
t.Errorf("expected user in POST body, got: %s", bodyStr)
|
||||
}
|
||||
if !strings.Contains(bodyStr, "123456") {
|
||||
t.Errorf("expected OTP in POST body, got: %s", bodyStr)
|
||||
}
|
||||
return jsonResponse(validateResponse(true)), nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := privacyidea.New(testConfig(), client)
|
||||
err := adapter.ValidateMFAToken(context.Background(), "alice", "123456")
|
||||
if err != nil {
|
||||
t.Errorf("expected nil error for valid OTP, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ValidateMFAToken — failure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidateMFAToken_InvalidOTP_ReturnsErrMFAFailed(t *testing.T) {
|
||||
client := &mockHTTPClient{
|
||||
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(validateResponse(false)), nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := privacyidea.New(testConfig(), client)
|
||||
err := adapter.ValidateMFAToken(context.Background(), "alice", "wrong")
|
||||
if err == nil {
|
||||
t.Fatal("expected ErrMFAFailed, got nil")
|
||||
}
|
||||
if err != domain.ErrMFAFailed {
|
||||
t.Errorf("expected domain.ErrMFAFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMFAToken_HTTPError_ReturnsError(t *testing.T) {
|
||||
client := &mockHTTPClient{
|
||||
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("network failure")
|
||||
},
|
||||
}
|
||||
|
||||
adapter := privacyidea.New(testConfig(), client)
|
||||
err := adapter.ValidateMFAToken(context.Background(), "alice", "123456")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on HTTP failure, got nil (must fail closed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMFAToken_Non200Status_ReturnsError(t *testing.T) {
|
||||
client := &mockHTTPClient{
|
||||
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Body: io.NopCloser(strings.NewReader(`{}`)),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := privacyidea.New(testConfig(), client)
|
||||
err := adapter.ValidateMFAToken(context.Background(), "alice", "123456")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on non-200 status, got nil (must fail closed)")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ValidateMFAToken — realm is included in request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidateMFAToken_IncludesRealm(t *testing.T) {
|
||||
client := &mockHTTPClient{
|
||||
doFn: func(req *http.Request) (*http.Response, error) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
if !strings.Contains(string(body), "netkingdom") {
|
||||
t.Errorf("expected realm in POST body, got: %s", body)
|
||||
}
|
||||
return jsonResponse(validateResponse(true)), nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := privacyidea.New(testConfig(), client)
|
||||
_ = adapter.ValidateMFAToken(context.Background(), "alice", "000000")
|
||||
}
|
||||
|
||||
func TestCheckMFARequired_IncludesRealm(t *testing.T) {
|
||||
client := &mockHTTPClient{
|
||||
doFn: func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.String(), "netkingdom") {
|
||||
t.Errorf("expected realm in request URL, got: %s", req.URL)
|
||||
}
|
||||
return jsonResponse(tokenListResponse(nil)), nil
|
||||
},
|
||||
}
|
||||
|
||||
adapter := privacyidea.New(testConfig(), client)
|
||||
_, _ = adapter.CheckMFARequired(context.Background(), "alice")
|
||||
}
|
||||
36
src/internal/adapters/privacyidea/config.go
Normal file
36
src/internal/adapters/privacyidea/config.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Package privacyidea implements the domain.MFAProvider interface by delegating
|
||||
// all MFA decisions to a privacyIDEA server. KeyCape contains no MFA logic —
|
||||
// every check and validation call is forwarded verbatim to privacyIDEA.
|
||||
package privacyidea
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Config holds all connection parameters for the privacyIDEA adapter.
|
||||
type Config struct {
|
||||
// BaseURL is the privacyIDEA server base URL, e.g. "https://privacyidea.local".
|
||||
BaseURL string
|
||||
|
||||
// AdminToken is the service-account JWT used to authenticate requests to the
|
||||
// privacyIDEA admin API.
|
||||
AdminToken string
|
||||
|
||||
// Realm is the privacyIDEA realm to scope token and validate requests.
|
||||
// Defaults to "netkingdom" when empty.
|
||||
Realm string
|
||||
}
|
||||
|
||||
// realm returns the effective realm, falling back to "netkingdom".
|
||||
func (c Config) realm() string {
|
||||
if c.Realm != "" {
|
||||
return c.Realm
|
||||
}
|
||||
return "netkingdom"
|
||||
}
|
||||
|
||||
// HTTPClient is a minimal interface over net/http.Client for test injection.
|
||||
type HTTPClient interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// defaultHTTPClient is the production HTTP client used when none is injected.
|
||||
var defaultHTTPClient HTTPClient = &http.Client{}
|
||||
Loading…
Add table
Add a link
Reference in a new issue