Support provider credential renewal and reject unsuccessful OTP validation
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
85f5deaf4a
commit
632b1f1376
6 changed files with 292 additions and 5 deletions
|
|
@ -7,6 +7,8 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"keycape/internal/domain"
|
||||
|
|
@ -64,7 +66,11 @@ func (a *PrivacyIDEAAdapter) hasActiveToken(ctx context.Context, userID string)
|
|||
if err != nil {
|
||||
return false, fmt.Errorf("privacyidea: build token list request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", a.cfg.AdminToken)
|
||||
credential, err := a.adminCredential()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Header.Set("Authorization", credential)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
|
|
@ -121,7 +127,11 @@ 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", a.cfg.AdminToken)
|
||||
credential, err := a.adminCredential()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", credential)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
|
|
@ -143,12 +153,49 @@ func (a *PrivacyIDEAAdapter) ValidateMFAToken(ctx context.Context, userID, token
|
|||
return fmt.Errorf("privacyidea: decode validate response: %w", err)
|
||||
}
|
||||
|
||||
if !parsed.Result.Value {
|
||||
if !parsed.Result.Status || !parsed.Result.Value {
|
||||
return domain.ErrMFAFailed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// adminCredential rereads the mounted file, including atomic Secret volume updates.
|
||||
// Never include paths, bytes or underlying OS errors in a returned error.
|
||||
func (a *PrivacyIDEAAdapter) adminCredential() (string, error) {
|
||||
const maxCredentialBytes = 16384
|
||||
if a.cfg.AdminToken != "" && a.cfg.AdminTokenFile != "" {
|
||||
return "", fmt.Errorf("privacyidea: configure only one administrative credential source")
|
||||
}
|
||||
credential := a.cfg.AdminToken
|
||||
if a.cfg.AdminTokenFile != "" {
|
||||
if !filepath.IsAbs(a.cfg.AdminTokenFile) {
|
||||
return "", fmt.Errorf("privacyidea: administrative credential path must be absolute")
|
||||
}
|
||||
info, err := os.Stat(a.cfg.AdminTokenFile)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("privacyidea: administrative credential unavailable")
|
||||
}
|
||||
file, err := os.Open(a.cfg.AdminTokenFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("privacyidea: administrative credential unavailable")
|
||||
}
|
||||
defer file.Close()
|
||||
info, err = file.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("privacyidea: administrative credential unavailable")
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxCredentialBytes+1))
|
||||
if err != nil || len(data) > maxCredentialBytes {
|
||||
return "", fmt.Errorf("privacyidea: administrative credential unavailable")
|
||||
}
|
||||
credential = strings.TrimSpace(string(data))
|
||||
}
|
||||
if credential == "" || len(credential) > maxCredentialBytes || strings.IndexFunc(credential, func(r rune) bool { return r < 33 || r > 126 }) >= 0 {
|
||||
return "", fmt.Errorf("privacyidea: administrative credential invalid or missing")
|
||||
}
|
||||
return credential, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON response types (internal to this package)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@
|
|||
// every check and validation call is forwarded verbatim to privacyIDEA.
|
||||
package privacyidea
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all connection parameters for the privacyIDEA adapter.
|
||||
type Config struct {
|
||||
|
|
@ -14,6 +17,10 @@ type Config struct {
|
|||
// privacyIDEA admin API.
|
||||
AdminToken string `yaml:"adminToken"`
|
||||
|
||||
// AdminTokenFile is a protected mounted credential reread for each request.
|
||||
// Configure exactly one source. Failed reads never fall back to a cached token.
|
||||
AdminTokenFile string `yaml:"adminTokenFile,omitempty"`
|
||||
|
||||
// Realm is the privacyIDEA realm to scope token and validate requests.
|
||||
// Defaults to "netkingdom" when empty.
|
||||
Realm string `yaml:"realm"`
|
||||
|
|
@ -38,4 +45,7 @@ type HTTPClient interface {
|
|||
}
|
||||
|
||||
// defaultHTTPClient is the production HTTP client used when none is injected.
|
||||
var defaultHTTPClient HTTPClient = &http.Client{}
|
||||
var defaultHTTPClient HTTPClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
|
|
|
|||
175
src/internal/adapters/privacyidea/credential_file_test.go
Normal file
175
src/internal/adapters/privacyidea/credential_file_test.go
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
package privacyidea_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"keycape/internal/adapters/privacyidea"
|
||||
)
|
||||
|
||||
func TestCredentialFileRenewalAndFailureRecovery(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "credential")
|
||||
cfg := testConfig()
|
||||
cfg.AdminToken = ""
|
||||
cfg.AdminTokenFile = path
|
||||
var seen []string
|
||||
adapter := privacyidea.New(cfg, &mockHTTPClient{doFn: func(req *http.Request) (*http.Response, error) {
|
||||
seen = append(seen, req.Header.Get("Authorization"))
|
||||
if req.URL.Path == "/validate/check" {
|
||||
return jsonResponse(`{"result":{"status":true,"value":true}}`), nil
|
||||
}
|
||||
return jsonResponse(`{"result":{"status":true,"value":{"tokens":[],"count":0}}}`), nil
|
||||
}})
|
||||
write := func(value string) {
|
||||
t.Helper()
|
||||
next := filepath.Join(dir, "replacement")
|
||||
if err := os.WriteFile(next, []byte(value), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(next, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("synthetic-first\n")
|
||||
if _, err := adapter.HasEnrolledFactor(context.Background(), "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write("synthetic-renewed")
|
||||
if err := adapter.ValidateMFAToken(context.Background(), "alice", "synthetic-otp"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := adapter.HasEnrolledFactor(context.Background(), "alice"); err == nil {
|
||||
t.Fatal("missing file reused stale credential")
|
||||
}
|
||||
if err := adapter.ValidateMFAToken(context.Background(), "alice", "synthetic-otp"); err == nil {
|
||||
t.Fatal("validation reused stale credential")
|
||||
}
|
||||
write("synthetic-restored")
|
||||
if _, err := adapter.HasEnrolledFactor(context.Background(), "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(seen, ",") != "synthetic-first,synthetic-renewed,synthetic-restored" {
|
||||
t.Fatal("renewal not used on next request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialSourceErrorsNeverReachProviderOrExposeValues(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, tc := range []struct {
|
||||
name, inline, content string
|
||||
missing, directory bool
|
||||
}{
|
||||
{name: "ambiguous", inline: "private-inline", content: "private-file"},
|
||||
{name: "empty"}, {name: "newline", content: "private\nvalue"},
|
||||
{name: "oversized", content: strings.Repeat("x", 16385)},
|
||||
{name: "missing", missing: true}, {name: "directory", directory: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(dir, "private-path-"+tc.name)
|
||||
if tc.directory {
|
||||
if err := os.Mkdir(path, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if !tc.missing {
|
||||
if err := os.WriteFile(path, []byte(tc.content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cfg := testConfig()
|
||||
cfg.AdminToken = tc.inline
|
||||
cfg.AdminTokenFile = path
|
||||
adapter := privacyidea.New(cfg, &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) {
|
||||
t.Fatal("invalid credential reached provider")
|
||||
return nil, nil
|
||||
}})
|
||||
_, err := adapter.HasEnrolledFactor(context.Background(), "alice")
|
||||
if err == nil {
|
||||
t.Fatal("invalid credential accepted")
|
||||
}
|
||||
if strings.Contains(err.Error(), "private") || strings.Contains(err.Error(), dir) {
|
||||
t.Fatal("credential details exposed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderFailureFlagCannotGrantAAL2(t *testing.T) {
|
||||
for _, body := range []string{`{"result":{"status":false,"value":true}}`, `{"result":{"value":true}}`, `{}`, `{"result":{"status":true,"value":false}}`} {
|
||||
adapter := privacyidea.New(testConfig(), &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) { return jsonResponse(body), nil }})
|
||||
if err := adapter.ValidateMFAToken(context.Background(), "alice", "synthetic-otp"); err == nil {
|
||||
t.Fatal("unsuccessful provider response accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionClientDoesNotFollowProviderRedirects(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.URL.Path == "/redirect-target" {
|
||||
t.Error("credential-bearing redirect followed")
|
||||
}
|
||||
http.Redirect(w, r, "/redirect-target", http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := testConfig()
|
||||
cfg.BaseURL = server.URL
|
||||
adapter := privacyidea.New(cfg, nil)
|
||||
if _, err := adapter.HasEnrolledFactor(context.Background(), "alice"); err == nil {
|
||||
t.Fatal("redirect accepted")
|
||||
}
|
||||
if err := adapter.ValidateMFAToken(context.Background(), "alice", "synthetic-otp"); err == nil {
|
||||
t.Fatal("redirect accepted")
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatal("unexpected redirect requests")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedCredentialCanRecoverAfterMountedSymlinkRenewal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "mounted-token")
|
||||
old := filepath.Join(dir, "old")
|
||||
replacement := filepath.Join(dir, "new")
|
||||
for p, value := range map[string]string{old: "synthetic-expired", replacement: "synthetic-fresh"} {
|
||||
if err := os.WriteFile(p, []byte(value), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.Symlink(old, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := testConfig()
|
||||
cfg.AdminToken = ""
|
||||
cfg.AdminTokenFile = path
|
||||
adapter := privacyidea.New(cfg, &mockHTTPClient{doFn: func(req *http.Request) (*http.Response, error) {
|
||||
resp := jsonResponse(`{"result":{"status":true,"value":{"tokens":[],"count":0}}}`)
|
||||
if req.Header.Get("Authorization") != "synthetic-fresh" {
|
||||
resp.StatusCode = http.StatusUnauthorized
|
||||
}
|
||||
return resp, nil
|
||||
}})
|
||||
if _, err := adapter.HasEnrolledFactor(context.Background(), "alice"); err == nil {
|
||||
t.Fatal("expired credential accepted")
|
||||
}
|
||||
next := filepath.Join(dir, "next-link")
|
||||
if err := os.Symlink(replacement, next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(next, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolled, err := adapter.HasEnrolledFactor(context.Background(), "alice")
|
||||
if err != nil || enrolled {
|
||||
t.Fatal("renewed credential did not recover lookup")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue