generated from coulomb/repo-seed
- 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>
154 lines
4.7 KiB
Go
154 lines
4.7 KiB
Go
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"`
|
|
}
|