Support opt-in MFA per browser client with authoritative enrollment checks
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 40s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 40s
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
e1e292919a
commit
ac8ed65203
14 changed files with 298 additions and 12 deletions
|
|
@ -57,12 +57,14 @@ func (a *PrivacyIDEAAdapter) hasActiveToken(ctx context.Context, userID string)
|
|||
q := url.Values{}
|
||||
q.Set("user", userID)
|
||||
q.Set("realm", a.cfg.realm())
|
||||
// Filter before pagination so an active factor cannot be hidden on page two.
|
||||
q.Set("active", "True")
|
||||
|
||||
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)
|
||||
req.Header.Set("Authorization", a.cfg.AdminToken)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
|
|
@ -84,11 +86,20 @@ func (a *PrivacyIDEAAdapter) hasActiveToken(ctx context.Context, userID string)
|
|||
return false, fmt.Errorf("privacyidea: decode token list response: %w", err)
|
||||
}
|
||||
|
||||
if !parsed.Result.Status || parsed.Result.Value.Tokens == nil || parsed.Result.Value.Count == nil || *parsed.Result.Value.Count < len(parsed.Result.Value.Tokens) {
|
||||
return false, fmt.Errorf("privacyidea: incomplete or unsuccessful token list response")
|
||||
}
|
||||
for _, tok := range parsed.Result.Value.Tokens {
|
||||
if tok.Active {
|
||||
if tok.Active == nil {
|
||||
return false, fmt.Errorf("privacyidea: token missing active state")
|
||||
}
|
||||
if *tok.Active {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if *parsed.Result.Value.Count > len(parsed.Result.Value.Tokens) {
|
||||
return false, fmt.Errorf("privacyidea: incomplete token list page")
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +121,7 @@ func (a *PrivacyIDEAAdapter) ValidateMFAToken(ctx context.Context, userID, token
|
|||
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)
|
||||
req.Header.Set("Authorization", a.cfg.AdminToken)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
|
|
@ -148,6 +159,7 @@ type tokenListResponse struct {
|
|||
Status bool `json:"status"`
|
||||
Value struct {
|
||||
Tokens []tokenEntry `json:"tokens"`
|
||||
Count *int `json:"count"`
|
||||
} `json:"value"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
|
@ -155,7 +167,7 @@ type tokenListResponse struct {
|
|||
// tokenEntry represents a single token entry in the token list response.
|
||||
type tokenEntry struct {
|
||||
Serial string `json:"serial"`
|
||||
Active bool `json:"active"`
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
|
||||
// validateResponse models the privacyIDEA POST /validate/check response envelope.
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ func tokenListResponse(tokens []map[string]interface{}) string {
|
|||
tokenJSON += fmt.Sprintf(`{"serial":"TOK%d","active":%v}`, i, active)
|
||||
}
|
||||
tokenJSON += "]"
|
||||
return fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":%s}}}`, tokenJSON)
|
||||
return fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":%s,"count":%d}}}`, tokenJSON, len(tokens))
|
||||
}
|
||||
|
||||
// validateResponse builds a privacyIDEA /validate/check JSON response.
|
||||
|
|
@ -243,8 +243,8 @@ 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 auth != "service-jwt" {
|
||||
t.Errorf("expected raw service JWT in Authorization, got %q", auth)
|
||||
}
|
||||
if !strings.Contains(auth, "service-jwt") {
|
||||
t.Errorf("expected admin token in Authorization header, got %q", auth)
|
||||
|
|
|
|||
43
src/internal/adapters/privacyidea/optional_mfa_test.go
Normal file
43
src/internal/adapters/privacyidea/optional_mfa_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package privacyidea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"keycape/internal/adapters/privacyidea"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnrollmentLookupRejectsIndeterminateResponses(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{}`, `{"result":{"status":true,"value":{"tokens":[{}],"count":1}}}`, `{"result":{"status":false,"value":{"tokens":[],"count":0}}}`,
|
||||
`{"result":{"status":true,"value":{"count":0}}}`,
|
||||
`{"result":{"status":true,"value":{"tokens":[]}}}`,
|
||||
`{"result":{"status":true,"value":{"tokens":null,"count":0}}}`,
|
||||
`{"result":{"status":true,"value":{"tokens":[],"count":1}}}`,
|
||||
`{"result":{"status":true,"value":{"tokens":[],"count":-1}}}`,
|
||||
} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
adapter := privacyidea.New(testConfig(), &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) { return jsonResponse(body), nil }})
|
||||
required, err := adapter.HasEnrolledFactor(context.Background(), "alice")
|
||||
if err == nil || required {
|
||||
t.Fatalf("indeterminate lookup accepted: required=%v error=%v", required, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestEnrollmentLookupFiltersBeforePagination(t *testing.T) {
|
||||
adapter := privacyidea.New(testConfig(), &mockHTTPClient{doFn: func(req *http.Request) (*http.Response, error) {
|
||||
q := req.URL.Query()
|
||||
if q.Get("user") != "alice" || q.Get("realm") != "netkingdom" || q.Get("active") != "True" {
|
||||
t.Fatalf("incorrect filter: %v", q)
|
||||
}
|
||||
if req.Header.Get("Authorization") != "service-jwt" {
|
||||
t.Fatal("provider requires raw JWT")
|
||||
}
|
||||
return jsonResponse(`{"result":{"status":true,"value":{"tokens":[{"active":true}],"count":20}}}`), nil
|
||||
}})
|
||||
required, err := adapter.HasEnrolledFactor(context.Background(), "alice")
|
||||
if err != nil || !required {
|
||||
t.Fatalf("active factor missed: %v %v", required, err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue