From a8ec8980656a6d0757a5a5d53a67ffd28fa27989 Mon Sep 17 00:00:00 2001 From: hardy Date: Sat, 23 May 2026 07:39:36 +0800 Subject: [PATCH 01/24] improve: refactor for the code --- core/api/security.go | 102 +++++++++ core/api/security_test.go | 106 ++++++++++ .../passwordchallenge/password_challenge.go | 164 +++++++++++++++ .../password_challenge_test.go | 29 +++ core/security/replay/replay.go | 199 ++++++++++++++++++ core/security/replay/replay_test.go | 59 ++++++ 6 files changed, 659 insertions(+) create mode 100644 core/api/security.go create mode 100644 core/api/security_test.go create mode 100644 core/security/passwordchallenge/password_challenge.go create mode 100644 core/security/passwordchallenge/password_challenge_test.go create mode 100644 core/security/replay/replay.go create mode 100644 core/security/replay/replay_test.go diff --git a/core/api/security.go b/core/api/security.go new file mode 100644 index 000000000..67226a018 --- /dev/null +++ b/core/api/security.go @@ -0,0 +1,102 @@ +package api + +import ( + "net/http" + "strings" + + httprouter "infini.sh/framework/core/api/router" + replaysecurity "infini.sh/framework/core/security/replay" +) + +type SecureTransportOptions struct { + TrustForwardHeaders bool +} + +func RequestUsesSecureTransport(req *http.Request, options ...SecureTransportOptions) bool { + if req == nil { + return false + } + if req.TLS != nil { + return true + } + + resolved := resolveSecureTransportOptions(options) + if !resolved.TrustForwardHeaders { + return false + } + + for _, header := range []string{"X-Forwarded-Proto", "X-Forwarded-Protocol", "X-Url-Scheme"} { + if headerIndicatesHTTPS(req.Header.Get(header)) { + return true + } + } + + if strings.EqualFold(strings.TrimSpace(req.Header.Get("X-Forwarded-Ssl")), "on") { + return true + } + + return forwardedHeaderIndicatesHTTPS(req.Header.Get("Forwarded")) +} + +func (handler Handler) RequireSecureTransport(h httprouter.Handle, options ...SecureTransportOptions) httprouter.Handle { + resolved := resolveSecureTransportOptions(options) + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + if !RequestUsesSecureTransport(r, resolved) { + handler.WriteError(w, "sensitive endpoints require HTTPS or a trusted HTTPS reverse proxy", http.StatusUpgradeRequired) + return + } + h(w, r, ps) + } +} + +func RequireSecureTransport(h httprouter.Handle, options ...SecureTransportOptions) httprouter.Handle { + return Handler{}.RequireSecureTransport(h, options...) +} + +func (handler Handler) RequireReplayProtection(h httprouter.Handle) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + if err := replaysecurity.ValidateAndConsumeReplayNonce(r); err != nil { + handler.WriteError(w, err.Error(), http.StatusUnauthorized) + return + } + h(w, r, ps) + } +} + +func RequireReplayProtection(h httprouter.Handle) httprouter.Handle { + return Handler{}.RequireReplayProtection(h) +} + +func resolveSecureTransportOptions(options []SecureTransportOptions) SecureTransportOptions { + if len(options) == 0 { + return SecureTransportOptions{} + } + return options[0] +} + +func headerIndicatesHTTPS(value string) bool { + if value == "" { + return false + } + first := strings.TrimSpace(strings.Split(value, ",")[0]) + return strings.EqualFold(first, "https") +} + +func forwardedHeaderIndicatesHTTPS(value string) bool { + if value == "" { + return false + } + + for _, forwardedValue := range strings.Split(value, ",") { + for _, token := range strings.Split(forwardedValue, ";") { + parts := strings.SplitN(strings.TrimSpace(token), "=", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "proto") { + continue + } + proto := strings.Trim(parts[1], "\"") + return strings.EqualFold(proto, "https") + } + } + + return false +} diff --git a/core/api/security_test.go b/core/api/security_test.go new file mode 100644 index 000000000..8b688b63a --- /dev/null +++ b/core/api/security_test.go @@ -0,0 +1,106 @@ +package api + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" + + httprouter "infini.sh/framework/core/api/router" + replaysecurity "infini.sh/framework/core/security/replay" +) + +func TestRequestUsesSecureTransport(t *testing.T) { + tests := []struct { + name string + setup func(req *http.Request) + options []SecureTransportOptions + secure bool + }{ + { + name: "tls request", + setup: func(req *http.Request) { + req.TLS = &tls.ConnectionState{} + }, + secure: true, + }, + { + name: "forwarded proto requires opt in", + setup: func(req *http.Request) { + req.Header.Set("X-Forwarded-Proto", "https") + }, + secure: false, + }, + { + name: "forwarded proto trusted when enabled", + setup: func(req *http.Request) { + req.Header.Set("X-Forwarded-Proto", "https") + }, + options: []SecureTransportOptions{{TrustForwardHeaders: true}}, + secure: true, + }, + { + name: "plain http", + setup: func(req *http.Request) {}, + secure: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://console.local/account/login", nil) + tt.setup(req) + + if RequestUsesSecureTransport(req, tt.options...) != tt.secure { + t.Fatalf("expected secure=%v", tt.secure) + } + }) + } +} + +func TestRequireSecureTransport(t *testing.T) { + handler := Handler{} + called := false + protected := handler.RequireSecureTransport(func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + called = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "http://console.local/account/login", nil) + resp := httptest.NewRecorder() + + protected(resp, req, nil) + + if called { + t.Fatal("expected insecure request to be blocked") + } + if resp.Code != http.StatusUpgradeRequired { + t.Fatalf("expected status %d, got %d", http.StatusUpgradeRequired, resp.Code) + } +} + +func TestRequireReplayProtection(t *testing.T) { + handler := Handler{} + req := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) + nonce, _, err := replaysecurity.IssueReplayNonce(req, http.MethodPost, "/account/login") + if err != nil { + t.Fatalf("issue replay nonce: %v", err) + } + req.Header.Set(replaysecurity.HeaderName, nonce) + + called := false + protected := handler.RequireReplayProtection(func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + called = true + w.WriteHeader(http.StatusOK) + }) + resp := httptest.NewRecorder() + + protected(resp, req, nil) + + if !called { + t.Fatal("expected replay-protected handler to run") + } + if resp.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, resp.Code) + } +} diff --git a/core/security/passwordchallenge/password_challenge.go b/core/security/passwordchallenge/password_challenge.go new file mode 100644 index 000000000..461914b50 --- /dev/null +++ b/core/security/passwordchallenge/password_challenge.go @@ -0,0 +1,164 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package passwordchallenge + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "sync" + "time" + + "golang.org/x/crypto/pbkdf2" + "infini.sh/framework/core/util" +) + +const ( + Method = "challenge" + Algorithm = "PBKDF2-SHA256" + Iterations = 120000 + keyLength = 32 + DefaultTTL = 5 * time.Minute +) + +type Challenge struct { + ID string + Subject string + Nonce string + ExpireAt time.Time +} + +type StoreOptions struct { + TTL time.Duration +} + +type Store struct { + mu sync.Mutex + ttl time.Duration + challenges map[string]Challenge +} + +var defaultStore = NewStore(StoreOptions{}) + +func NewStore(options StoreOptions) *Store { + ttl := options.TTL + if ttl <= 0 { + ttl = DefaultTTL + } + return &Store{ + ttl: ttl, + challenges: map[string]Challenge{}, + } +} + +func DeriveVerifier(password, salt string) (string, error) { + if password == "" { + return "", errors.New("password is empty") + } + if salt == "" { + return "", errors.New("password salt is empty") + } + key := pbkdf2.Key([]byte(password), []byte(salt), Iterations, keyLength, sha256.New) + return hex.EncodeToString(key), nil +} + +func BuildProof(verifier, subject, challengeID, nonce string) (string, error) { + key, err := hex.DecodeString(verifier) + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, key) + mac.Write([]byte(subject)) + mac.Write([]byte(":")) + mac.Write([]byte(challengeID)) + mac.Write([]byte(":")) + mac.Write([]byte(nonce)) + return hex.EncodeToString(mac.Sum(nil)), nil +} + +func VerifyProof(verifier, subject, challengeID, nonce, proof string) bool { + expected, err := BuildProof(verifier, subject, challengeID, nonce) + if err != nil { + return false + } + expectedBytes, err := hex.DecodeString(expected) + if err != nil { + return false + } + proofBytes, err := hex.DecodeString(strings.ToLower(proof)) + if err != nil { + return false + } + return hmac.Equal(expectedBytes, proofBytes) +} + +func New(subject string) Challenge { + return defaultStore.New(subject) +} + +func Consume(challengeID, subject string) (Challenge, error) { + return defaultStore.Consume(challengeID, subject) +} + +func (store *Store) New(subject string) Challenge { + now := time.Now() + store.mu.Lock() + defer store.mu.Unlock() + + for id, challenge := range store.challenges { + if challenge.ExpireAt.Before(now) { + delete(store.challenges, id) + } + } + + challenge := Challenge{ + ID: util.GenerateSecureString(32), + Subject: subject, + Nonce: util.GenerateSecureString(32), + ExpireAt: now.Add(store.ttl), + } + store.challenges[challenge.ID] = challenge + return challenge +} + +func (store *Store) Consume(challengeID, subject string) (Challenge, error) { + store.mu.Lock() + defer store.mu.Unlock() + + challenge, ok := store.challenges[challengeID] + if !ok { + return Challenge{}, errors.New("login challenge is invalid") + } + delete(store.challenges, challengeID) + + if challenge.ExpireAt.Before(time.Now()) { + return Challenge{}, errors.New("login challenge expired") + } + if challenge.Subject != subject { + return Challenge{}, errors.New("login challenge does not match user") + } + return challenge, nil +} diff --git a/core/security/passwordchallenge/password_challenge_test.go b/core/security/passwordchallenge/password_challenge_test.go new file mode 100644 index 000000000..ed9eff938 --- /dev/null +++ b/core/security/passwordchallenge/password_challenge_test.go @@ -0,0 +1,29 @@ +package passwordchallenge + +import "testing" + +func TestPasswordChallengeProofRoundTrip(t *testing.T) { + verifier, err := DeriveVerifier("admin", "salt-123") + if err != nil { + t.Fatalf("derive verifier: %v", err) + } + + challenge := New("admin") + proof, err := BuildProof(verifier, "admin", challenge.ID, challenge.Nonce) + if err != nil { + t.Fatalf("build proof: %v", err) + } + + if !VerifyProof(verifier, "admin", challenge.ID, challenge.Nonce, proof) { + t.Fatal("expected password proof to validate") + } +} + +func TestConsumeRejectsWrongSubject(t *testing.T) { + store := NewStore(StoreOptions{}) + challenge := store.New("admin") + + if _, err := store.Consume(challenge.ID, "guest"); err == nil { + t.Fatal("expected challenge subject mismatch to fail") + } +} diff --git a/core/security/replay/replay.go b/core/security/replay/replay.go new file mode 100644 index 000000000..1619f0820 --- /dev/null +++ b/core/security/replay/replay.go @@ -0,0 +1,199 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package replay + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + pathutil "path" + "strings" + "sync" + "time" + + "infini.sh/framework/core/util" +) + +const ( + HeaderName = "X-Request-Nonce" + DefaultTTL = 30 * time.Second +) + +type SubjectExtractor func(r *http.Request) string + +type StoreOptions struct { + TTL time.Duration + SubjectExtractor SubjectExtractor +} + +type nonceRecord struct { + Subject string + Method string + Path string + ExpiresAt time.Time +} + +type Store struct { + mu sync.Mutex + ttl time.Duration + subjectExtractor SubjectExtractor + records map[string]nonceRecord +} + +var defaultStore = NewStore(StoreOptions{}) + +func NewStore(options StoreOptions) *Store { + ttl := options.TTL + if ttl <= 0 { + ttl = DefaultTTL + } + extractor := options.SubjectExtractor + if extractor == nil { + extractor = DefaultSubjectExtractor + } + return &Store{ + ttl: ttl, + subjectExtractor: extractor, + records: map[string]nonceRecord{}, + } +} + +func IssueReplayNonce(r *http.Request, method, requestPath string) (string, time.Duration, error) { + return defaultStore.IssueReplayNonce(r, method, requestPath) +} + +func ValidateAndConsumeReplayNonce(r *http.Request) error { + return defaultStore.ValidateAndConsumeReplayNonce(r) +} + +func DefaultSubjectExtractor(r *http.Request) string { + if r == nil { + return "anonymous" + } + authorizationHeader := strings.TrimSpace(r.Header.Get("Authorization")) + if authorizationHeader == "" { + return "anonymous" + } + sum := sha256.Sum256([]byte(authorizationHeader)) + return hex.EncodeToString(sum[:]) +} + +func (store *Store) IssueReplayNonce(r *http.Request, method, requestPath string) (string, time.Duration, error) { + normalizedMethod, normalizedPath, err := normalizeScope(method, requestPath) + if err != nil { + return "", 0, err + } + + nonce := util.GenerateSecureString(32) + if nonce == "" { + return "", 0, fmt.Errorf("failed to generate replay nonce") + } + + subject := store.extractSubject(r) + expiresAt := time.Now().Add(store.ttl) + + store.mu.Lock() + defer store.mu.Unlock() + store.cleanupExpiredLocked(time.Now()) + store.records[nonce] = nonceRecord{ + Subject: subject, + Method: normalizedMethod, + Path: normalizedPath, + ExpiresAt: expiresAt, + } + return nonce, store.ttl, nil +} + +func (store *Store) ValidateAndConsumeReplayNonce(r *http.Request) error { + if r == nil { + return fmt.Errorf("request can not be nil") + } + + nonce := strings.TrimSpace(r.Header.Get(HeaderName)) + if nonce == "" { + return fmt.Errorf("missing replay nonce") + } + + subject := store.extractSubject(r) + method, requestPath, err := normalizeScope(r.Method, r.URL.Path) + if err != nil { + return err + } + + now := time.Now() + store.mu.Lock() + defer store.mu.Unlock() + store.cleanupExpiredLocked(now) + + record, ok := store.records[nonce] + if !ok { + return fmt.Errorf("replay nonce is invalid or expired") + } + delete(store.records, nonce) + + if record.Subject != subject || record.Method != method || record.Path != requestPath { + return fmt.Errorf("replay nonce does not match request context") + } + + return nil +} + +func (store *Store) extractSubject(r *http.Request) string { + if store == nil || store.subjectExtractor == nil { + return DefaultSubjectExtractor(r) + } + return store.subjectExtractor(r) +} + +func (store *Store) cleanupExpiredLocked(now time.Time) { + for nonce, record := range store.records { + if now.After(record.ExpiresAt) { + delete(store.records, nonce) + } + } +} + +func normalizeScope(method, requestPath string) (string, string, error) { + normalizedMethod := strings.ToUpper(strings.TrimSpace(method)) + switch normalizedMethod { + case http.MethodPost, http.MethodPut, http.MethodDelete: + default: + return "", "", fmt.Errorf("unsupported replay-protected method [%s]", method) + } + + normalizedPath := strings.TrimSpace(requestPath) + if normalizedPath == "" { + return "", "", fmt.Errorf("request path can not be empty") + } + if !strings.HasPrefix(normalizedPath, "/") { + normalizedPath = "/" + normalizedPath + } + normalizedPath = pathutil.Clean(normalizedPath) + if normalizedPath == "." { + normalizedPath = "/" + } + + return normalizedMethod, normalizedPath, nil +} diff --git a/core/security/replay/replay_test.go b/core/security/replay/replay_test.go new file mode 100644 index 000000000..b495b71c9 --- /dev/null +++ b/core/security/replay/replay_test.go @@ -0,0 +1,59 @@ +package replay + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestReplayNonceCanOnlyBeUsedOnce(t *testing.T) { + store := NewStore(StoreOptions{}) + req := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) + + nonce, _, err := store.IssueReplayNonce(req, http.MethodPost, "/account/login") + if err != nil { + t.Fatalf("issue replay nonce failed: %v", err) + } + + req.Header.Set(HeaderName, nonce) + if err := store.ValidateAndConsumeReplayNonce(req); err != nil { + t.Fatalf("expected first nonce use to succeed: %v", err) + } + if err := store.ValidateAndConsumeReplayNonce(req); err == nil { + t.Fatal("expected second nonce use to be rejected") + } +} + +func TestReplayNonceBindsToAuthorizationHeader(t *testing.T) { + store := NewStore(StoreOptions{}) + issueReq := httptest.NewRequest(http.MethodPut, "https://console.local/credential/test", nil) + issueReq.Header.Set("Authorization", "Bearer token-a") + + nonce, _, err := store.IssueReplayNonce(issueReq, http.MethodPut, "/credential/test") + if err != nil { + t.Fatalf("issue replay nonce failed: %v", err) + } + + useReq := httptest.NewRequest(http.MethodPut, "https://console.local/credential/test", nil) + useReq.Header.Set(HeaderName, nonce) + useReq.Header.Set("Authorization", "Bearer token-b") + if err := store.ValidateAndConsumeReplayNonce(useReq); err == nil { + t.Fatal("expected nonce bound to a different authorization header to fail") + } +} + +func TestReplayNonceBindsToPathAndMethod(t *testing.T) { + store := NewStore(StoreOptions{}) + issueReq := httptest.NewRequest(http.MethodPost, "https://console.local/setup/_initialize", nil) + + nonce, _, err := store.IssueReplayNonce(issueReq, http.MethodPost, "/setup/_initialize") + if err != nil { + t.Fatalf("issue replay nonce failed: %v", err) + } + + useReq := httptest.NewRequest(http.MethodPut, "https://console.local/setup/_initialize", nil) + useReq.Header.Set(HeaderName, nonce) + if err := store.ValidateAndConsumeReplayNonce(useReq); err == nil { + t.Fatal("expected nonce with mismatched method to fail") + } +} From c02f2eaba694a697f509e2dc807ce3054467b8e5 Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 11:35:29 +0800 Subject: [PATCH 02/24] docs: clarify login challenge helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/security/replay/replay.go | 2 ++ docs/content.en/docs/release-notes/_index.md | 1 + 2 files changed, 3 insertions(+) diff --git a/core/security/replay/replay.go b/core/security/replay/replay.go index 1619f0820..5727d3cb4 100644 --- a/core/security/replay/replay.go +++ b/core/security/replay/replay.go @@ -96,6 +96,8 @@ func DefaultSubjectExtractor(r *http.Request) string { if authorizationHeader == "" { return "anonymous" } + // Bind the nonce to the caller's authorization material so a replay token issued for one + // authenticated context cannot be reused with a different credential set. sum := sha256.Sum256([]byte(authorizationHeader)) return hex.EncodeToString(sum[:]) } diff --git a/docs/content.en/docs/release-notes/_index.md b/docs/content.en/docs/release-notes/_index.md index fefcf930f..ab1b3be3d 100644 --- a/docs/content.en/docs/release-notes/_index.md +++ b/docs/content.en/docs/release-notes/_index.md @@ -27,6 +27,7 @@ Information about release notes of INFINI Framework is provided here. - feat(client): support token-based authorization #288 - feat: add pluggable sink to host metrics collectors #288 - feat: add access_token to security #359 +- feat(security): add login challenge, replay protection, and secure transport helpers ### 🐛 Bug fix ### ✈️ Improvements From efb67e76dce2898983d2c69c1ad52d1da7e9a04e Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 11:38:54 +0800 Subject: [PATCH 03/24] chore: add file headers for security helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/api/security.go | 23 +++++++++++++++++++ core/api/security_test.go | 23 +++++++++++++++++++ .../password_challenge_test.go | 23 +++++++++++++++++++ core/security/replay/replay_test.go | 23 +++++++++++++++++++ 4 files changed, 92 insertions(+) diff --git a/core/api/security.go b/core/api/security.go index 67226a018..9429fab1c 100644 --- a/core/api/security.go +++ b/core/api/security.go @@ -1,3 +1,26 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + package api import ( diff --git a/core/api/security_test.go b/core/api/security_test.go index 8b688b63a..f00935918 100644 --- a/core/api/security_test.go +++ b/core/api/security_test.go @@ -1,3 +1,26 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + package api import ( diff --git a/core/security/passwordchallenge/password_challenge_test.go b/core/security/passwordchallenge/password_challenge_test.go index ed9eff938..74e834473 100644 --- a/core/security/passwordchallenge/password_challenge_test.go +++ b/core/security/passwordchallenge/password_challenge_test.go @@ -1,3 +1,26 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + package passwordchallenge import "testing" diff --git a/core/security/replay/replay_test.go b/core/security/replay/replay_test.go index b495b71c9..a22b581d0 100644 --- a/core/security/replay/replay_test.go +++ b/core/security/replay/replay_test.go @@ -1,3 +1,26 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + package replay import ( From cec184f0ccec626168dcc987b8e0596578bf5b11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 03:55:10 +0000 Subject: [PATCH 04/24] feat(security): add http filter support for transport and replay options Agent-Logs-Url: https://github.com/infinilabs/framework/sessions/ab9e4b17-e2e4-4f0f-9c33-50bb89a93c77 Co-authored-by: medcl <64487+medcl@users.noreply.github.com> --- core/api/security.go | 18 ++++ core/api/security_test.go | 24 +++++ modules/security/http_filters/security.go | 71 +++++++++++++++ .../security/http_filters/security_test.go | 91 +++++++++++++++++++ 4 files changed, 204 insertions(+) create mode 100644 modules/security/http_filters/security.go create mode 100644 modules/security/http_filters/security_test.go diff --git a/core/api/security.go b/core/api/security.go index 9429fab1c..4cc24db8e 100644 --- a/core/api/security.go +++ b/core/api/security.go @@ -35,6 +35,12 @@ type SecureTransportOptions struct { TrustForwardHeaders bool } +const ( + FeatureRequireSecureTransport = "feature_require_secure_transport" + FeatureRequireReplayProtection = "feature_require_replay_protection" + LabelTrustForwardHeaders = "label_trust_forward_headers" +) + func RequestUsesSecureTransport(req *http.Request, options ...SecureTransportOptions) bool { if req == nil { return false @@ -90,6 +96,18 @@ func RequireReplayProtection(h httprouter.Handle) httprouter.Handle { return Handler{}.RequireReplayProtection(h) } +func SecureTransportOption(options ...SecureTransportOptions) Option { + resolved := resolveSecureTransportOptions(options) + return func(o *HandlerOptions) { + Feature(FeatureRequireSecureTransport)(o) + Label(LabelTrustForwardHeaders, resolved.TrustForwardHeaders)(o) + } +} + +func ReplayProtectionOption() Option { + return Feature(FeatureRequireReplayProtection) +} + func resolveSecureTransportOptions(options []SecureTransportOptions) SecureTransportOptions { if len(options) == 0 { return SecureTransportOptions{} diff --git a/core/api/security_test.go b/core/api/security_test.go index f00935918..5c0c56a8f 100644 --- a/core/api/security_test.go +++ b/core/api/security_test.go @@ -127,3 +127,27 @@ func TestRequireReplayProtection(t *testing.T) { t.Fatalf("expected status %d, got %d", http.StatusOK, resp.Code) } } + +func TestSecureTransportOption(t *testing.T) { + options := &HandlerOptions{} + SecureTransportOption(SecureTransportOptions{TrustForwardHeaders: true})(options) + + if !options.Feature(FeatureRequireSecureTransport) { + t.Fatal("expected secure transport feature to be enabled") + } + if options.Labels == nil { + t.Fatal("expected labels to be initialized") + } + if v, ok := options.Labels[LabelTrustForwardHeaders].(bool); !ok || !v { + t.Fatalf("expected trust forward headers label to be true, got %#v", options.Labels[LabelTrustForwardHeaders]) + } +} + +func TestReplayProtectionOption(t *testing.T) { + options := &HandlerOptions{} + ReplayProtectionOption()(options) + + if !options.Feature(FeatureRequireReplayProtection) { + t.Fatal("expected replay protection feature to be enabled") + } +} diff --git a/modules/security/http_filters/security.go b/modules/security/http_filters/security.go new file mode 100644 index 000000000..9fc8bf0d9 --- /dev/null +++ b/modules/security/http_filters/security.go @@ -0,0 +1,71 @@ +/* Copyright © INFINI LTD. All rights reserved. + * Web: https://infinilabs.com + * Email: hello#infini.ltd */ + +package http_filters + +import ( + "net/http" + + log "github.com/cihub/seelog" + "infini.sh/framework/core/api" + httprouter "infini.sh/framework/core/api/router" + replaysecurity "infini.sh/framework/core/security/replay" +) + +func init() { + api.RegisterUIFilter(&SecurityFilter{}) +} + +type SecurityFilter struct { + api.Handler +} + +func (f *SecurityFilter) GetPriority() int { + return 450 +} + +func (f *SecurityFilter) ApplyFilter( + method string, + pattern string, + options *api.HandlerOptions, + next httprouter.Handle, +) httprouter.Handle { + if options == nil || (!options.Feature(api.FeatureRequireSecureTransport) && !options.Feature(api.FeatureRequireReplayProtection)) { + log.Debug(method, ",", pattern, ",skip security feature filters") + return next + } + + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + if options.Feature(api.FeatureRequireSecureTransport) { + secureOptions := api.SecureTransportOptions{ + TrustForwardHeaders: trustForwardHeadersFromOptions(options), + } + if !api.RequestUsesSecureTransport(r, secureOptions) { + f.WriteError(w, "sensitive endpoints require HTTPS or a trusted HTTPS reverse proxy", http.StatusUpgradeRequired) + return + } + } + + if options.Feature(api.FeatureRequireReplayProtection) { + if err := replaysecurity.ValidateAndConsumeReplayNonce(r); err != nil { + f.WriteError(w, err.Error(), http.StatusUnauthorized) + return + } + } + + next(w, r, ps) + } +} + +func trustForwardHeadersFromOptions(options *api.HandlerOptions) bool { + if options == nil || options.Labels == nil { + return false + } + trustValue, ok := options.Labels[api.LabelTrustForwardHeaders] + if !ok { + return false + } + trustForwardHeaders, ok := trustValue.(bool) + return ok && trustForwardHeaders +} diff --git a/modules/security/http_filters/security_test.go b/modules/security/http_filters/security_test.go new file mode 100644 index 000000000..063e9dd89 --- /dev/null +++ b/modules/security/http_filters/security_test.go @@ -0,0 +1,91 @@ +/* Copyright © INFINI LTD. All rights reserved. + * Web: https://infinilabs.com + * Email: hello#infini.ltd */ + +package http_filters + +import ( + "net/http" + "net/http/httptest" + "testing" + + "infini.sh/framework/core/api" + httprouter "infini.sh/framework/core/api/router" + replaysecurity "infini.sh/framework/core/security/replay" +) + +func TestSecurityFilterSecureTransportFeature(t *testing.T) { + filter := &SecurityFilter{} + options := &api.HandlerOptions{} + api.SecureTransportOption()(options) + + called := false + protected := filter.ApplyFilter(http.MethodPost, "/account/login", options, func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + called = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "http://console.local/account/login", nil) + resp := httptest.NewRecorder() + protected(resp, req, nil) + + if called { + t.Fatal("expected insecure request to be blocked") + } + if resp.Code != http.StatusUpgradeRequired { + t.Fatalf("expected status %d, got %d", http.StatusUpgradeRequired, resp.Code) + } +} + +func TestSecurityFilterReplayProtectionFeature(t *testing.T) { + filter := &SecurityFilter{} + options := &api.HandlerOptions{} + api.ReplayProtectionOption()(options) + + req := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) + nonce, _, err := replaysecurity.IssueReplayNonce(req, http.MethodPost, "/account/login") + if err != nil { + t.Fatalf("issue replay nonce: %v", err) + } + req.Header.Set(replaysecurity.HeaderName, nonce) + + called := false + protected := filter.ApplyFilter(http.MethodPost, "/account/login", options, func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + called = true + w.WriteHeader(http.StatusOK) + }) + + resp := httptest.NewRecorder() + protected(resp, req, nil) + + if !called { + t.Fatal("expected replay-protected handler to run") + } + if resp.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, resp.Code) + } +} + +func TestSecurityFilterReadsTrustForwardHeadersLabel(t *testing.T) { + filter := &SecurityFilter{} + options := &api.HandlerOptions{} + api.SecureTransportOption(api.SecureTransportOptions{TrustForwardHeaders: true})(options) + + called := false + protected := filter.ApplyFilter(http.MethodPost, "/account/login", options, func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + called = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "http://console.local/account/login", nil) + req.Header.Set("X-Forwarded-Proto", "https") + resp := httptest.NewRecorder() + protected(resp, req, nil) + + if !called { + t.Fatal("expected trusted forwarded proto request to be allowed") + } + if resp.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, resp.Code) + } +} From cd5f34a137713a3b0d9fe79c62be9d43f69de1a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 03:57:30 +0000 Subject: [PATCH 05/24] fixup(security): address review nits in security filter Agent-Logs-Url: https://github.com/infinilabs/framework/sessions/ab9e4b17-e2e4-4f0f-9c33-50bb89a93c77 Co-authored-by: medcl <64487+medcl@users.noreply.github.com> --- config/generated.go | 10 +++++----- modules/security/http_filters/security.go | 2 +- modules/security/http_filters/security_test.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/config/generated.go b/config/generated.go index baf497913..5a2edc402 100644 --- a/config/generated.go +++ b/config/generated.go @@ -1,11 +1,11 @@ package config -const LastCommitLog = "N/A" +const LastCommitLog = "cec184f0ccec626168dcc987b8e0596578bf5b11" -const BuildDate = "N/A" +const BuildDate = "2026-05-26T03:55:24Z" -const EOLDate = "N/A" +const EOLDate = "2023-12-31T10:10:10Z" -const Version = "0.0.1-SNAPSHOT" +const Version = "1.0.0_SNAPSHOT" -const BuildNumber = "001" +const BuildNumber = "001" diff --git a/modules/security/http_filters/security.go b/modules/security/http_filters/security.go index 9fc8bf0d9..b710b3b62 100644 --- a/modules/security/http_filters/security.go +++ b/modules/security/http_filters/security.go @@ -32,7 +32,7 @@ func (f *SecurityFilter) ApplyFilter( next httprouter.Handle, ) httprouter.Handle { if options == nil || (!options.Feature(api.FeatureRequireSecureTransport) && !options.Feature(api.FeatureRequireReplayProtection)) { - log.Debug(method, ",", pattern, ",skip security feature filters") + log.Debug(method, ",", pattern, ", skip security feature filters") return next } diff --git a/modules/security/http_filters/security_test.go b/modules/security/http_filters/security_test.go index 063e9dd89..c44f6a945 100644 --- a/modules/security/http_filters/security_test.go +++ b/modules/security/http_filters/security_test.go @@ -66,7 +66,7 @@ func TestSecurityFilterReplayProtectionFeature(t *testing.T) { } } -func TestSecurityFilterReadsTrustForwardHeadersLabel(t *testing.T) { +func TestSecurityFilterWithTrustedForwardHeaders(t *testing.T) { filter := &SecurityFilter{} options := &api.HandlerOptions{} api.SecureTransportOption(api.SecureTransportOptions{TrustForwardHeaders: true})(options) From b604cfab24a49ab1cb620652ccc90bd93202c584 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 03:57:51 +0000 Subject: [PATCH 06/24] fixup: remove unintended generated config change Agent-Logs-Url: https://github.com/infinilabs/framework/sessions/ab9e4b17-e2e4-4f0f-9c33-50bb89a93c77 Co-authored-by: medcl <64487+medcl@users.noreply.github.com> --- config/generated.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/config/generated.go b/config/generated.go index 5a2edc402..baf497913 100644 --- a/config/generated.go +++ b/config/generated.go @@ -1,11 +1,11 @@ package config -const LastCommitLog = "cec184f0ccec626168dcc987b8e0596578bf5b11" +const LastCommitLog = "N/A" -const BuildDate = "2026-05-26T03:55:24Z" +const BuildDate = "N/A" -const EOLDate = "2023-12-31T10:10:10Z" +const EOLDate = "N/A" -const Version = "1.0.0_SNAPSHOT" +const Version = "0.0.1-SNAPSHOT" -const BuildNumber = "001" +const BuildNumber = "001" From 5de4829feb6901ca381acdddd2fbf84c481d413d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 03:59:21 +0000 Subject: [PATCH 07/24] improve(security): clarify https requirement error message Agent-Logs-Url: https://github.com/infinilabs/framework/sessions/ab9e4b17-e2e4-4f0f-9c33-50bb89a93c77 Co-authored-by: medcl <64487+medcl@users.noreply.github.com> --- config/generated.go | 10 +++++----- core/api/security.go | 2 +- modules/security/http_filters/security.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/config/generated.go b/config/generated.go index baf497913..1bd019f70 100644 --- a/config/generated.go +++ b/config/generated.go @@ -1,11 +1,11 @@ package config -const LastCommitLog = "N/A" +const LastCommitLog = "b604cfab24a49ab1cb620652ccc90bd93202c584" -const BuildDate = "N/A" +const BuildDate = "2026-05-26T03:57:58Z" -const EOLDate = "N/A" +const EOLDate = "2023-12-31T10:10:10Z" -const Version = "0.0.1-SNAPSHOT" +const Version = "1.0.0_SNAPSHOT" -const BuildNumber = "001" +const BuildNumber = "001" diff --git a/core/api/security.go b/core/api/security.go index 4cc24db8e..5f5955b7f 100644 --- a/core/api/security.go +++ b/core/api/security.go @@ -71,7 +71,7 @@ func (handler Handler) RequireSecureTransport(h httprouter.Handle, options ...Se resolved := resolveSecureTransportOptions(options) return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { if !RequestUsesSecureTransport(r, resolved) { - handler.WriteError(w, "sensitive endpoints require HTTPS or a trusted HTTPS reverse proxy", http.StatusUpgradeRequired) + handler.WriteError(w, "this endpoint requires HTTPS. use https:// directly or route through a trusted HTTPS reverse proxy", http.StatusUpgradeRequired) return } h(w, r, ps) diff --git a/modules/security/http_filters/security.go b/modules/security/http_filters/security.go index b710b3b62..451fc17c3 100644 --- a/modules/security/http_filters/security.go +++ b/modules/security/http_filters/security.go @@ -42,7 +42,7 @@ func (f *SecurityFilter) ApplyFilter( TrustForwardHeaders: trustForwardHeadersFromOptions(options), } if !api.RequestUsesSecureTransport(r, secureOptions) { - f.WriteError(w, "sensitive endpoints require HTTPS or a trusted HTTPS reverse proxy", http.StatusUpgradeRequired) + f.WriteError(w, "this endpoint requires HTTPS. use https:// directly or route through a trusted HTTPS reverse proxy", http.StatusUpgradeRequired) return } } From d7bad6c529a25a38e6749c66e6f3d5a02af728c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 03:59:34 +0000 Subject: [PATCH 08/24] fixup: revert generated metadata file Agent-Logs-Url: https://github.com/infinilabs/framework/sessions/ab9e4b17-e2e4-4f0f-9c33-50bb89a93c77 Co-authored-by: medcl <64487+medcl@users.noreply.github.com> --- config/generated.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/config/generated.go b/config/generated.go index 1bd019f70..baf497913 100644 --- a/config/generated.go +++ b/config/generated.go @@ -1,11 +1,11 @@ package config -const LastCommitLog = "b604cfab24a49ab1cb620652ccc90bd93202c584" +const LastCommitLog = "N/A" -const BuildDate = "2026-05-26T03:57:58Z" +const BuildDate = "N/A" -const EOLDate = "2023-12-31T10:10:10Z" +const EOLDate = "N/A" -const Version = "1.0.0_SNAPSHOT" +const Version = "0.0.1-SNAPSHOT" -const BuildNumber = "001" +const BuildNumber = "001" From 5826ab9ca6674e1b879d9fc4b74c5b8e572c64fd Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 14:56:23 +0800 Subject: [PATCH 09/24] feat: add native account challenge login Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/security/password_challenge.go | 115 ++++++++ core/security/password_challenge_test.go | 86 ++++++ core/security/user_profile.go | 10 +- docs/content.en/docs/release-notes/_index.md | 2 +- modules/security/account/profile.go | 13 +- modules/security/http_filters/json_mask.go | 12 +- modules/security/rbac/account_login.go | 279 +++++++++++++++++++ modules/security/rbac/account_login_test.go | 90 ++++++ modules/security/rbac/init.go | 1 + modules/security/rbac/user.go | 24 +- 10 files changed, 607 insertions(+), 25 deletions(-) create mode 100644 core/security/password_challenge.go create mode 100644 core/security/password_challenge_test.go create mode 100644 modules/security/rbac/account_login.go create mode 100644 modules/security/rbac/account_login_test.go diff --git a/core/security/password_challenge.go b/core/security/password_challenge.go new file mode 100644 index 000000000..aa521781d --- /dev/null +++ b/core/security/password_challenge.go @@ -0,0 +1,115 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package security + +import ( + "errors" + + "golang.org/x/crypto/bcrypt" + passwordchallenge "infini.sh/framework/core/security/passwordchallenge" + "infini.sh/framework/core/util" +) + +const ( + PasswordChallengeMethod = passwordchallenge.Method + PasswordChallengeAlgorithm = passwordchallenge.Algorithm + PasswordChallengeIterations = passwordchallenge.Iterations +) + +type LoginChallenge = passwordchallenge.Challenge + +func CanUsePasswordChallenge(user *UserAccount) bool { + return user != nil && user.PasswordSalt != "" && user.PasswordVerifier != "" +} + +func SetPassword(user *UserAccount, password string) error { + if user == nil { + return errors.New("user is nil") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + salt := util.GenerateSecureString(32) + verifier, err := DerivePasswordVerifier(password, salt) + if err != nil { + return err + } + + user.Password = string(hash) + user.PasswordSalt = salt + user.PasswordVerifier = verifier + return nil +} + +func EnsurePasswordChallenge(user *UserAccount, password string) error { + if user == nil { + return errors.New("user is nil") + } + if CanUsePasswordChallenge(user) { + return nil + } + + salt := util.GenerateSecureString(32) + verifier, err := DerivePasswordVerifier(password, salt) + if err != nil { + return err + } + + user.PasswordSalt = salt + user.PasswordVerifier = verifier + return nil +} + +func VerifyPassword(user *UserAccount, password string) error { + if user == nil { + return errors.New("user is nil") + } + if user.Password == "" { + return errors.New("password is not set") + } + return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) +} + +func DerivePasswordVerifier(password, salt string) (string, error) { + return passwordchallenge.DeriveVerifier(password, salt) +} + +func BuildPasswordProof(verifier, subject, challengeID, nonce string) (string, error) { + return passwordchallenge.BuildProof(verifier, subject, challengeID, nonce) +} + +func VerifyPasswordProof(verifier, subject, challengeID, nonce, proof string) bool { + return passwordchallenge.VerifyProof(verifier, subject, challengeID, nonce, proof) +} + +func NewLoginChallenge(subject string) LoginChallenge { + return passwordchallenge.New(subject) +} + +func ConsumeLoginChallenge(challengeID, subject string) (LoginChallenge, error) { + return passwordchallenge.Consume(challengeID, subject) +} diff --git a/core/security/password_challenge_test.go b/core/security/password_challenge_test.go new file mode 100644 index 000000000..aac0562a6 --- /dev/null +++ b/core/security/password_challenge_test.go @@ -0,0 +1,86 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package security + +import "testing" + +func TestSetPasswordPopulatesChallengeFields(t *testing.T) { + user := &UserAccount{} + if err := SetPassword(user, "StrongPassw0rd!"); err != nil { + t.Fatalf("set password: %v", err) + } + + if user.Password == "" { + t.Fatal("expected password hash to be set") + } + if user.PasswordSalt == "" { + t.Fatal("expected password salt to be set") + } + if user.PasswordVerifier == "" { + t.Fatal("expected password verifier to be set") + } + if err := VerifyPassword(user, "StrongPassw0rd!"); err != nil { + t.Fatalf("verify password: %v", err) + } +} + +func TestEnsurePasswordChallengePreservesExistingVerifier(t *testing.T) { + user := &UserAccount{} + if err := SetPassword(user, "StrongPassw0rd!"); err != nil { + t.Fatalf("set password: %v", err) + } + + originalSalt := user.PasswordSalt + originalVerifier := user.PasswordVerifier + if err := EnsurePasswordChallenge(user, "AnotherStrongPassw0rd!"); err != nil { + t.Fatalf("ensure password challenge: %v", err) + } + + if user.PasswordSalt != originalSalt { + t.Fatal("expected existing password salt to be preserved") + } + if user.PasswordVerifier != originalVerifier { + t.Fatal("expected existing password verifier to be preserved") + } +} + +func TestPasswordChallengeProofRoundTrip(t *testing.T) { + user := &UserAccount{} + login := "admin@example.org" + password := "StrongPassw0rd!" + + if err := SetPassword(user, password); err != nil { + t.Fatalf("set password: %v", err) + } + + challenge := NewLoginChallenge(login) + proof, err := BuildPasswordProof(user.PasswordVerifier, login, challenge.ID, challenge.Nonce) + if err != nil { + t.Fatalf("build password proof: %v", err) + } + + if !VerifyPasswordProof(user.PasswordVerifier, login, challenge.ID, challenge.Nonce, proof) { + t.Fatal("expected challenge proof to validate") + } +} diff --git a/core/security/user_profile.go b/core/security/user_profile.go index cd6936fe5..82b8af12b 100644 --- a/core/security/user_profile.go +++ b/core/security/user_profile.go @@ -33,10 +33,12 @@ type User struct { type UserAccount struct { orm.ORMObjectBase - Name string `json:"name,omitempty" elastic_mapping:"name: { type: keyword }" validate:"required" ` - Email string `json:"email,omitempty" elastic_mapping:"email: { type: keyword }" validate:"required|email" ` //unique - Roles []string `json:"roles,omitempty" elastic_mapping:"roles: { type: keyword }"` - Password string `json:"password,omitempty" elastic_mapping:"password: { type: keyword }"` + Name string `json:"name,omitempty" elastic_mapping:"name: { type: keyword }" validate:"required" ` + Email string `json:"email,omitempty" elastic_mapping:"email: { type: keyword }" validate:"required|email" ` //unique + Roles []string `json:"roles,omitempty" elastic_mapping:"roles: { type: keyword }"` + Password string `json:"password,omitempty" elastic_mapping:"password: { type: keyword }"` + PasswordSalt string `json:"password_salt,omitempty" elastic_mapping:"password_salt: { type: keyword }"` + PasswordVerifier string `json:"password_verifier,omitempty" elastic_mapping:"password_verifier: { type: keyword }"` } type UserProfile struct { diff --git a/docs/content.en/docs/release-notes/_index.md b/docs/content.en/docs/release-notes/_index.md index ab1b3be3d..9996d3443 100644 --- a/docs/content.en/docs/release-notes/_index.md +++ b/docs/content.en/docs/release-notes/_index.md @@ -27,7 +27,7 @@ Information about release notes of INFINI Framework is provided here. - feat(client): support token-based authorization #288 - feat: add pluggable sink to host metrics collectors #288 - feat: add access_token to security #359 -- feat(security): add login challenge, replay protection, and secure transport helpers +- feat(security): add native account login challenge, replay protection, and secure transport helpers ### 🐛 Bug fix ### ✈️ Improvements diff --git a/modules/security/account/profile.go b/modules/security/account/profile.go index 3bb81aced..107da9a79 100644 --- a/modules/security/account/profile.go +++ b/modules/security/account/profile.go @@ -28,10 +28,21 @@ func Profile(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { } p := &security.UserProfile{ - Name: reqUser.Login, + Name: reqUser.Login, + Roles: reqUser.Roles, } p.ID = reqUser.UserID + if reqUser.Provider == security.DefaultNativeAuthBackend { + if _, account, err := security.GetUserByID(reqUser.UserID); err == nil && account != nil { + if account.Name != "" { + p.Name = account.Name + } + p.Email = account.Email + p.Roles = account.Roles + } + } + //get all permissions for user p.Permissions = security.GetAllPermissionsForUser(reqUser) diff --git a/modules/security/http_filters/json_mask.go b/modules/security/http_filters/json_mask.go index f98f88f6a..998b4d924 100644 --- a/modules/security/http_filters/json_mask.go +++ b/modules/security/http_filters/json_mask.go @@ -18,11 +18,13 @@ const FeatureRemoveSensitiveField = "feature_sensitive_fields_remove_sensitive_f const SensitiveFields = "feature_sensitive_fields_extra_keys" var sensitiveFields = map[string]bool{ - "password": true, - "token": true, - "secret": true, - "access_token": true, - "refresh_token": true, + "password": true, + "password_salt": true, + "password_verifier": true, + "token": true, + "secret": true, + "access_token": true, + "refresh_token": true, } type JSONMaskFilter struct{} diff --git a/modules/security/rbac/account_login.go b/modules/security/rbac/account_login.go new file mode 100644 index 000000000..d0f7c948d --- /dev/null +++ b/modules/security/rbac/account_login.go @@ -0,0 +1,279 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package rbac + +import ( + "errors" + "net/http" + "strings" + "time" + + log "github.com/cihub/seelog" + "infini.sh/framework/core/api" + httprouter "infini.sh/framework/core/api/router" + "infini.sh/framework/core/orm" + "infini.sh/framework/core/security" + replaysecurity "infini.sh/framework/core/security/replay" + "infini.sh/framework/core/util" +) + +var ( + errInvalidLoginCredentials = errors.New("invalid login or password") + errIncompleteChallenge = errors.New("challenge response is incomplete") + errMissingPassword = errors.New("password is required") +) + +type accountLoginRequest struct { + Login string `json:"login"` + Email string `json:"email"` + Username string `json:"username"` + UserName string `json:"userName"` + Password string `json:"password"` + ChallengeID string `json:"challenge_id"` + Proof string `json:"proof"` +} + +func registerAccountRoutes() { + api.HandleUIMethod(api.POST, "/account/replay_nonce", + api.RequireSecureTransport(IssueReplayNonce), + api.AllowPublicAccess(), + api.AllowOPTIONSS(), + api.Feature(api.FeatureCORS)) + + api.HandleUIMethod(api.POST, "/account/login/challenge", + api.RequireSecureTransport(LoginChallenge), + api.AllowPublicAccess(), + api.AllowOPTIONSS(), + api.Feature(api.FeatureCORS)) + + api.HandleUIMethod(api.POST, "/account/login", + api.RequireSecureTransport(Login), + api.AllowPublicAccess(), + api.AllowOPTIONSS(), + api.Feature(api.FeatureCORS)) +} + +func IssueReplayNonce(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + var req struct { + Method string `json:"method"` + Path string `json:"path"` + } + + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteError(w, err.Error(), http.StatusBadRequest) + return + } + + nonce, ttl, err := replaysecurity.IssueReplayNonce(r, req.Method, req.Path) + if err != nil { + api.WriteError(w, err.Error(), http.StatusBadRequest) + return + } + + api.WriteOKJSON(w, util.MapStr{ + "status": "ok", + "nonce": nonce, + "expire_in_seconds": int(ttl / time.Second), + }) +} + +func LoginChallenge(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + var req accountLoginRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteError(w, err.Error(), http.StatusBadRequest) + return + } + + login := req.NormalizedLogin() + if login == "" { + api.WriteError(w, "login is required", http.StatusBadRequest) + return + } + + exists, user, err := lookupAccountByLogin(login) + if err != nil { + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + + api.WriteOKJSON(w, buildLoginChallengeResponse(login, exists, user)) +} + +func Login(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + var req accountLoginRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteError(w, err.Error(), http.StatusBadRequest) + return + } + + login := req.NormalizedLogin() + if login == "" { + api.WriteError(w, "login is required", http.StatusBadRequest) + return + } + + exists, user, err := lookupAccountByLogin(login) + if err != nil { + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + if !exists || user == nil { + api.WriteError(w, errInvalidLoginCredentials.Error(), http.StatusForbidden) + return + } + + usedChallenge := req.ChallengeID != "" || req.Proof != "" + if err := validateReplayNonce(r, usedChallenge); err != nil { + api.WriteError(w, err.Error(), http.StatusUnauthorized) + return + } + + usedChallenge, err = authenticateLogin(user, login, req.Password, req.ChallengeID, req.Proof) + if err != nil { + statusCode := http.StatusForbidden + if errors.Is(err, errIncompleteChallenge) || errors.Is(err, errMissingPassword) { + statusCode = http.StatusBadRequest + } + api.WriteError(w, err.Error(), statusCode) + return + } + + if !usedChallenge { + upgradePasswordChallenge(user, req.Password) + } + + sessionUser := newNativeSession(user, login) + if err, token := security.AddUserToSession(w, r, sessionUser); err != nil { + api.WriteError(w, err.Error(), http.StatusInternalServerError) + } else { + api.WriteOKJSON(w, token) + } +} + +func (req accountLoginRequest) NormalizedLogin() string { + for _, candidate := range []string{req.Login, req.Email, req.Username, req.UserName} { + if value := strings.TrimSpace(candidate); value != "" { + return value + } + } + return "" +} + +func buildLoginChallengeResponse(login string, exists bool, user *security.UserAccount) util.MapStr { + if exists && security.CanUsePasswordChallenge(user) { + challenge := security.NewLoginChallenge(login) + return util.MapStr{ + "status": "ok", + "method": security.PasswordChallengeMethod, + "algorithm": security.PasswordChallengeAlgorithm, + "iterations": security.PasswordChallengeIterations, + "challenge_id": challenge.ID, + "nonce": challenge.Nonce, + "salt": user.PasswordSalt, + } + } + + return util.MapStr{ + "status": "ok", + "method": "plain", + } +} + +func authenticateLogin(user *security.UserAccount, login, password, challengeID, proof string) (bool, error) { + if user == nil { + return false, errInvalidLoginCredentials + } + + if challengeID != "" || proof != "" { + if challengeID == "" || proof == "" { + return true, errIncompleteChallenge + } + + challenge, err := security.ConsumeLoginChallenge(challengeID, login) + if err != nil || !security.CanUsePasswordChallenge(user) { + return true, errInvalidLoginCredentials + } + if !security.VerifyPasswordProof(user.PasswordVerifier, login, challenge.ID, challenge.Nonce, proof) { + return true, errInvalidLoginCredentials + } + return true, nil + } + + if password == "" { + return false, errMissingPassword + } + if err := security.VerifyPassword(user, password); err != nil { + return false, errInvalidLoginCredentials + } + return false, nil +} + +func lookupAccountByLogin(login string) (bool, *security.UserAccount, error) { + exists, user, err := GetUserByLogin(login) + if err != nil && err.Error() == "not found" { + return false, nil, nil + } + return exists, user, err +} + +func validateReplayNonce(r *http.Request, required bool) error { + nonce := strings.TrimSpace(r.Header.Get(replaysecurity.HeaderName)) + if nonce == "" && !required { + return nil + } + return replaysecurity.ValidateAndConsumeReplayNonce(r) +} + +func upgradePasswordChallenge(user *security.UserAccount, password string) { + if user == nil || password == "" || security.CanUsePasswordChallenge(user) { + return + } + + if err := security.EnsurePasswordChallenge(user, password); err != nil { + log.Warnf("failed to derive password challenge for user [%s]: %v", user.Email, err) + return + } + + ctx := orm.NewContext() + ctx.DirectAccess() + ctx.Refresh = orm.WaitForRefresh + if err := orm.Update(ctx, user); err != nil { + log.Warnf("failed to persist password challenge for user [%s]: %v", user.Email, err) + } +} + +func newNativeSession(user *security.UserAccount, login string) *security.UserSessionInfo { + userLogin := strings.TrimSpace(user.Email) + if userLogin == "" { + userLogin = login + } + + session := &security.UserSessionInfo{ + Provider: security.DefaultNativeAuthBackend, + Login: userLogin, + Roles: append([]string(nil), user.Roles...), + } + session.SetUserID(user.ID) + return session +} diff --git a/modules/security/rbac/account_login_test.go b/modules/security/rbac/account_login_test.go new file mode 100644 index 000000000..859507ab2 --- /dev/null +++ b/modules/security/rbac/account_login_test.go @@ -0,0 +1,90 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package rbac + +import ( + "errors" + "testing" + + "infini.sh/framework/core/security" +) + +func TestAccountLoginRequestNormalizedLogin(t *testing.T) { + req := accountLoginRequest{ + Email: "admin@example.org", + Username: "ignored@example.org", + } + + if got := req.NormalizedLogin(); got != "admin@example.org" { + t.Fatalf("expected email to be preferred, got %q", got) + } +} + +func TestAuthenticateLoginWithPassword(t *testing.T) { + user := &security.UserAccount{Email: "admin@example.org"} + if err := security.SetPassword(user, "StrongPassw0rd!"); err != nil { + t.Fatalf("set password: %v", err) + } + + usedChallenge, err := authenticateLogin(user, user.Email, "StrongPassw0rd!", "", "") + if err != nil { + t.Fatalf("authenticate login: %v", err) + } + if usedChallenge { + t.Fatal("expected password login path") + } +} + +func TestAuthenticateLoginWithChallenge(t *testing.T) { + user := &security.UserAccount{Email: "admin@example.org"} + if err := security.SetPassword(user, "StrongPassw0rd!"); err != nil { + t.Fatalf("set password: %v", err) + } + + challenge := security.NewLoginChallenge(user.Email) + proof, err := security.BuildPasswordProof(user.PasswordVerifier, user.Email, challenge.ID, challenge.Nonce) + if err != nil { + t.Fatalf("build password proof: %v", err) + } + + usedChallenge, err := authenticateLogin(user, user.Email, "", challenge.ID, proof) + if err != nil { + t.Fatalf("authenticate login: %v", err) + } + if !usedChallenge { + t.Fatal("expected challenge login path") + } +} + +func TestAuthenticateLoginRejectsIncompleteChallenge(t *testing.T) { + user := &security.UserAccount{Email: "admin@example.org"} + if err := security.SetPassword(user, "StrongPassw0rd!"); err != nil { + t.Fatalf("set password: %v", err) + } + + _, err := authenticateLogin(user, user.Email, "", "challenge-id", "") + if !errors.Is(err, errIncompleteChallenge) { + t.Fatalf("expected incomplete challenge error, got %v", err) + } +} diff --git a/modules/security/rbac/init.go b/modules/security/rbac/init.go index 4caa07f65..1a2a022ab 100644 --- a/modules/security/rbac/init.go +++ b/modules/security/rbac/init.go @@ -18,6 +18,7 @@ func Init() { provider := SecurityBackendProvider{} security.RegisterAuthenticationProvider(security.DefaultNativeAuthBackend, &provider) security.RegisterAuthorizationProvider(security.DefaultNativeAuthBackend, &provider) + registerAccountRoutes() orm.MustRegisterSchemaWithIndexName(&security.UserAccount{}, "app-users") orm.MustRegisterSchemaWithIndexName(&security.UserRole{}, "app-roles") diff --git a/modules/security/rbac/user.go b/modules/security/rbac/user.go index 569bc1917..5b2a1f483 100644 --- a/modules/security/rbac/user.go +++ b/modules/security/rbac/user.go @@ -8,7 +8,6 @@ import ( "net/http" log "github.com/cihub/seelog" - "golang.org/x/crypto/bcrypt" "infini.sh/framework/core/api" httprouter "infini.sh/framework/core/api/router" "infini.sh/framework/core/elastic" @@ -74,15 +73,15 @@ func UpdateUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) if obj.Password == "" { obj.Password = oldObj.Password + obj.PasswordSalt = oldObj.PasswordSalt + obj.PasswordVerifier = oldObj.PasswordVerifier } else { if !util.ValidateSecure(obj.Password) { panic("should be secured password") } - hash, err := bcrypt.GenerateFromPassword([]byte(obj.Password), bcrypt.DefaultCost) - if err != nil { + if err := security.SetPassword(&obj, obj.Password); err != nil { panic(err) } - obj.Password = string(hash) } ctx.Refresh = orm.WaitForRefresh err = orm.Update(ctx, &obj) @@ -204,17 +203,15 @@ func (provider *SecurityBackendProvider) CreateUser(name, email, password string log.Warn("email already exists, will be replaced") obj.ID = account.ID } else { - obj.ID = getUIDByEmail(obj.Email) + obj.ID = getUIDByEmail(email) } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - panic(err) - } obj.Name = name obj.Email = email obj.Roles = []string{security.RoleAdmin} - obj.Password = string(hash) + if err := security.SetPassword(obj, password); err != nil { + panic(err) + } ctx := orm.NewContext() ctx.DirectAccess() @@ -252,13 +249,10 @@ func CreateUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) } randStr := util.GenerateSecureString(8) - hash, err := bcrypt.GenerateFromPassword([]byte(randStr), bcrypt.DefaultCost) - if err != nil { + if err := security.SetPassword(obj, randStr); err != nil { panic(err) } - obj.Password = string(hash) - ctx := orm.NewContextWithParent(req.Context()) ctx.Refresh = orm.WaitForRefresh err = orm.Save(ctx, obj) @@ -267,5 +261,7 @@ func CreateUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) } obj.Password = randStr + obj.PasswordSalt = "" + obj.PasswordVerifier = "" api.WriteJSON(w, obj, 200) } From 18578aa335f7472ecf94c39918dae364418aec3a Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 15:05:29 +0800 Subject: [PATCH 10/24] test: document native challenge login flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/security/password_challenge.go | 4 + core/security/password_challenge_test.go | 17 ++++ modules/security/rbac/account_login.go | 8 ++ modules/security/rbac/account_login_test.go | 89 +++++++++++++++++++++ modules/security/rbac/user.go | 4 + 5 files changed, 122 insertions(+) diff --git a/core/security/password_challenge.go b/core/security/password_challenge.go index aa521781d..dbd801d4d 100644 --- a/core/security/password_challenge.go +++ b/core/security/password_challenge.go @@ -53,6 +53,8 @@ func SetPassword(user *UserAccount, password string) error { return err } + // Store both the bcrypt hash for existing password checks and the derived + // verifier for challenge login so the two login modes stay in sync. salt := util.GenerateSecureString(32) verifier, err := DerivePasswordVerifier(password, salt) if err != nil { @@ -73,6 +75,8 @@ func EnsurePasswordChallenge(user *UserAccount, password string) error { return nil } + // This is used as an in-place upgrade path for older native accounts that only + // have a bcrypt password hash from before challenge login was introduced. salt := util.GenerateSecureString(32) verifier, err := DerivePasswordVerifier(password, salt) if err != nil { diff --git a/core/security/password_challenge_test.go b/core/security/password_challenge_test.go index aac0562a6..3b0b77560 100644 --- a/core/security/password_challenge_test.go +++ b/core/security/password_challenge_test.go @@ -84,3 +84,20 @@ func TestPasswordChallengeProofRoundTrip(t *testing.T) { t.Fatal("expected challenge proof to validate") } } + +func TestEnsurePasswordChallengePopulatesLegacyAccount(t *testing.T) { + user := &UserAccount{Password: "existing-bcrypt-hash"} + if err := EnsurePasswordChallenge(user, "StrongPassw0rd!"); err != nil { + t.Fatalf("ensure password challenge: %v", err) + } + + if user.PasswordSalt == "" { + t.Fatal("expected password salt to be populated") + } + if user.PasswordVerifier == "" { + t.Fatal("expected password verifier to be populated") + } + if !CanUsePasswordChallenge(user) { + t.Fatal("expected legacy account to become challenge-capable") + } +} diff --git a/modules/security/rbac/account_login.go b/modules/security/rbac/account_login.go index d0f7c948d..c846f7152 100644 --- a/modules/security/rbac/account_login.go +++ b/modules/security/rbac/account_login.go @@ -55,6 +55,8 @@ type accountLoginRequest struct { } func registerAccountRoutes() { + // These endpoints are only registered from rbac.Init(), so they exist only when + // native authentication is enabled and the native user backend is ready. api.HandleUIMethod(api.POST, "/account/replay_nonce", api.RequireSecureTransport(IssueReplayNonce), api.AllowPublicAccess(), @@ -182,6 +184,8 @@ func (req accountLoginRequest) NormalizedLogin() string { func buildLoginChallengeResponse(login string, exists bool, user *security.UserAccount) util.MapStr { if exists && security.CanUsePasswordChallenge(user) { + // The challenge payload gives clients everything needed to derive a proof + // locally without sending the raw password back to the server. challenge := security.NewLoginChallenge(login) return util.MapStr{ "status": "ok", @@ -240,6 +244,8 @@ func lookupAccountByLogin(login string) (bool, *security.UserAccount, error) { func validateReplayNonce(r *http.Request, required bool) error { nonce := strings.TrimSpace(r.Header.Get(replaysecurity.HeaderName)) if nonce == "" && !required { + // Keep the original password login path backward compatible: upgraded clients + // send replay nonces, while older clients can still post passwords directly. return nil } return replaysecurity.ValidateAndConsumeReplayNonce(r) @@ -255,6 +261,8 @@ func upgradePasswordChallenge(user *security.UserAccount, password string) { return } + // Persist the verifier after a successful legacy password login so subsequent + // logins can move onto the challenge flow without an explicit migration step. ctx := orm.NewContext() ctx.DirectAccess() ctx.Refresh = orm.WaitForRefresh diff --git a/modules/security/rbac/account_login_test.go b/modules/security/rbac/account_login_test.go index 859507ab2..d9073cbac 100644 --- a/modules/security/rbac/account_login_test.go +++ b/modules/security/rbac/account_login_test.go @@ -25,9 +25,12 @@ package rbac import ( "errors" + "net/http" + "net/http/httptest" "testing" "infini.sh/framework/core/security" + replaysecurity "infini.sh/framework/core/security/replay" ) func TestAccountLoginRequestNormalizedLogin(t *testing.T) { @@ -88,3 +91,89 @@ func TestAuthenticateLoginRejectsIncompleteChallenge(t *testing.T) { t.Fatalf("expected incomplete challenge error, got %v", err) } } + +func TestAuthenticateLoginRejectsWrongProof(t *testing.T) { + user := &security.UserAccount{Email: "admin@example.org"} + if err := security.SetPassword(user, "StrongPassw0rd!"); err != nil { + t.Fatalf("set password: %v", err) + } + + challenge := security.NewLoginChallenge(user.Email) + _, err := authenticateLogin(user, user.Email, "", challenge.ID, "bad-proof") + if !errors.Is(err, errInvalidLoginCredentials) { + t.Fatalf("expected invalid credential error, got %v", err) + } +} + +func TestBuildLoginChallengeResponseFallsBackToPlain(t *testing.T) { + user := &security.UserAccount{Email: "admin@example.org"} + resp := buildLoginChallengeResponse(user.Email, true, user) + + if got := resp["method"]; got != "plain" { + t.Fatalf("expected plain fallback, got %v", got) + } + if _, ok := resp["challenge_id"]; ok { + t.Fatal("did not expect challenge payload for plain fallback") + } +} + +func TestBuildLoginChallengeResponseReturnsChallenge(t *testing.T) { + user := &security.UserAccount{Email: "admin@example.org"} + if err := security.SetPassword(user, "StrongPassw0rd!"); err != nil { + t.Fatalf("set password: %v", err) + } + + resp := buildLoginChallengeResponse(user.Email, true, user) + if got := resp["method"]; got != security.PasswordChallengeMethod { + t.Fatalf("expected challenge method, got %v", got) + } + if resp["challenge_id"] == "" { + t.Fatal("expected challenge id to be returned") + } + if resp["nonce"] == "" { + t.Fatal("expected nonce to be returned") + } + if resp["salt"] != user.PasswordSalt { + t.Fatal("expected challenge response to expose password salt") + } +} + +func TestValidateReplayNonceAllowsLegacyPasswordLoginWithoutNonce(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/account/login", nil) + if err := validateReplayNonce(req, false); err != nil { + t.Fatalf("expected missing nonce to be allowed for legacy password login, got %v", err) + } +} + +func TestValidateReplayNonceRequiresNonceForChallengeLogin(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/account/login", nil) + if err := validateReplayNonce(req, true); err == nil { + t.Fatal("expected missing nonce to be rejected for challenge login") + } +} + +func TestValidateReplayNonceConsumesIssuedNonce(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/account/login", nil) + nonce, _, err := replaysecurity.IssueReplayNonce(req, http.MethodPost, "/account/login") + if err != nil { + t.Fatalf("issue replay nonce: %v", err) + } + req.Header.Set(replaysecurity.HeaderName, nonce) + + if err := validateReplayNonce(req, true); err != nil { + t.Fatalf("expected issued nonce to validate, got %v", err) + } +} + +func TestNewNativeSessionFallsBackToRequestedLogin(t *testing.T) { + user := &security.UserAccount{Email: "", Roles: []string{security.RoleAdmin}} + user.ID = "user-1" + + session := newNativeSession(user, "admin@example.org") + if session.Login != "admin@example.org" { + t.Fatalf("expected requested login fallback, got %q", session.Login) + } + if session.Provider != security.DefaultNativeAuthBackend { + t.Fatalf("expected native provider, got %q", session.Provider) + } +} diff --git a/modules/security/rbac/user.go b/modules/security/rbac/user.go index 5b2a1f483..64d36b44e 100644 --- a/modules/security/rbac/user.go +++ b/modules/security/rbac/user.go @@ -72,6 +72,8 @@ func UpdateUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) } if obj.Password == "" { + // Preserve the verifier material on metadata-only updates so editing roles, + // names, or other fields does not silently disable challenge login. obj.Password = oldObj.Password obj.PasswordSalt = oldObj.PasswordSalt obj.PasswordVerifier = oldObj.PasswordVerifier @@ -261,6 +263,8 @@ func CreateUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) } obj.Password = randStr + // The one-time bootstrap password should be returned to the caller, but the + // persisted verifier material must stay server-side only. obj.PasswordSalt = "" obj.PasswordVerifier = "" api.WriteJSON(w, obj, 200) From 089f9e20525a5d1cd6cb950699acaeb5799f6029 Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 15:08:47 +0800 Subject: [PATCH 11/24] docs: expand challenge login comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/security/password_challenge.go | 17 +++++++++++++++-- core/security/user_profile.go | 6 +++--- modules/security/rbac/account_login.go | 24 ++++++++++++++++++++++-- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/core/security/password_challenge.go b/core/security/password_challenge.go index dbd801d4d..2c860a6ed 100644 --- a/core/security/password_challenge.go +++ b/core/security/password_challenge.go @@ -32,17 +32,23 @@ import ( ) const ( - PasswordChallengeMethod = passwordchallenge.Method - PasswordChallengeAlgorithm = passwordchallenge.Algorithm + // PasswordChallengeMethod identifies the login flow returned by the challenge endpoint. + PasswordChallengeMethod = passwordchallenge.Method + // PasswordChallengeAlgorithm describes the verifier/proof derivation algorithm for clients. + PasswordChallengeAlgorithm = passwordchallenge.Algorithm + // PasswordChallengeIterations tells clients which PBKDF2 work factor to use. PasswordChallengeIterations = passwordchallenge.Iterations ) +// LoginChallenge re-exports the framework challenge payload used by native account login. type LoginChallenge = passwordchallenge.Challenge +// CanUsePasswordChallenge reports whether a native account already has challenge credentials. func CanUsePasswordChallenge(user *UserAccount) bool { return user != nil && user.PasswordSalt != "" && user.PasswordVerifier != "" } +// SetPassword updates both the legacy bcrypt hash and the challenge verifier material. func SetPassword(user *UserAccount, password string) error { if user == nil { return errors.New("user is nil") @@ -67,6 +73,7 @@ func SetPassword(user *UserAccount, password string) error { return nil } +// EnsurePasswordChallenge derives challenge material for older accounts without changing the bcrypt hash. func EnsurePasswordChallenge(user *UserAccount, password string) error { if user == nil { return errors.New("user is nil") @@ -88,6 +95,7 @@ func EnsurePasswordChallenge(user *UserAccount, password string) error { return nil } +// VerifyPassword validates the plain password against the stored bcrypt hash. func VerifyPassword(user *UserAccount, password string) error { if user == nil { return errors.New("user is nil") @@ -98,22 +106,27 @@ func VerifyPassword(user *UserAccount, password string) error { return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) } +// DerivePasswordVerifier converts a password and salt into the stored challenge verifier. func DerivePasswordVerifier(password, salt string) (string, error) { return passwordchallenge.DeriveVerifier(password, salt) } +// BuildPasswordProof creates the challenge response that clients send to /account/login. func BuildPasswordProof(verifier, subject, challengeID, nonce string) (string, error) { return passwordchallenge.BuildProof(verifier, subject, challengeID, nonce) } +// VerifyPasswordProof checks whether a submitted proof matches the stored verifier. func VerifyPasswordProof(verifier, subject, challengeID, nonce, proof string) bool { return passwordchallenge.VerifyProof(verifier, subject, challengeID, nonce, proof) } +// NewLoginChallenge allocates a one-time challenge bound to the requested login subject. func NewLoginChallenge(subject string) LoginChallenge { return passwordchallenge.New(subject) } +// ConsumeLoginChallenge validates and removes a one-time challenge after it is used. func ConsumeLoginChallenge(challengeID, subject string) (LoginChallenge, error) { return passwordchallenge.Consume(challengeID, subject) } diff --git a/core/security/user_profile.go b/core/security/user_profile.go index 82b8af12b..35155b0e2 100644 --- a/core/security/user_profile.go +++ b/core/security/user_profile.go @@ -36,9 +36,9 @@ type UserAccount struct { Name string `json:"name,omitempty" elastic_mapping:"name: { type: keyword }" validate:"required" ` Email string `json:"email,omitempty" elastic_mapping:"email: { type: keyword }" validate:"required|email" ` //unique Roles []string `json:"roles,omitempty" elastic_mapping:"roles: { type: keyword }"` - Password string `json:"password,omitempty" elastic_mapping:"password: { type: keyword }"` - PasswordSalt string `json:"password_salt,omitempty" elastic_mapping:"password_salt: { type: keyword }"` - PasswordVerifier string `json:"password_verifier,omitempty" elastic_mapping:"password_verifier: { type: keyword }"` + Password string `json:"password,omitempty" elastic_mapping:"password: { type: keyword }"` // Bcrypt hash used by the existing password-login flow. + PasswordSalt string `json:"password_salt,omitempty" elastic_mapping:"password_salt: { type: keyword }"` // Per-user salt exposed to clients during challenge login. + PasswordVerifier string `json:"password_verifier,omitempty" elastic_mapping:"password_verifier: { type: keyword }"` // Server-side verifier used to validate challenge proofs. } type UserProfile struct { diff --git a/modules/security/rbac/account_login.go b/modules/security/rbac/account_login.go index c846f7152..1b0672800 100644 --- a/modules/security/rbac/account_login.go +++ b/modules/security/rbac/account_login.go @@ -39,11 +39,16 @@ import ( ) var ( + // Keep the password and challenge paths aligned on one user-facing failure message. errInvalidLoginCredentials = errors.New("invalid login or password") - errIncompleteChallenge = errors.New("challenge response is incomplete") - errMissingPassword = errors.New("password is required") + // A challenge login must send both the one-time challenge id and the derived proof. + errIncompleteChallenge = errors.New("challenge response is incomplete") + // Password login keeps requiring the legacy password field when no challenge proof is supplied. + errMissingPassword = errors.New("password is required") ) +// accountLoginRequest accepts both the framework-native "login" field and the aliases +// already used by existing clients while challenge login is rolled out incrementally. type accountLoginRequest struct { Login string `json:"login"` Email string `json:"email"` @@ -76,6 +81,7 @@ func registerAccountRoutes() { api.Feature(api.FeatureCORS)) } +// IssueReplayNonce mints a short-lived nonce bound to the caller and target request scope. func IssueReplayNonce(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { var req struct { Method string `json:"method"` @@ -100,6 +106,8 @@ func IssueReplayNonce(w http.ResponseWriter, r *http.Request, ps httprouter.Para }) } +// LoginChallenge tells the client whether this account can use challenge login and, if so, +// returns the one-time challenge payload required to derive the proof locally. func LoginChallenge(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { var req accountLoginRequest if err := api.DecodeJSON(r, &req); err != nil { @@ -122,6 +130,8 @@ func LoginChallenge(w http.ResponseWriter, r *http.Request, ps httprouter.Params api.WriteOKJSON(w, buildLoginChallengeResponse(login, exists, user)) } +// Login accepts either the legacy password payload or the new challenge proof and then +// reuses the existing session/token issuance path once the credentials are verified. func Login(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { var req accountLoginRequest if err := api.DecodeJSON(r, &req); err != nil { @@ -173,6 +183,7 @@ func Login(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { } } +// NormalizedLogin resolves the various historical request field names into one lookup key. func (req accountLoginRequest) NormalizedLogin() string { for _, candidate := range []string{req.Login, req.Email, req.Username, req.UserName} { if value := strings.TrimSpace(candidate); value != "" { @@ -182,6 +193,8 @@ func (req accountLoginRequest) NormalizedLogin() string { return "" } +// buildLoginChallengeResponse keeps the challenge negotiation explicit: challenge-capable +// accounts get the proof derivation inputs, while older accounts stay on plain login. func buildLoginChallengeResponse(login string, exists bool, user *security.UserAccount) util.MapStr { if exists && security.CanUsePasswordChallenge(user) { // The challenge payload gives clients everything needed to derive a proof @@ -204,6 +217,7 @@ func buildLoginChallengeResponse(login string, exists bool, user *security.UserA } } +// authenticateLogin selects the correct credential validation path based on the request body. func authenticateLogin(user *security.UserAccount, login, password, challengeID, proof string) (bool, error) { if user == nil { return false, errInvalidLoginCredentials @@ -233,6 +247,7 @@ func authenticateLogin(user *security.UserAccount, login, password, challengeID, return false, nil } +// lookupAccountByLogin normalizes the service-registry "not found" result into a regular miss. func lookupAccountByLogin(login string) (bool, *security.UserAccount, error) { exists, user, err := GetUserByLogin(login) if err != nil && err.Error() == "not found" { @@ -241,6 +256,8 @@ func lookupAccountByLogin(login string) (bool, *security.UserAccount, error) { return exists, user, err } +// validateReplayNonce keeps challenge login replay-safe while leaving older password-only +// clients working until they adopt the explicit nonce negotiation endpoint. func validateReplayNonce(r *http.Request, required bool) error { nonce := strings.TrimSpace(r.Header.Get(replaysecurity.HeaderName)) if nonce == "" && !required { @@ -251,6 +268,8 @@ func validateReplayNonce(r *http.Request, required bool) error { return replaysecurity.ValidateAndConsumeReplayNonce(r) } +// upgradePasswordChallenge backfills verifier material after a successful legacy login so +// existing native accounts can move onto the challenge flow without an offline migration. func upgradePasswordChallenge(user *security.UserAccount, password string) { if user == nil || password == "" || security.CanUsePasswordChallenge(user) { return @@ -271,6 +290,7 @@ func upgradePasswordChallenge(user *security.UserAccount, password string) { } } +// newNativeSession converts a native account record into the existing framework session claims. func newNativeSession(user *security.UserAccount, login string) *security.UserSessionInfo { userLogin := strings.TrimSpace(user.Email) if userLogin == "" { From dd51e98150ee7cd115620b57b83414d561d63427 Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 15:42:47 +0800 Subject: [PATCH 12/24] docs: annotate security route options Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/api/security.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/core/api/security.go b/core/api/security.go index 5f5955b7f..89a74cd48 100644 --- a/core/api/security.go +++ b/core/api/security.go @@ -32,15 +32,21 @@ import ( ) type SecureTransportOptions struct { + // TrustForwardHeaders allows HTTPS detection to honor reverse-proxy forwarding headers. TrustForwardHeaders bool } const ( - FeatureRequireSecureTransport = "feature_require_secure_transport" + // FeatureRequireSecureTransport marks a UI handler as HTTPS-only when it is enforced by filters. + FeatureRequireSecureTransport = "feature_require_secure_transport" + // FeatureRequireReplayProtection marks a UI handler as requiring a valid replay nonce. FeatureRequireReplayProtection = "feature_require_replay_protection" - LabelTrustForwardHeaders = "label_trust_forward_headers" + // LabelTrustForwardHeaders stores whether HTTPS checks may trust reverse-proxy forwarding headers. + LabelTrustForwardHeaders = "label_trust_forward_headers" ) +// RequestUsesSecureTransport reports whether the request arrived over HTTPS directly or, when +// allowed, through a trusted reverse proxy that forwarded HTTPS metadata. func RequestUsesSecureTransport(req *http.Request, options ...SecureTransportOptions) bool { if req == nil { return false @@ -67,6 +73,7 @@ func RequestUsesSecureTransport(req *http.Request, options ...SecureTransportOpt return forwardedHeaderIndicatesHTTPS(req.Header.Get("Forwarded")) } +// RequireSecureTransport wraps a handler so it rejects requests that do not resolve to HTTPS. func (handler Handler) RequireSecureTransport(h httprouter.Handle, options ...SecureTransportOptions) httprouter.Handle { resolved := resolveSecureTransportOptions(options) return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { @@ -78,10 +85,12 @@ func (handler Handler) RequireSecureTransport(h httprouter.Handle, options ...Se } } +// RequireSecureTransport wraps a handler with the default security handler implementation. func RequireSecureTransport(h httprouter.Handle, options ...SecureTransportOptions) httprouter.Handle { return Handler{}.RequireSecureTransport(h, options...) } +// RequireReplayProtection wraps a handler so each request must present a valid replay nonce. func (handler Handler) RequireReplayProtection(h httprouter.Handle) httprouter.Handle { return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { if err := replaysecurity.ValidateAndConsumeReplayNonce(r); err != nil { @@ -92,10 +101,12 @@ func (handler Handler) RequireReplayProtection(h httprouter.Handle) httprouter.H } } +// RequireReplayProtection wraps a handler with the default replay-protection implementation. func RequireReplayProtection(h httprouter.Handle) httprouter.Handle { return Handler{}.RequireReplayProtection(h) } +// SecureTransportOption annotates a UI route so SecurityFilter can enforce HTTPS consistently. func SecureTransportOption(options ...SecureTransportOptions) Option { resolved := resolveSecureTransportOptions(options) return func(o *HandlerOptions) { @@ -104,6 +115,7 @@ func SecureTransportOption(options ...SecureTransportOptions) Option { } } +// ReplayProtectionOption annotates a UI route so SecurityFilter enforces replay-nonce validation. func ReplayProtectionOption() Option { return Feature(FeatureRequireReplayProtection) } From 19b7a01e73e250396553e98a8ded4bc56ebf9043 Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 16:05:34 +0800 Subject: [PATCH 13/24] test: expand challenge security coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../passwordchallenge/password_challenge.go | 19 ++++++++- .../password_challenge_test.go | 24 ++++++++++- core/security/replay/replay.go | 12 ++++++ core/security/replay/replay_test.go | 40 +++++++++++++++++++ modules/security/http_filters/security.go | 4 ++ .../security/http_filters/security_test.go | 32 +++++++++++++++ 6 files changed, 128 insertions(+), 3 deletions(-) diff --git a/core/security/passwordchallenge/password_challenge.go b/core/security/passwordchallenge/password_challenge.go index 461914b50..ae975eb7d 100644 --- a/core/security/passwordchallenge/password_challenge.go +++ b/core/security/passwordchallenge/password_challenge.go @@ -37,13 +37,18 @@ import ( ) const ( - Method = "challenge" - Algorithm = "PBKDF2-SHA256" + // Method identifies the password challenge login flow returned by the challenge endpoint. + Method = "challenge" + // Algorithm describes the verifier/proof derivation algorithm that clients must use. + Algorithm = "PBKDF2-SHA256" + // Iterations is the PBKDF2 work factor shared with clients during challenge negotiation. Iterations = 120000 keyLength = 32 + // DefaultTTL is the default lifetime of a login challenge before it must be re-issued. DefaultTTL = 5 * time.Minute ) +// Challenge carries the one-time identifiers clients need to build a password proof locally. type Challenge struct { ID string Subject string @@ -51,10 +56,12 @@ type Challenge struct { ExpireAt time.Time } +// StoreOptions configures the lifetime of issued login challenges. type StoreOptions struct { TTL time.Duration } +// Store tracks outstanding login challenges until they are consumed or expire. type Store struct { mu sync.Mutex ttl time.Duration @@ -63,6 +70,7 @@ type Store struct { var defaultStore = NewStore(StoreOptions{}) +// NewStore creates an in-memory challenge store with the requested TTL. func NewStore(options StoreOptions) *Store { ttl := options.TTL if ttl <= 0 { @@ -74,6 +82,7 @@ func NewStore(options StoreOptions) *Store { } } +// DeriveVerifier turns a password and salt into the verifier stored on the account record. func DeriveVerifier(password, salt string) (string, error) { if password == "" { return "", errors.New("password is empty") @@ -85,6 +94,7 @@ func DeriveVerifier(password, salt string) (string, error) { return hex.EncodeToString(key), nil } +// BuildProof derives the one-time challenge response that clients submit to /account/login. func BuildProof(verifier, subject, challengeID, nonce string) (string, error) { key, err := hex.DecodeString(verifier) if err != nil { @@ -99,6 +109,7 @@ func BuildProof(verifier, subject, challengeID, nonce string) (string, error) { return hex.EncodeToString(mac.Sum(nil)), nil } +// VerifyProof compares a submitted proof against the expected proof for this challenge tuple. func VerifyProof(verifier, subject, challengeID, nonce, proof string) bool { expected, err := BuildProof(verifier, subject, challengeID, nonce) if err != nil { @@ -115,14 +126,17 @@ func VerifyProof(verifier, subject, challengeID, nonce, proof string) bool { return hmac.Equal(expectedBytes, proofBytes) } +// New issues a login challenge from the default store. func New(subject string) Challenge { return defaultStore.New(subject) } +// Consume loads and invalidates a login challenge from the default store. func Consume(challengeID, subject string) (Challenge, error) { return defaultStore.Consume(challengeID, subject) } +// New allocates a fresh challenge for the provided subject. func (store *Store) New(subject string) Challenge { now := time.Now() store.mu.Lock() @@ -144,6 +158,7 @@ func (store *Store) New(subject string) Challenge { return challenge } +// Consume validates the subject and TTL, then invalidates the one-time challenge. func (store *Store) Consume(challengeID, subject string) (Challenge, error) { store.mu.Lock() defer store.mu.Unlock() diff --git a/core/security/passwordchallenge/password_challenge_test.go b/core/security/passwordchallenge/password_challenge_test.go index 74e834473..7866b055e 100644 --- a/core/security/passwordchallenge/password_challenge_test.go +++ b/core/security/passwordchallenge/password_challenge_test.go @@ -23,7 +23,10 @@ package passwordchallenge -import "testing" +import ( + "testing" + "time" +) func TestPasswordChallengeProofRoundTrip(t *testing.T) { verifier, err := DeriveVerifier("admin", "salt-123") @@ -50,3 +53,22 @@ func TestConsumeRejectsWrongSubject(t *testing.T) { t.Fatal("expected challenge subject mismatch to fail") } } + +func TestDeriveVerifierRejectsEmptyInput(t *testing.T) { + if _, err := DeriveVerifier("", "salt-123"); err == nil { + t.Fatal("expected empty password to fail") + } + if _, err := DeriveVerifier("admin", ""); err == nil { + t.Fatal("expected empty salt to fail") + } +} + +func TestConsumeRejectsExpiredChallenge(t *testing.T) { + store := NewStore(StoreOptions{TTL: time.Millisecond}) + challenge := store.New("admin") + + time.Sleep(5 * time.Millisecond) + if _, err := store.Consume(challenge.ID, "admin"); err == nil { + t.Fatal("expected expired challenge to fail") + } +} diff --git a/core/security/replay/replay.go b/core/security/replay/replay.go index 5727d3cb4..b5a630eaf 100644 --- a/core/security/replay/replay.go +++ b/core/security/replay/replay.go @@ -37,12 +37,16 @@ import ( ) const ( + // HeaderName is the HTTP header clients use to submit a one-time replay nonce. HeaderName = "X-Request-Nonce" + // DefaultTTL is the default lifetime of an issued replay nonce. DefaultTTL = 30 * time.Second ) +// SubjectExtractor derives the caller identity that a replay nonce should be bound to. type SubjectExtractor func(r *http.Request) string +// StoreOptions configures replay nonce retention and subject binding behavior. type StoreOptions struct { TTL time.Duration SubjectExtractor SubjectExtractor @@ -55,6 +59,7 @@ type nonceRecord struct { ExpiresAt time.Time } +// Store tracks issued replay nonces until they are consumed or expire. type Store struct { mu sync.Mutex ttl time.Duration @@ -64,6 +69,7 @@ type Store struct { var defaultStore = NewStore(StoreOptions{}) +// NewStore creates an in-memory replay store with optional TTL and subject extraction overrides. func NewStore(options StoreOptions) *Store { ttl := options.TTL if ttl <= 0 { @@ -80,14 +86,18 @@ func NewStore(options StoreOptions) *Store { } } +// IssueReplayNonce issues a nonce from the default store for the requested method/path scope. func IssueReplayNonce(r *http.Request, method, requestPath string) (string, time.Duration, error) { return defaultStore.IssueReplayNonce(r, method, requestPath) } +// ValidateAndConsumeReplayNonce validates a nonce from the default store and deletes it on success. func ValidateAndConsumeReplayNonce(r *http.Request) error { return defaultStore.ValidateAndConsumeReplayNonce(r) } +// DefaultSubjectExtractor binds anonymous callers together and authenticated callers to their +// Authorization header so replay nonces cannot be replayed across credential contexts. func DefaultSubjectExtractor(r *http.Request) string { if r == nil { return "anonymous" @@ -102,6 +112,7 @@ func DefaultSubjectExtractor(r *http.Request) string { return hex.EncodeToString(sum[:]) } +// IssueReplayNonce stores a nonce that is scoped to the caller, HTTP method, and request path. func (store *Store) IssueReplayNonce(r *http.Request, method, requestPath string) (string, time.Duration, error) { normalizedMethod, normalizedPath, err := normalizeScope(method, requestPath) if err != nil { @@ -128,6 +139,7 @@ func (store *Store) IssueReplayNonce(r *http.Request, method, requestPath string return nonce, store.ttl, nil } +// ValidateAndConsumeReplayNonce accepts a nonce only once and only for the original request scope. func (store *Store) ValidateAndConsumeReplayNonce(r *http.Request) error { if r == nil { return fmt.Errorf("request can not be nil") diff --git a/core/security/replay/replay_test.go b/core/security/replay/replay_test.go index a22b581d0..38a354625 100644 --- a/core/security/replay/replay_test.go +++ b/core/security/replay/replay_test.go @@ -27,6 +27,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" ) func TestReplayNonceCanOnlyBeUsedOnce(t *testing.T) { @@ -80,3 +81,42 @@ func TestReplayNonceBindsToPathAndMethod(t *testing.T) { t.Fatal("expected nonce with mismatched method to fail") } } + +func TestDefaultSubjectExtractorFallsBackToAnonymous(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) + if got := DefaultSubjectExtractor(req); got != "anonymous" { + t.Fatalf("expected anonymous subject, got %q", got) + } +} + +func TestReplayNonceNormalizesPath(t *testing.T) { + store := NewStore(StoreOptions{}) + issueReq := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) + + nonce, _, err := store.IssueReplayNonce(issueReq, http.MethodPost, "account/../account/login") + if err != nil { + t.Fatalf("issue replay nonce failed: %v", err) + } + + useReq := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) + useReq.Header.Set(HeaderName, nonce) + if err := store.ValidateAndConsumeReplayNonce(useReq); err != nil { + t.Fatalf("expected normalized path to validate: %v", err) + } +} + +func TestReplayNonceExpires(t *testing.T) { + store := NewStore(StoreOptions{TTL: time.Millisecond}) + req := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) + + nonce, _, err := store.IssueReplayNonce(req, http.MethodPost, "/account/login") + if err != nil { + t.Fatalf("issue replay nonce failed: %v", err) + } + + time.Sleep(5 * time.Millisecond) + req.Header.Set(HeaderName, nonce) + if err := store.ValidateAndConsumeReplayNonce(req); err == nil { + t.Fatal("expected expired nonce to be rejected") + } +} diff --git a/modules/security/http_filters/security.go b/modules/security/http_filters/security.go index 451fc17c3..6e372a000 100644 --- a/modules/security/http_filters/security.go +++ b/modules/security/http_filters/security.go @@ -17,14 +17,17 @@ func init() { api.RegisterUIFilter(&SecurityFilter{}) } +// SecurityFilter enforces per-route HTTPS and replay-protection features declared in HandlerOptions. type SecurityFilter struct { api.Handler } +// GetPriority keeps the security checks ahead of permission checks but after early request shaping. func (f *SecurityFilter) GetPriority() int { return 450 } +// ApplyFilter translates route feature flags into runtime checks for HTTPS and replay nonce usage. func (f *SecurityFilter) ApplyFilter( method string, pattern string, @@ -58,6 +61,7 @@ func (f *SecurityFilter) ApplyFilter( } } +// trustForwardHeadersFromOptions extracts whether SecureTransportOption opted into proxy headers. func trustForwardHeadersFromOptions(options *api.HandlerOptions) bool { if options == nil || options.Labels == nil { return false diff --git a/modules/security/http_filters/security_test.go b/modules/security/http_filters/security_test.go index c44f6a945..58f9b02e6 100644 --- a/modules/security/http_filters/security_test.go +++ b/modules/security/http_filters/security_test.go @@ -66,6 +66,29 @@ func TestSecurityFilterReplayProtectionFeature(t *testing.T) { } } +func TestSecurityFilterReplayProtectionRejectsMissingNonce(t *testing.T) { + filter := &SecurityFilter{} + options := &api.HandlerOptions{} + api.ReplayProtectionOption()(options) + + called := false + protected := filter.ApplyFilter(http.MethodPost, "/account/login", options, func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + called = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) + resp := httptest.NewRecorder() + protected(resp, req, nil) + + if called { + t.Fatal("expected missing nonce to block handler execution") + } + if resp.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, resp.Code) + } +} + func TestSecurityFilterWithTrustedForwardHeaders(t *testing.T) { filter := &SecurityFilter{} options := &api.HandlerOptions{} @@ -89,3 +112,12 @@ func TestSecurityFilterWithTrustedForwardHeaders(t *testing.T) { t.Fatalf("expected status %d, got %d", http.StatusOK, resp.Code) } } + +func TestTrustForwardHeadersFromOptionsDefaultsFalse(t *testing.T) { + if trustForwardHeadersFromOptions(nil) { + t.Fatal("expected nil options to disable trusted forward headers") + } + if trustForwardHeadersFromOptions(&api.HandlerOptions{}) { + t.Fatal("expected missing label to disable trusted forward headers") + } +} From 20d03bf0b50293ae358f6cd71c957a431ad49097 Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 16:07:02 +0800 Subject: [PATCH 14/24] docs: clarify password challenge constants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../passwordchallenge/password_challenge.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/core/security/passwordchallenge/password_challenge.go b/core/security/passwordchallenge/password_challenge.go index ae975eb7d..f16190bdb 100644 --- a/core/security/passwordchallenge/password_challenge.go +++ b/core/security/passwordchallenge/password_challenge.go @@ -43,21 +43,26 @@ const ( Algorithm = "PBKDF2-SHA256" // Iterations is the PBKDF2 work factor shared with clients during challenge negotiation. Iterations = 120000 - keyLength = 32 + // keyLength is the derived key size used for both the stored verifier and request proof. + keyLength = 32 // DefaultTTL is the default lifetime of a login challenge before it must be re-issued. DefaultTTL = 5 * time.Minute ) // Challenge carries the one-time identifiers clients need to build a password proof locally. type Challenge struct { - ID string - Subject string - Nonce string + ID string + // Subject keeps the challenge bound to the login identity it was issued for. + Subject string + // Nonce is the random per-challenge input mixed into the client proof. + Nonce string + // ExpireAt marks when the one-time challenge stops being valid. ExpireAt time.Time } // StoreOptions configures the lifetime of issued login challenges. type StoreOptions struct { + // TTL overrides the default challenge lifetime for this store instance. TTL time.Duration } From 7a180c9e954dd494f579639352b132449809e9b1 Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 16:09:08 +0800 Subject: [PATCH 15/24] docs: explain challenge login tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/api/security_test.go | 7 +++++++ core/security/password_challenge_test.go | 5 +++++ .../passwordchallenge/password_challenge_test.go | 5 +++++ core/security/replay/replay_test.go | 6 ++++++ modules/security/http_filters/security_test.go | 5 +++++ modules/security/rbac/account_login_test.go | 11 +++++++++++ 6 files changed, 39 insertions(+) diff --git a/core/api/security_test.go b/core/api/security_test.go index 5c0c56a8f..ce9002e09 100644 --- a/core/api/security_test.go +++ b/core/api/security_test.go @@ -33,6 +33,8 @@ import ( replaysecurity "infini.sh/framework/core/security/replay" ) +// The transport tests cover both direct TLS and trusted proxy headers because the +// security helpers are shared by embedded UI routes that may sit behind a proxy. func TestRequestUsesSecureTransport(t *testing.T) { tests := []struct { name string @@ -81,6 +83,7 @@ func TestRequestUsesSecureTransport(t *testing.T) { } } +// The wrapper should fail fast before running the protected handler on plain HTTP. func TestRequireSecureTransport(t *testing.T) { handler := Handler{} called := false @@ -102,6 +105,7 @@ func TestRequireSecureTransport(t *testing.T) { } } +// Replay-protected handlers should pass straight through once a matching nonce exists. func TestRequireReplayProtection(t *testing.T) { handler := Handler{} req := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) @@ -128,6 +132,8 @@ func TestRequireReplayProtection(t *testing.T) { } } +// Route options are later consumed by SecurityFilter, so the feature flag and labels +// must both be set when secure transport enforcement is requested declaratively. func TestSecureTransportOption(t *testing.T) { options := &HandlerOptions{} SecureTransportOption(SecureTransportOptions{TrustForwardHeaders: true})(options) @@ -143,6 +149,7 @@ func TestSecureTransportOption(t *testing.T) { } } +// Replay protection uses a single feature flag because the filter reads no extra labels. func TestReplayProtectionOption(t *testing.T) { options := &HandlerOptions{} ReplayProtectionOption()(options) diff --git a/core/security/password_challenge_test.go b/core/security/password_challenge_test.go index 3b0b77560..c2f68a145 100644 --- a/core/security/password_challenge_test.go +++ b/core/security/password_challenge_test.go @@ -25,6 +25,8 @@ package security import "testing" +// Setting a password should populate both the legacy bcrypt path and the new +// challenge-login material so either login mode can succeed afterward. func TestSetPasswordPopulatesChallengeFields(t *testing.T) { user := &UserAccount{} if err := SetPassword(user, "StrongPassw0rd!"); err != nil { @@ -45,6 +47,7 @@ func TestSetPasswordPopulatesChallengeFields(t *testing.T) { } } +// Existing challenge material should be stable when an account is already upgraded. func TestEnsurePasswordChallengePreservesExistingVerifier(t *testing.T) { user := &UserAccount{} if err := SetPassword(user, "StrongPassw0rd!"); err != nil { @@ -65,6 +68,7 @@ func TestEnsurePasswordChallengePreservesExistingVerifier(t *testing.T) { } } +// The framework wrapper should produce proofs compatible with the lower-level package. func TestPasswordChallengeProofRoundTrip(t *testing.T) { user := &UserAccount{} login := "admin@example.org" @@ -85,6 +89,7 @@ func TestPasswordChallengeProofRoundTrip(t *testing.T) { } } +// Legacy accounts that only had a bcrypt hash should become challenge-capable in place. func TestEnsurePasswordChallengePopulatesLegacyAccount(t *testing.T) { user := &UserAccount{Password: "existing-bcrypt-hash"} if err := EnsurePasswordChallenge(user, "StrongPassw0rd!"); err != nil { diff --git a/core/security/passwordchallenge/password_challenge_test.go b/core/security/passwordchallenge/password_challenge_test.go index 7866b055e..8b0ce6aac 100644 --- a/core/security/passwordchallenge/password_challenge_test.go +++ b/core/security/passwordchallenge/password_challenge_test.go @@ -28,6 +28,8 @@ import ( "time" ) +// Proof generation and verification need to round-trip because this package defines the +// wire contract shared between the framework login endpoint and upgraded clients. func TestPasswordChallengeProofRoundTrip(t *testing.T) { verifier, err := DeriveVerifier("admin", "salt-123") if err != nil { @@ -45,6 +47,7 @@ func TestPasswordChallengeProofRoundTrip(t *testing.T) { } } +// Challenges are bound to the requested login subject and must not be replayed for others. func TestConsumeRejectsWrongSubject(t *testing.T) { store := NewStore(StoreOptions{}) challenge := store.New("admin") @@ -54,6 +57,7 @@ func TestConsumeRejectsWrongSubject(t *testing.T) { } } +// Empty input should be rejected up front to avoid persisting or comparing invalid verifiers. func TestDeriveVerifierRejectsEmptyInput(t *testing.T) { if _, err := DeriveVerifier("", "salt-123"); err == nil { t.Fatal("expected empty password to fail") @@ -63,6 +67,7 @@ func TestDeriveVerifierRejectsEmptyInput(t *testing.T) { } } +// Expiration keeps the one-time challenge store bounded and prevents stale proof reuse. func TestConsumeRejectsExpiredChallenge(t *testing.T) { store := NewStore(StoreOptions{TTL: time.Millisecond}) challenge := store.New("admin") diff --git a/core/security/replay/replay_test.go b/core/security/replay/replay_test.go index 38a354625..414cd79b5 100644 --- a/core/security/replay/replay_test.go +++ b/core/security/replay/replay_test.go @@ -30,6 +30,7 @@ import ( "time" ) +// A nonce is one-time use by design, so any second validation attempt must fail. func TestReplayNonceCanOnlyBeUsedOnce(t *testing.T) { store := NewStore(StoreOptions{}) req := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) @@ -48,6 +49,7 @@ func TestReplayNonceCanOnlyBeUsedOnce(t *testing.T) { } } +// Replay tokens should stay bound to the caller identity, not just the raw path/method tuple. func TestReplayNonceBindsToAuthorizationHeader(t *testing.T) { store := NewStore(StoreOptions{}) issueReq := httptest.NewRequest(http.MethodPut, "https://console.local/credential/test", nil) @@ -66,6 +68,7 @@ func TestReplayNonceBindsToAuthorizationHeader(t *testing.T) { } } +// The scope includes HTTP method so a nonce issued for one mutation cannot authorize another. func TestReplayNonceBindsToPathAndMethod(t *testing.T) { store := NewStore(StoreOptions{}) issueReq := httptest.NewRequest(http.MethodPost, "https://console.local/setup/_initialize", nil) @@ -82,6 +85,7 @@ func TestReplayNonceBindsToPathAndMethod(t *testing.T) { } } +// Anonymous callers still need a stable default subject so unauthenticated setup flows work. func TestDefaultSubjectExtractorFallsBackToAnonymous(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) if got := DefaultSubjectExtractor(req); got != "anonymous" { @@ -89,6 +93,7 @@ func TestDefaultSubjectExtractorFallsBackToAnonymous(t *testing.T) { } } +// Path normalization lets clients request a nonce with equivalent path forms safely. func TestReplayNonceNormalizesPath(t *testing.T) { store := NewStore(StoreOptions{}) issueReq := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) @@ -105,6 +110,7 @@ func TestReplayNonceNormalizesPath(t *testing.T) { } } +// Expired nonces should be rejected even if the caller, method, and path still match. func TestReplayNonceExpires(t *testing.T) { store := NewStore(StoreOptions{TTL: time.Millisecond}) req := httptest.NewRequest(http.MethodPost, "https://console.local/account/login", nil) diff --git a/modules/security/http_filters/security_test.go b/modules/security/http_filters/security_test.go index 58f9b02e6..775463a43 100644 --- a/modules/security/http_filters/security_test.go +++ b/modules/security/http_filters/security_test.go @@ -14,6 +14,7 @@ import ( replaysecurity "infini.sh/framework/core/security/replay" ) +// Secure-transport enforcement should stop the request before the wrapped UI handler runs. func TestSecurityFilterSecureTransportFeature(t *testing.T) { filter := &SecurityFilter{} options := &api.HandlerOptions{} @@ -37,6 +38,7 @@ func TestSecurityFilterSecureTransportFeature(t *testing.T) { } } +// When a nonce matches the request scope, the filter should behave like a no-op wrapper. func TestSecurityFilterReplayProtectionFeature(t *testing.T) { filter := &SecurityFilter{} options := &api.HandlerOptions{} @@ -66,6 +68,7 @@ func TestSecurityFilterReplayProtectionFeature(t *testing.T) { } } +// Missing nonce headers must block replay-protected routes before business logic executes. func TestSecurityFilterReplayProtectionRejectsMissingNonce(t *testing.T) { filter := &SecurityFilter{} options := &api.HandlerOptions{} @@ -89,6 +92,7 @@ func TestSecurityFilterReplayProtectionRejectsMissingNonce(t *testing.T) { } } +// Trusted forward headers let deployments behind HTTPS reverse proxies pass transport checks. func TestSecurityFilterWithTrustedForwardHeaders(t *testing.T) { filter := &SecurityFilter{} options := &api.HandlerOptions{} @@ -113,6 +117,7 @@ func TestSecurityFilterWithTrustedForwardHeaders(t *testing.T) { } } +// Routes that do not opt into trusted proxy headers should stay conservative by default. func TestTrustForwardHeadersFromOptionsDefaultsFalse(t *testing.T) { if trustForwardHeadersFromOptions(nil) { t.Fatal("expected nil options to disable trusted forward headers") diff --git a/modules/security/rbac/account_login_test.go b/modules/security/rbac/account_login_test.go index d9073cbac..fe66941db 100644 --- a/modules/security/rbac/account_login_test.go +++ b/modules/security/rbac/account_login_test.go @@ -33,6 +33,7 @@ import ( replaysecurity "infini.sh/framework/core/security/replay" ) +// The request payload accepts multiple historical login field names during rollout. func TestAccountLoginRequestNormalizedLogin(t *testing.T) { req := accountLoginRequest{ Email: "admin@example.org", @@ -44,6 +45,7 @@ func TestAccountLoginRequestNormalizedLogin(t *testing.T) { } } +// Password login remains the backward-compatible path for accounts and clients not yet upgraded. func TestAuthenticateLoginWithPassword(t *testing.T) { user := &security.UserAccount{Email: "admin@example.org"} if err := security.SetPassword(user, "StrongPassw0rd!"); err != nil { @@ -59,6 +61,7 @@ func TestAuthenticateLoginWithPassword(t *testing.T) { } } +// Challenge login should succeed once the account already has verifier material. func TestAuthenticateLoginWithChallenge(t *testing.T) { user := &security.UserAccount{Email: "admin@example.org"} if err := security.SetPassword(user, "StrongPassw0rd!"); err != nil { @@ -80,6 +83,7 @@ func TestAuthenticateLoginWithChallenge(t *testing.T) { } } +// Partially supplied challenge payloads should fail distinctly from bad credentials. func TestAuthenticateLoginRejectsIncompleteChallenge(t *testing.T) { user := &security.UserAccount{Email: "admin@example.org"} if err := security.SetPassword(user, "StrongPassw0rd!"); err != nil { @@ -92,6 +96,7 @@ func TestAuthenticateLoginRejectsIncompleteChallenge(t *testing.T) { } } +// Incorrect proofs should collapse to the same user-facing error as bad passwords. func TestAuthenticateLoginRejectsWrongProof(t *testing.T) { user := &security.UserAccount{Email: "admin@example.org"} if err := security.SetPassword(user, "StrongPassw0rd!"); err != nil { @@ -105,6 +110,7 @@ func TestAuthenticateLoginRejectsWrongProof(t *testing.T) { } } +// Older accounts intentionally advertise plain login until their verifier is available. func TestBuildLoginChallengeResponseFallsBackToPlain(t *testing.T) { user := &security.UserAccount{Email: "admin@example.org"} resp := buildLoginChallengeResponse(user.Email, true, user) @@ -117,6 +123,7 @@ func TestBuildLoginChallengeResponseFallsBackToPlain(t *testing.T) { } } +// Upgraded accounts should return the exact challenge inputs the client needs next. func TestBuildLoginChallengeResponseReturnsChallenge(t *testing.T) { user := &security.UserAccount{Email: "admin@example.org"} if err := security.SetPassword(user, "StrongPassw0rd!"); err != nil { @@ -138,6 +145,7 @@ func TestBuildLoginChallengeResponseReturnsChallenge(t *testing.T) { } } +// Legacy password clients keep working even before they learn the replay-nonce preflight. func TestValidateReplayNonceAllowsLegacyPasswordLoginWithoutNonce(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/account/login", nil) if err := validateReplayNonce(req, false); err != nil { @@ -145,6 +153,7 @@ func TestValidateReplayNonceAllowsLegacyPasswordLoginWithoutNonce(t *testing.T) } } +// Challenge logins must enforce nonce usage immediately because the frontend already negotiated it. func TestValidateReplayNonceRequiresNonceForChallengeLogin(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/account/login", nil) if err := validateReplayNonce(req, true); err == nil { @@ -152,6 +161,7 @@ func TestValidateReplayNonceRequiresNonceForChallengeLogin(t *testing.T) { } } +// Once a nonce is explicitly issued for /account/login it should validate on that exact route. func TestValidateReplayNonceConsumesIssuedNonce(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/account/login", nil) nonce, _, err := replaysecurity.IssueReplayNonce(req, http.MethodPost, "/account/login") @@ -165,6 +175,7 @@ func TestValidateReplayNonceConsumesIssuedNonce(t *testing.T) { } } +// Native sessions should still be constructible even when the stored account email is blank. func TestNewNativeSessionFallsBackToRequestedLogin(t *testing.T) { user := &security.UserAccount{Email: "", Roles: []string{security.RoleAdmin}} user.ID = "user-1" From c6c7b3f40b3bc1fb8bb5720bbdd8137c76d2095d Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 16:47:09 +0800 Subject: [PATCH 16/24] feat: align native account sessions with console rollout\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/security/session.go | 45 +++++++++- core/security/user_session.go | 63 +++++++++++++ core/security/user_session_test.go | 88 ++++++++++++++++++ core/security/validate.go | 21 +++-- core/security/validate_test.go | 68 ++++++++++++++ modules/security/account/refresh.go | 95 ++++++++++++++++++++ modules/security/account/refresh_test.go | 98 +++++++++++++++++++++ modules/security/rbac/account_login.go | 1 + modules/security/rbac/account_login_test.go | 36 ++++++++ 9 files changed, 508 insertions(+), 7 deletions(-) create mode 100644 core/security/user_session_test.go create mode 100644 core/security/validate_test.go create mode 100644 modules/security/account/refresh.go create mode 100644 modules/security/account/refresh_test.go diff --git a/core/security/session.go b/core/security/session.go index dfbdf1593..93dc97b97 100644 --- a/core/security/session.go +++ b/core/security/session.go @@ -16,6 +16,7 @@ import ( ) const UserAccessTokenSessionName = "user_session_access_token" +const UserAccessTokenTTL = 24 * time.Hour func init() { RegisterHTTPAuthFilterProvider("session_token", byAccessTokenSession) @@ -89,7 +90,7 @@ func GenerateJWTAccessToken(user *UserSessionInfo) (map[string]interface{}, erro token1 := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaims{ UserSessionInfo: user, RegisteredClaims: &jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(UserAccessTokenTTL)), }, }) @@ -105,7 +106,7 @@ func GenerateJWTAccessToken(user *UserSessionInfo) (map[string]interface{}, erro data = util.MapStr{ "access_token": tokenString, - "expire_in": time.Now().Unix() + 86400, //24h + "expire_in": time.Now().Unix() + int64(UserAccessTokenTTL/time.Second), } data["status"] = "ok" @@ -113,3 +114,43 @@ func GenerateJWTAccessToken(user *UserSessionInfo) (map[string]interface{}, erro return data, err } + +// DecorateSessionTokenResponse keeps framework-issued account responses directly +// consumable by existing console clients while auth flows converge on framework. +func DecorateSessionTokenResponse(token map[string]interface{}, user *UserSessionInfo) { + if token == nil || user == nil { + return + } + + if expiresAt := tokenExpiresAtUnix(token["expire_in"]); expiresAt > 0 { + token["expires_at"] = expiresAt + + remaining := expiresAt - time.Now().Unix() + if remaining < 0 { + remaining = 0 + } + token["expire_in"] = remaining + } + + token["username"] = user.Login + token["id"] = user.UserID + token["roles"] = append([]string(nil), user.Roles...) + token["privilege"] = GetAllPermissionsForUser(user) +} + +func tokenExpiresAtUnix(value interface{}) int64 { + switch v := value.(type) { + case int64: + return v + case int: + return int64(v) + case int32: + return int64(v) + case float64: + return int64(v) + case float32: + return int64(v) + default: + return 0 + } +} diff --git a/core/security/user_session.go b/core/security/user_session.go index 95e514745..0bbcddfb8 100644 --- a/core/security/user_session.go +++ b/core/security/user_session.go @@ -24,7 +24,9 @@ package security import ( + "encoding/json" "fmt" + "strings" "time" log "github.com/cihub/seelog" @@ -47,6 +49,67 @@ func NewUserClaims() *UserClaims { } } +type userSessionInfoAlias UserSessionInfo + +// MarshalJSON keeps the framework claims readable by older console clients while the +// token/session stack is converging onto the shared framework implementation. +func (c UserClaims) MarshalJSON() ([]byte, error) { + sessionUser := c.UserSessionInfo + if sessionUser == nil { + sessionUser = &UserSessionInfo{} + } + + claims := c.RegisteredClaims + if claims == nil { + claims = &jwt.RegisteredClaims{} + } + + return json.Marshal(struct { + *jwt.RegisteredClaims + *userSessionInfoAlias + Username string `json:"username,omitempty"` + UserID string `json:"user_id,omitempty"` + }{ + RegisteredClaims: claims, + userSessionInfoAlias: (*userSessionInfoAlias)(sessionUser), + Username: sessionUser.Login, + UserID: sessionUser.UserID, + }) +} + +// UnmarshalJSON accepts both the framework-native field names and the older console +// aliases so apps can switch validators without forcing a token-format fork first. +func (c *UserClaims) UnmarshalJSON(data []byte) error { + aux := struct { + *jwt.RegisteredClaims + *userSessionInfoAlias + Username string `json:"username,omitempty"` + UserID string `json:"user_id,omitempty"` + }{ + RegisteredClaims: &jwt.RegisteredClaims{}, + userSessionInfoAlias: &userSessionInfoAlias{}, + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + sessionUser := (*UserSessionInfo)(aux.userSessionInfoAlias) + if sessionUser == nil { + sessionUser = &UserSessionInfo{} + } + if strings.TrimSpace(sessionUser.Login) == "" { + sessionUser.Login = strings.TrimSpace(aux.Username) + } + if strings.TrimSpace(sessionUser.UserID) == "" { + sessionUser.UserID = strings.TrimSpace(aux.UserID) + } + + c.RegisteredClaims = aux.RegisteredClaims + c.UserSessionInfo = sessionUser + return nil +} + // auth user info type UserSessionInfo struct { param.Parameters diff --git a/core/security/user_session_test.go b/core/security/user_session_test.go new file mode 100644 index 000000000..41b37eb19 --- /dev/null +++ b/core/security/user_session_test.go @@ -0,0 +1,88 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package security + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/golang-jwt/jwt/v4" +) + +// Framework-issued tokens need to remain readable by console clients until the two +// stacks finish converging on a single session claim format. +func TestUserClaimsMarshalIncludesLegacyConsoleAliases(t *testing.T) { + claims := UserClaims{ + RegisteredClaims: &jwt.RegisteredClaims{}, + UserSessionInfo: &UserSessionInfo{ + Provider: "native", + Login: "admin@example.org", + Roles: []string{RoleAdmin}, + UserID: "user-1", + }, + } + + payload, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + + text := string(payload) + for _, expected := range []string{ + `"login":"admin@example.org"`, + `"username":"admin@example.org"`, + `"userid":"user-1"`, + `"user_id":"user-1"`, + } { + if !strings.Contains(text, expected) { + t.Fatalf("expected %s in %s", expected, text) + } + } +} + +// Older console tokens only carried username/user_id, so the framework parser must +// backfill its native login/userid fields from those aliases during migration. +func TestUserClaimsUnmarshalAcceptsLegacyConsoleAliases(t *testing.T) { + var claims UserClaims + err := json.Unmarshal([]byte(`{ + "provider":"native", + "username":"admin@example.org", + "user_id":"user-1", + "roles":["admin"] + }`), &claims) + if err != nil { + t.Fatalf("unmarshal claims: %v", err) + } + + if claims.Login != "admin@example.org" { + t.Fatalf("expected login to be backfilled from username, got %q", claims.Login) + } + if claims.UserID != "user-1" { + t.Fatalf("expected user id to be backfilled from user_id, got %q", claims.UserID) + } + if claims.Provider != "native" { + t.Fatalf("expected provider to be preserved, got %q", claims.Provider) + } +} diff --git a/core/security/validate.go b/core/security/validate.go index 2dc3c1353..0ae2ce4e0 100644 --- a/core/security/validate.go +++ b/core/security/validate.go @@ -15,11 +15,8 @@ import ( "infini.sh/framework/core/errors" ) -func byAuthorizationHeader(w http.ResponseWriter, r *http.Request) (claims *UserClaims, err error) { - var ( - authorization = r.Header.Get("Authorization") - ok bool - ) +func parseUserClaimsFromAuthorizationHeader(authorization string) (claims *UserClaims, err error) { + var ok bool if authorization == "" { return nil, errors.Error("Authorization not found") @@ -63,6 +60,20 @@ func byAuthorizationHeader(w http.ResponseWriter, r *http.Request) (claims *User return claims, nil } +// ValidateAuthorizationHeader validates a bearer token header and returns the +// decoded framework session information for callers that only have header access. +func ValidateAuthorizationHeader(authorization string) (*UserSessionInfo, error) { + claims, err := parseUserClaimsFromAuthorizationHeader(authorization) + if err != nil { + return nil, err + } + return claims.UserSessionInfo, nil +} + +func byAuthorizationHeader(w http.ResponseWriter, r *http.Request) (claims *UserClaims, err error) { + return parseUserClaimsFromAuthorizationHeader(r.Header.Get("Authorization")) +} + func ValidateLogin(w http.ResponseWriter, r *http.Request) (session *UserSessionInfo, err error) { var claims *UserClaims diff --git a/core/security/validate_test.go b/core/security/validate_test.go new file mode 100644 index 000000000..144a6f8c5 --- /dev/null +++ b/core/security/validate_test.go @@ -0,0 +1,68 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package security + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +// Header-only callers in downstream apps need the same validation path as the +// framework HTTP auth middleware while shared auth code is being adopted. +func TestValidateAuthorizationHeader(t *testing.T) { + oldSecret := secretKey + secretKey = "test-framework-secret" + defer func() { + secretKey = oldSecret + }() + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaims{ + RegisteredClaims: &jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + UserSessionInfo: &UserSessionInfo{ + Provider: "native", + Login: "admin@example.org", + Roles: []string{RoleAdmin}, + UserID: "user-1", + }, + }) + tokenString, err := token.SignedString([]byte(secretKey)) + if err != nil { + t.Fatalf("sign token: %v", err) + } + + sessionUser, err := ValidateAuthorizationHeader("Bearer " + tokenString) + if err != nil { + t.Fatalf("validate authorization header: %v", err) + } + if sessionUser.Login != "admin@example.org" { + t.Fatalf("expected login to round-trip, got %q", sessionUser.Login) + } + if sessionUser.UserID != "user-1" { + t.Fatalf("expected user id to round-trip, got %q", sessionUser.UserID) + } +} diff --git a/modules/security/account/refresh.go b/modules/security/account/refresh.go new file mode 100644 index 000000000..ea000d9e9 --- /dev/null +++ b/modules/security/account/refresh.go @@ -0,0 +1,95 @@ +/* Copyright © INFINI LTD. All rights reserved. + * Web: https://infinilabs.com + * Email: hello#infini.ltd */ + +package account + +import ( + "fmt" + "net/http" + "strings" + + "infini.sh/framework/core/api" + httprouter "infini.sh/framework/core/api/router" + "infini.sh/framework/core/security" +) + +func init() { + api.HandleUIMethod(api.POST, "/account/refresh", api.RequireSecureTransport(Refresh), api.RequireLogin(), api.AllowOPTIONSS(), api.Feature(api.FeatureCORS)) +} + +// Refresh reissues an access token for the current session user while reloading the +// native account record so updated roles/profile data are reflected in new tokens. +func Refresh(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + reqUser, err := security.GetUserFromContext(r.Context()) + if err != nil || reqUser == nil { + api.WriteError(w, "invalid user", http.StatusUnauthorized) + return + } + + sessionUser, err := buildRefreshedSession(reqUser) + if err != nil { + api.WriteError(w, err.Error(), http.StatusUnauthorized) + return + } + + if err, token := security.AddUserToSession(w, r, sessionUser); err != nil { + api.WriteError(w, err.Error(), http.StatusInternalServerError) + } else { + security.DecorateSessionTokenResponse(token, sessionUser) + api.WriteOKJSON(w, token) + } +} + +func buildRefreshedSession(reqUser *security.UserSessionInfo) (*security.UserSessionInfo, error) { + if reqUser == nil { + return nil, fmt.Errorf("user not found") + } + + sessionUser := cloneSessionUser(reqUser) + if reqUser.Provider != security.DefaultNativeAuthBackend { + return sessionUser, nil + } + + provider, account, err := security.GetUserByID(reqUser.UserID) + if err != nil { + return nil, err + } + if account == nil { + return nil, fmt.Errorf("user not found") + } + + login := strings.TrimSpace(account.Email) + if login == "" { + login = strings.TrimSpace(reqUser.Login) + } + if provider == "" { + provider = security.DefaultNativeAuthBackend + } + + sessionUser = &security.UserSessionInfo{ + Provider: provider, + Login: login, + Roles: append([]string(nil), account.Roles...), + Permissions: append([]security.PermissionKey(nil), reqUser.Permissions...), + LastLogin: reqUser.LastLogin, + } + sessionUser.SetUserID(account.ID) + return sessionUser, nil +} + +func cloneSessionUser(reqUser *security.UserSessionInfo) *security.UserSessionInfo { + if reqUser == nil { + return nil + } + + sessionUser := &security.UserSessionInfo{ + Provider: reqUser.Provider, + Login: reqUser.Login, + Roles: append([]string(nil), reqUser.Roles...), + Permissions: append([]security.PermissionKey(nil), reqUser.Permissions...), + LastLogin: reqUser.LastLogin, + } + sessionUser.SetUserID(reqUser.UserID) + return sessionUser +} diff --git a/modules/security/account/refresh_test.go b/modules/security/account/refresh_test.go new file mode 100644 index 000000000..41e49aecb --- /dev/null +++ b/modules/security/account/refresh_test.go @@ -0,0 +1,98 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package account + +import ( + "testing" + + "infini.sh/framework/core/security" +) + +type refreshTestProvider struct{} + +func (refreshTestProvider) GetUserByID(id string) (bool, *security.UserAccount, error) { + if id != "refresh-native-user" { + return false, nil, nil + } + + account := &security.UserAccount{ + Name: "Refreshed Admin", + Email: "refreshed@example.org", + Roles: []string{security.RoleAdmin}, + } + account.ID = id + return true, account, nil +} + +func (refreshTestProvider) GetUserByLogin(login string) (bool, *security.UserAccount, error) { + return false, nil, nil +} + +func (refreshTestProvider) CreateUser(name, login, password string, force bool) (*security.UserAccount, error) { + return nil, nil +} + +// External providers can keep their current session payload when refreshing. +func TestBuildRefreshedSessionKeepsExternalUser(t *testing.T) { + reqUser := &security.UserSessionInfo{ + Provider: "sso", + Login: "alice@example.org", + Roles: []string{"viewer"}, + } + reqUser.SetUserID("external-1") + + sessionUser, err := buildRefreshedSession(reqUser) + if err != nil { + t.Fatalf("build refreshed session: %v", err) + } + if sessionUser.Login != reqUser.Login { + t.Fatalf("expected external login %q, got %q", reqUser.Login, sessionUser.Login) + } + if sessionUser.UserID != reqUser.UserID { + t.Fatalf("expected external user id %q, got %q", reqUser.UserID, sessionUser.UserID) + } +} + +// Native refreshes should pull the latest account snapshot from the registered backend. +func TestBuildRefreshedSessionReloadsNativeAccount(t *testing.T) { + security.RegisterAuthenticationProvider("refresh-test-provider", refreshTestProvider{}) + + reqUser := &security.UserSessionInfo{ + Provider: security.DefaultNativeAuthBackend, + Login: "stale@example.org", + Roles: []string{"viewer"}, + } + reqUser.SetUserID("refresh-native-user") + + sessionUser, err := buildRefreshedSession(reqUser) + if err != nil { + t.Fatalf("build refreshed session: %v", err) + } + if sessionUser.Login != "refreshed@example.org" { + t.Fatalf("expected refreshed login, got %q", sessionUser.Login) + } + if len(sessionUser.Roles) != 1 || sessionUser.Roles[0] != security.RoleAdmin { + t.Fatalf("expected refreshed roles, got %#v", sessionUser.Roles) + } +} diff --git a/modules/security/rbac/account_login.go b/modules/security/rbac/account_login.go index 1b0672800..9fdea979c 100644 --- a/modules/security/rbac/account_login.go +++ b/modules/security/rbac/account_login.go @@ -179,6 +179,7 @@ func Login(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { if err, token := security.AddUserToSession(w, r, sessionUser); err != nil { api.WriteError(w, err.Error(), http.StatusInternalServerError) } else { + security.DecorateSessionTokenResponse(token, sessionUser) api.WriteOKJSON(w, token) } } diff --git a/modules/security/rbac/account_login_test.go b/modules/security/rbac/account_login_test.go index fe66941db..97524465e 100644 --- a/modules/security/rbac/account_login_test.go +++ b/modules/security/rbac/account_login_test.go @@ -28,6 +28,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "infini.sh/framework/core/security" replaysecurity "infini.sh/framework/core/security/replay" @@ -188,3 +189,38 @@ func TestNewNativeSessionFallsBackToRequestedLogin(t *testing.T) { t.Fatalf("expected native provider, got %q", session.Provider) } } + +// The framework login response keeps the console frontend contract while the handler +// implementation moves from console into framework-owned routes. +func TestDecorateLoginResponseAddsConsoleCompatibilityFields(t *testing.T) { + session := &security.UserSessionInfo{ + Provider: security.DefaultNativeAuthBackend, + Login: "admin@example.org", + Roles: []string{security.RoleAdmin}, + Permissions: []security.PermissionKey{security.GetSimplePermission("generic", "unit", security.Read)}, + } + session.SetUserID("user-1") + + token := map[string]interface{}{ + "status": "ok", + "expire_in": time.Now().Unix() + 3600, + } + security.DecorateSessionTokenResponse(token, session) + + if token["username"] != session.Login { + t.Fatalf("expected username %q, got %v", session.Login, token["username"]) + } + if token["id"] != session.UserID { + t.Fatalf("expected id %q, got %v", session.UserID, token["id"]) + } + if token["expires_at"] == nil { + t.Fatal("expected expires_at to be populated") + } + if expireIn, ok := token["expire_in"].(int64); !ok || expireIn <= 0 || expireIn > 3600 { + t.Fatalf("expected expire_in to become remaining lifetime seconds, got %#v", token["expire_in"]) + } + privilege, ok := token["privilege"].([]security.PermissionKey) + if !ok || len(privilege) == 0 { + t.Fatalf("expected privilege list to be populated, got %#v", token["privilege"]) + } +} From 1118e4bb0d57c5f3fa3c179b1eb387dcd3647d08 Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 17:06:14 +0800 Subject: [PATCH 17/24] feat: support shared account flow migration\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/security/account_flow.go | 88 +++++++++++++++++++++ core/security/password_challenge.go | 41 +++++++--- core/security/session.go | 2 +- modules/security/rbac/account_login.go | 48 ++++++----- modules/security/rbac/account_login_test.go | 49 +++++++++++- 5 files changed, 194 insertions(+), 34 deletions(-) create mode 100644 core/security/account_flow.go diff --git a/core/security/account_flow.go b/core/security/account_flow.go new file mode 100644 index 000000000..9cf0e886c --- /dev/null +++ b/core/security/account_flow.go @@ -0,0 +1,88 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package security + +import "sync" + +// AccountPasswordLoginProvider lets applications keep their own password-auth realms +// while reusing the framework-owned /account/login HTTP flow and session issuance. +type AccountPasswordLoginProvider interface { + AuthenticateByPassword(login, password string) (*UserSessionInfo, error) +} + +var accountPasswordLoginProviders = sync.Map{} + +func RegisterAccountPasswordLoginProvider(name string, provider AccountPasswordLoginProvider) { + accountPasswordLoginProviders.Store(name, provider) +} + +// AuthenticateAccountPasswordLogin tries application-provided password login providers +// after the native framework account path has either not matched or not succeeded. +func AuthenticateAccountPasswordLogin(login, password string) (*UserSessionInfo, error) { + var out *UserSessionInfo + var lastErr error + + accountPasswordLoginProviders.Range(func(key, value any) bool { + provider, ok := value.(AccountPasswordLoginProvider) + if !ok { + return true + } + + sessionUser, err := provider.AuthenticateByPassword(login, password) + if err != nil { + lastErr = err + return true + } + if sessionUser != nil { + out = sessionUser + return false + } + return true + }) + + if out != nil { + return out, nil + } + return nil, lastErr +} + +// SessionTokenResponseDecorator lets applications enrich the shared login/refresh +// response with app-specific fields while reusing the framework session pipeline. +type SessionTokenResponseDecorator func(token map[string]interface{}, user *UserSessionInfo) + +var sessionTokenResponseDecorators = sync.Map{} + +func RegisterSessionTokenResponseDecorator(name string, decorator SessionTokenResponseDecorator) { + sessionTokenResponseDecorators.Store(name, decorator) +} + +func applySessionTokenResponseDecorators(token map[string]interface{}, user *UserSessionInfo) { + sessionTokenResponseDecorators.Range(func(key, value any) bool { + decorator, ok := value.(SessionTokenResponseDecorator) + if ok { + decorator(token, user) + } + return true + }) +} diff --git a/core/security/password_challenge.go b/core/security/password_challenge.go index 2c860a6ed..c007031cd 100644 --- a/core/security/password_challenge.go +++ b/core/security/password_challenge.go @@ -43,33 +43,54 @@ const ( // LoginChallenge re-exports the framework challenge payload used by native account login. type LoginChallenge = passwordchallenge.Challenge +// PasswordMaterial bundles the fields that apps need to persist after accepting a password. +type PasswordMaterial struct { + Hash string + Salt string + Verifier string +} + // CanUsePasswordChallenge reports whether a native account already has challenge credentials. func CanUsePasswordChallenge(user *UserAccount) bool { return user != nil && user.PasswordSalt != "" && user.PasswordVerifier != "" } -// SetPassword updates both the legacy bcrypt hash and the challenge verifier material. -func SetPassword(user *UserAccount, password string) error { - if user == nil { - return errors.New("user is nil") - } - +// GeneratePasswordMaterial derives the bcrypt hash and challenge verifier fields for a password. +func GeneratePasswordMaterial(password string) (*PasswordMaterial, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { - return err + return nil, err } // Store both the bcrypt hash for existing password checks and the derived // verifier for challenge login so the two login modes stay in sync. salt := util.GenerateSecureString(32) verifier, err := DerivePasswordVerifier(password, salt) + if err != nil { + return nil, err + } + + return &PasswordMaterial{ + Hash: string(hash), + Salt: salt, + Verifier: verifier, + }, nil +} + +// SetPassword updates both the legacy bcrypt hash and the challenge verifier material. +func SetPassword(user *UserAccount, password string) error { + if user == nil { + return errors.New("user is nil") + } + + material, err := GeneratePasswordMaterial(password) if err != nil { return err } - user.Password = string(hash) - user.PasswordSalt = salt - user.PasswordVerifier = verifier + user.Password = material.Hash + user.PasswordSalt = material.Salt + user.PasswordVerifier = material.Verifier return nil } diff --git a/core/security/session.go b/core/security/session.go index 93dc97b97..73d51c325 100644 --- a/core/security/session.go +++ b/core/security/session.go @@ -114,7 +114,6 @@ func GenerateJWTAccessToken(user *UserSessionInfo) (map[string]interface{}, erro return data, err } - // DecorateSessionTokenResponse keeps framework-issued account responses directly // consumable by existing console clients while auth flows converge on framework. func DecorateSessionTokenResponse(token map[string]interface{}, user *UserSessionInfo) { @@ -136,6 +135,7 @@ func DecorateSessionTokenResponse(token map[string]interface{}, user *UserSessio token["id"] = user.UserID token["roles"] = append([]string(nil), user.Roles...) token["privilege"] = GetAllPermissionsForUser(user) + applySessionTokenResponseDecorators(token, user) } func tokenExpiresAtUnix(value interface{}) int64 { diff --git a/modules/security/rbac/account_login.go b/modules/security/rbac/account_login.go index 9fdea979c..eba0ce312 100644 --- a/modules/security/rbac/account_login.go +++ b/modules/security/rbac/account_login.go @@ -145,23 +145,23 @@ func Login(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { return } + usedChallenge := req.ChallengeID != "" || req.Proof != "" exists, user, err := lookupAccountByLogin(login) if err != nil { api.WriteError(w, err.Error(), http.StatusInternalServerError) return } - if !exists || user == nil { + if usedChallenge && (!exists || user == nil) { api.WriteError(w, errInvalidLoginCredentials.Error(), http.StatusForbidden) return } - usedChallenge := req.ChallengeID != "" || req.Proof != "" if err := validateReplayNonce(r, usedChallenge); err != nil { api.WriteError(w, err.Error(), http.StatusUnauthorized) return } - usedChallenge, err = authenticateLogin(user, login, req.Password, req.ChallengeID, req.Proof) + usedChallenge, sessionUser, nativeUser, err := authenticateLogin(user, login, req.Password, req.ChallengeID, req.Proof) if err != nil { statusCode := http.StatusForbidden if errors.Is(err, errIncompleteChallenge) || errors.Is(err, errMissingPassword) { @@ -171,11 +171,10 @@ func Login(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { return } - if !usedChallenge { - upgradePasswordChallenge(user, req.Password) + if !usedChallenge && nativeUser != nil { + upgradePasswordChallenge(nativeUser, req.Password) } - sessionUser := newNativeSession(user, login) if err, token := security.AddUserToSession(w, r, sessionUser); err != nil { api.WriteError(w, err.Error(), http.StatusInternalServerError) } else { @@ -219,33 +218,44 @@ func buildLoginChallengeResponse(login string, exists bool, user *security.UserA } // authenticateLogin selects the correct credential validation path based on the request body. -func authenticateLogin(user *security.UserAccount, login, password, challengeID, proof string) (bool, error) { - if user == nil { - return false, errInvalidLoginCredentials - } - +func authenticateLogin(user *security.UserAccount, login, password, challengeID, proof string) (bool, *security.UserSessionInfo, *security.UserAccount, error) { if challengeID != "" || proof != "" { if challengeID == "" || proof == "" { - return true, errIncompleteChallenge + return true, nil, nil, errIncompleteChallenge } + if user == nil { + return true, nil, nil, errInvalidLoginCredentials + } challenge, err := security.ConsumeLoginChallenge(challengeID, login) if err != nil || !security.CanUsePasswordChallenge(user) { - return true, errInvalidLoginCredentials + return true, nil, nil, errInvalidLoginCredentials } if !security.VerifyPasswordProof(user.PasswordVerifier, login, challenge.ID, challenge.Nonce, proof) { - return true, errInvalidLoginCredentials + return true, nil, nil, errInvalidLoginCredentials } - return true, nil + return true, newNativeSession(user, login), user, nil } if password == "" { - return false, errMissingPassword + return false, nil, nil, errMissingPassword + } + + if user != nil { + if err := security.VerifyPassword(user, password); err == nil { + return false, newNativeSession(user, login), user, nil + } + } + + sessionUser, err := security.AuthenticateAccountPasswordLogin(login, password) + if err != nil { + return false, nil, nil, err } - if err := security.VerifyPassword(user, password); err != nil { - return false, errInvalidLoginCredentials + if sessionUser != nil { + return false, sessionUser, nil, nil } - return false, nil + + return false, nil, nil, errInvalidLoginCredentials } // lookupAccountByLogin normalizes the service-registry "not found" result into a regular miss. diff --git a/modules/security/rbac/account_login_test.go b/modules/security/rbac/account_login_test.go index 97524465e..6b4b60a75 100644 --- a/modules/security/rbac/account_login_test.go +++ b/modules/security/rbac/account_login_test.go @@ -34,6 +34,22 @@ import ( replaysecurity "infini.sh/framework/core/security/replay" ) +type testAccountPasswordLoginProvider struct{} + +func (testAccountPasswordLoginProvider) AuthenticateByPassword(login, password string) (*security.UserSessionInfo, error) { + if login != "ldap-user" || password != "StrongPassw0rd!" { + return nil, nil + } + + sessionUser := &security.UserSessionInfo{ + Provider: "ldap", + Login: login, + Roles: []string{"viewer"}, + } + sessionUser.SetUserID("ldap-user-id") + return sessionUser, nil +} + // The request payload accepts multiple historical login field names during rollout. func TestAccountLoginRequestNormalizedLogin(t *testing.T) { req := accountLoginRequest{ @@ -53,13 +69,16 @@ func TestAuthenticateLoginWithPassword(t *testing.T) { t.Fatalf("set password: %v", err) } - usedChallenge, err := authenticateLogin(user, user.Email, "StrongPassw0rd!", "", "") + usedChallenge, sessionUser, nativeUser, err := authenticateLogin(user, user.Email, "StrongPassw0rd!", "", "") if err != nil { t.Fatalf("authenticate login: %v", err) } if usedChallenge { t.Fatal("expected password login path") } + if sessionUser == nil || nativeUser == nil { + t.Fatalf("expected native password login state, got session=%#v native=%#v", sessionUser, nativeUser) + } } // Challenge login should succeed once the account already has verifier material. @@ -75,13 +94,16 @@ func TestAuthenticateLoginWithChallenge(t *testing.T) { t.Fatalf("build password proof: %v", err) } - usedChallenge, err := authenticateLogin(user, user.Email, "", challenge.ID, proof) + usedChallenge, sessionUser, nativeUser, err := authenticateLogin(user, user.Email, "", challenge.ID, proof) if err != nil { t.Fatalf("authenticate login: %v", err) } if !usedChallenge { t.Fatal("expected challenge login path") } + if sessionUser == nil || nativeUser == nil { + t.Fatalf("expected native challenge login state, got session=%#v native=%#v", sessionUser, nativeUser) + } } // Partially supplied challenge payloads should fail distinctly from bad credentials. @@ -91,7 +113,7 @@ func TestAuthenticateLoginRejectsIncompleteChallenge(t *testing.T) { t.Fatalf("set password: %v", err) } - _, err := authenticateLogin(user, user.Email, "", "challenge-id", "") + _, _, _, err := authenticateLogin(user, user.Email, "", "challenge-id", "") if !errors.Is(err, errIncompleteChallenge) { t.Fatalf("expected incomplete challenge error, got %v", err) } @@ -105,12 +127,31 @@ func TestAuthenticateLoginRejectsWrongProof(t *testing.T) { } challenge := security.NewLoginChallenge(user.Email) - _, err := authenticateLogin(user, user.Email, "", challenge.ID, "bad-proof") + _, _, _, err := authenticateLogin(user, user.Email, "", challenge.ID, "bad-proof") if !errors.Is(err, errInvalidLoginCredentials) { t.Fatalf("expected invalid credential error, got %v", err) } } +// Applications can attach non-native password realms to the shared framework login flow. +func TestAuthenticateLoginFallsBackToRegisteredPasswordProvider(t *testing.T) { + security.RegisterAccountPasswordLoginProvider("test-account-login", testAccountPasswordLoginProvider{}) + + usedChallenge, sessionUser, nativeUser, err := authenticateLogin(nil, "ldap-user", "StrongPassw0rd!", "", "") + if err != nil { + t.Fatalf("authenticate login: %v", err) + } + if usedChallenge { + t.Fatal("expected password fallback path") + } + if nativeUser != nil { + t.Fatalf("expected no native user for fallback path, got %#v", nativeUser) + } + if sessionUser == nil || sessionUser.Provider != "ldap" { + t.Fatalf("expected ldap session user, got %#v", sessionUser) + } +} + // Older accounts intentionally advertise plain login until their verifier is available. func TestBuildLoginChallengeResponseFallsBackToPlain(t *testing.T) { user := &security.UserAccount{Email: "admin@example.org"} From 98aae585cfe9bb31163f796821488afbabeef320 Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 17:18:24 +0800 Subject: [PATCH 18/24] Avoid panic in RBAC user flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- modules/security/rbac/user.go | 84 ++++++++++++++++++++---------- modules/security/rbac/user_test.go | 38 ++++++++++++++ 2 files changed, 95 insertions(+), 27 deletions(-) create mode 100644 modules/security/rbac/user_test.go diff --git a/modules/security/rbac/user.go b/modules/security/rbac/user.go index 64d36b44e..f8ea5c6a2 100644 --- a/modules/security/rbac/user.go +++ b/modules/security/rbac/user.go @@ -11,12 +11,20 @@ import ( "infini.sh/framework/core/api" httprouter "infini.sh/framework/core/api/router" "infini.sh/framework/core/elastic" + cerr "infini.sh/framework/core/errors" "infini.sh/framework/core/global" "infini.sh/framework/core/orm" "infini.sh/framework/core/security" "infini.sh/framework/core/util" ) +const ( + errCannotUpdateOwnRoles = "sorry, you can not update your roles" + errCannotDeleteSelf = "you can not delete yourself" + errInsecurePassword = "password does not meet security requirements" + errEmailAlreadyExists = "email already existed" +) + func GetUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { id := ps.MustGetParameter("id") @@ -25,10 +33,11 @@ func GetUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { ctx := orm.NewContextWithParent(req.Context()) exists, err := orm.GetV2(ctx, &obj) if err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return } if !exists { - api.NotFoundResponse(id) + api.WriteJSON(w, api.NotFoundResponse(id), http.StatusNotFound) return } @@ -42,7 +51,8 @@ func UpdateUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) obj := security.UserAccount{} err := api.DecodeJSON(req, &obj) if err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusBadRequest) + return } api.MustValidateInput(w, obj) @@ -51,10 +61,11 @@ func UpdateUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) oldObj.ID = id exists, err := orm.GetV2(ctx, &oldObj) if err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return } if !exists { - api.NotFoundResponse(id) + api.WriteJSON(w, api.NotFoundResponse(id), http.StatusNotFound) return } @@ -67,7 +78,8 @@ func UpdateUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) if userID == id { //user can't update self's role if !util.CompareStringArray(obj.Roles, oldObj.Roles) { - panic("sorry, you can not update your roles") + api.WriteError(w, errCannotUpdateOwnRoles, http.StatusForbidden) + return } } @@ -78,17 +90,20 @@ func UpdateUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) obj.PasswordSalt = oldObj.PasswordSalt obj.PasswordVerifier = oldObj.PasswordVerifier } else { - if !util.ValidateSecure(obj.Password) { - panic("should be secured password") + if err := validateSecurePassword(obj.Password); err != nil { + api.WriteError(w, err.Error(), http.StatusBadRequest) + return } if err := security.SetPassword(&obj, obj.Password); err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return } } ctx.Refresh = orm.WaitForRefresh err = orm.Update(ctx, &obj) if err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return } security.IncreasePermissionVersion() @@ -104,13 +119,15 @@ func DeleteUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) sessionUser := security.MustGetUserFromContext(ctx) userID := sessionUser.MustGetUserID() if userID == id { - panic("you can not delete yourself") + api.WriteError(w, errCannotDeleteSelf, http.StatusForbidden) + return } ctx.Refresh = orm.WaitForRefresh err := orm.Delete(ctx, &obj) if err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return } api.WriteDeletedOKJSON(w, obj.ID) @@ -119,7 +136,8 @@ func DeleteUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) func SearchUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { builder, err := orm.NewQueryBuilderFromRequest(req, "id", "name", "email") if err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusBadRequest) + return } ctx := orm.NewContextWithParent(req.Context()) ctx.DirectReadAccess() @@ -129,12 +147,13 @@ func SearchUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) orm.WithModel(ctx, &security.UserAccount{}) res, err := orm.SearchV2(ctx, builder) if err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return } _, err = api.Write(w, res.Payload.([]byte)) if err != nil { - panic(err) + api.Error(w, err) } } @@ -187,17 +206,17 @@ func (provider *SecurityBackendProvider) GetUserByID(id string) (bool, *security func (provider *SecurityBackendProvider) CreateUser(name, email, password string, force bool) (*security.UserAccount, error) { - if !util.ValidateSecure(password) { - panic("should be secured password") + if err := validateSecurePassword(password); err != nil { + return nil, err } exists, account, err := GetUserByLogin(email) if err != nil { - panic(err) + return nil, err } if exists && !force { - panic("email already existed") + return nil, cerr.NewWithHTTPCode(http.StatusConflict, errEmailAlreadyExists) } var obj = &security.UserAccount{} @@ -212,7 +231,7 @@ func (provider *SecurityBackendProvider) CreateUser(name, email, password string obj.Email = email obj.Roles = []string{security.RoleAdmin} if err := security.SetPassword(obj, password); err != nil { - panic(err) + return nil, err } ctx := orm.NewContext() @@ -220,7 +239,7 @@ func (provider *SecurityBackendProvider) CreateUser(name, email, password string ctx.Refresh = orm.WaitForRefresh err = orm.Save(ctx, obj) if err != nil { - panic(err) + return nil, err } return obj, nil } @@ -233,33 +252,37 @@ func CreateUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) var obj = &security.UserAccount{} err := api.DecodeJSON(req, obj) if err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusBadRequest) + return } api.MustValidateInput(w, obj) exists, account, err := GetUserByLogin(obj.Email) if err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return } if exists && account != nil { log.Warn("email already exists") - //obj.ID = account.ID - panic("email already existed") + api.WriteError(w, errEmailAlreadyExists, http.StatusConflict) + return } else { obj.ID = getUIDByEmail(obj.Email) } randStr := util.GenerateSecureString(8) if err := security.SetPassword(obj, randStr); err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return } ctx := orm.NewContextWithParent(req.Context()) ctx.Refresh = orm.WaitForRefresh err = orm.Save(ctx, obj) if err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return } obj.Password = randStr @@ -269,3 +292,10 @@ func CreateUser(w http.ResponseWriter, req *http.Request, ps httprouter.Params) obj.PasswordVerifier = "" api.WriteJSON(w, obj, 200) } + +func validateSecurePassword(password string) error { + if util.ValidateSecure(password) { + return nil + } + return cerr.NewWithHTTPCode(http.StatusBadRequest, errInsecurePassword) +} diff --git a/modules/security/rbac/user_test.go b/modules/security/rbac/user_test.go new file mode 100644 index 000000000..be4a9333f --- /dev/null +++ b/modules/security/rbac/user_test.go @@ -0,0 +1,38 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package rbac + +import "testing" + +// Weak passwords should now fail as normal validation errors instead of aborting +// the request flow via panic. +func TestValidateSecurePassword(t *testing.T) { + if err := validateSecurePassword("weak"); err == nil { + t.Fatal("expected weak password to be rejected") + } + + if err := validateSecurePassword("StrongPassw0rd!"); err != nil { + t.Fatalf("expected strong password to pass validation, got %v", err) + } +} From dd84ff209d0194fec7a19d60397a8d6bd1718844 Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 17:31:53 +0800 Subject: [PATCH 19/24] Clean auth runtime panics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/security/session.go | 2 +- core/security/session_test.go | 36 ++++++++++++++++++++++++ core/security/user_session.go | 4 +-- core/security/user_session_test.go | 11 ++++++++ core/security/validate_test.go | 27 ++++++++++++++++++ modules/security/rbac/entity.go | 4 ++- modules/security/rbac/principal.go | 6 ++-- modules/security/rbac/role.go | 45 ++++++++++++++++++++++-------- 8 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 core/security/session_test.go diff --git a/core/security/session.go b/core/security/session.go index 73d51c325..97270100f 100644 --- a/core/security/session.go +++ b/core/security/session.go @@ -66,7 +66,7 @@ func byAccessTokenSession(w http.ResponseWriter, r *http.Request) (claims *UserC func AddUserToSession(w http.ResponseWriter, r *http.Request, user *UserSessionInfo) (error, map[string]interface{}) { if user == nil { - panic("invalid user") + return errors.NewWithHTTPCode(http.StatusUnauthorized, "invalid user"), nil } // Generate access token diff --git a/core/security/session_test.go b/core/security/session_test.go new file mode 100644 index 000000000..aaa1d5cba --- /dev/null +++ b/core/security/session_test.go @@ -0,0 +1,36 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Framework is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package security + +import "testing" + +func TestAddUserToSessionRejectsNilUser(t *testing.T) { + err, token := AddUserToSession(nil, nil, nil) + if err == nil { + t.Fatal("expected nil user to be rejected") + } + if token != nil { + t.Fatalf("expected no token for nil user, got %+v", token) + } +} diff --git a/core/security/user_session.go b/core/security/user_session.go index 0bbcddfb8..5451435f1 100644 --- a/core/security/user_session.go +++ b/core/security/user_session.go @@ -147,7 +147,7 @@ func (u *UserSessionInfo) MustGetUserID() string { return u.UserID } - panic(errors.NewWithHTTPCode(400, "invalid user")) + panic(errors.NewWithHTTPCode(401, "invalid user")) } func (u *UserSessionInfo) IsValid() bool { @@ -156,7 +156,7 @@ func (u *UserSessionInfo) IsValid() bool { if global.Env().IsDebug { log.Error(util.MustToJSON(u), u.UserID) } - panic(errors.NewWithHTTPCode(400, "invalid user")) + return false } return v } diff --git a/core/security/user_session_test.go b/core/security/user_session_test.go index 41b37eb19..d383b829a 100644 --- a/core/security/user_session_test.go +++ b/core/security/user_session_test.go @@ -86,3 +86,14 @@ func TestUserClaimsUnmarshalAcceptsLegacyConsoleAliases(t *testing.T) { t.Fatalf("expected provider to be preserved, got %q", claims.Provider) } } + +func TestUserSessionInfoIsValidReturnsFalseForIncompleteUser(t *testing.T) { + user := &UserSessionInfo{ + Provider: "native", + Login: "admin@example.org", + } + + if user.IsValid() { + t.Fatal("expected incomplete user session to be invalid") + } +} diff --git a/core/security/validate_test.go b/core/security/validate_test.go index 144a6f8c5..6ad896983 100644 --- a/core/security/validate_test.go +++ b/core/security/validate_test.go @@ -66,3 +66,30 @@ func TestValidateAuthorizationHeader(t *testing.T) { t.Fatalf("expected user id to round-trip, got %q", sessionUser.UserID) } } + +func TestValidateAuthorizationHeaderRejectsIncompleteUserClaims(t *testing.T) { + oldSecret := secretKey + secretKey = "test-framework-secret" + defer func() { + secretKey = oldSecret + }() + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, UserClaims{ + RegisteredClaims: &jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + UserSessionInfo: &UserSessionInfo{ + Provider: "native", + Login: "admin@example.org", + }, + }) + tokenString, err := token.SignedString([]byte(secretKey)) + if err != nil { + t.Fatalf("sign token: %v", err) + } + + sessionUser, err := ValidateAuthorizationHeader("Bearer " + tokenString) + if err == nil { + t.Fatalf("expected invalid claims to be rejected, got user %+v", sessionUser) + } +} diff --git a/modules/security/rbac/entity.go b/modules/security/rbac/entity.go index 20544681d..9a839766f 100644 --- a/modules/security/rbac/entity.go +++ b/modules/security/rbac/entity.go @@ -7,6 +7,7 @@ package rbac import ( "context" + log "github.com/cihub/seelog" "infini.sh/framework/core/elastic" "infini.sh/framework/core/entity_card" "infini.sh/framework/core/orm" @@ -47,7 +48,8 @@ func (this *UserEntityProvider) GenEntityLabel(ctx1 context.Context, t string, i out := []security.UserAccount{} err, _ := elastic.SearchV2WithResultItemMapper(ctx, &out, builder, nil) if err != nil { - panic(err) + log.Errorf("failed to load user entity labels for ids %v: %v", ids, err) + return output } for _, a := range out { diff --git a/modules/security/rbac/principal.go b/modules/security/rbac/principal.go index 1f24d27a8..d21d91a61 100644 --- a/modules/security/rbac/principal.go +++ b/modules/security/rbac/principal.go @@ -18,7 +18,8 @@ func SearchPrincipals(w http.ResponseWriter, req *http.Request, ps httprouter.Pa builder, err := orm.NewQueryBuilderFromRequest(req, "id", "name", "email") if err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusBadRequest) + return } ctx := orm.NewContextWithParent(req.Context()) ctx.DirectReadAccess() @@ -26,7 +27,8 @@ func SearchPrincipals(w http.ResponseWriter, req *http.Request, ps httprouter.Pa out := []security.UserAccount{} err, res := elastic.SearchV2WithResultItemMapper(ctx, &out, builder, nil) if err != nil { - panic(err) + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return } // use the generic type correctly diff --git a/modules/security/rbac/role.go b/modules/security/rbac/role.go index a611d881a..404f627a0 100644 --- a/modules/security/rbac/role.go +++ b/modules/security/rbac/role.go @@ -8,6 +8,7 @@ import ( "context" "net/http" + log "github.com/cihub/seelog" "infini.sh/framework/core/api" httprouter "infini.sh/framework/core/api/router" "infini.sh/framework/core/elastic" @@ -17,6 +18,14 @@ import ( "infini.sh/framework/core/util" ) +const ( + errInvalidCurrentUser = "invalid user" + errInvalidRole = "invalid role" + errCannotUpdateOwnRole = "you can not update the roles for you" + errReservedRoleName = "can not use the reserved role name" + errRoleAlreadyExists = "same role name already exists" +) + func GetRole(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { id := ps.MustGetParameter("id") @@ -28,8 +37,12 @@ func GetRole(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { ctx.PermissionScope(security.PermissionScopePlatform) exists, err := orm.GetV2(ctx, &obj) - if !exists || err != nil { - api.NotFoundResponse(id) + if err != nil { + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + if !exists { + api.WriteJSON(w, api.NotFoundResponse(id), http.StatusNotFound) return } @@ -44,7 +57,7 @@ func UpdateRole(w http.ResponseWriter, req *http.Request, ps httprouter.Params) obj := security.UserRole{} err := api.DecodeJSON(req, &obj) if err != nil { - api.WriteError(w, err.Error(), http.StatusInternalServerError) + api.WriteError(w, err.Error(), http.StatusBadRequest) return } @@ -56,16 +69,23 @@ func UpdateRole(w http.ResponseWriter, req *http.Request, ps httprouter.Params) userID := sessionUser.MustGetUserID() _, account, err := security.GetUserByID(userID) - if account == nil || err != nil { - panic("invalid user") + if err != nil { + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + if account == nil { + api.WriteError(w, errInvalidCurrentUser, http.StatusUnauthorized) + return } _, role := GetRoleByID(id) if role == nil { - panic("invalid role") + api.WriteError(w, errInvalidRole, http.StatusNotFound) + return } if util.ContainsAnyInArray(role.Name, account.Roles) { - panic("you can not update the roles for you") + api.WriteError(w, errCannotUpdateOwnRole, http.StatusForbidden) + return } } @@ -164,19 +184,21 @@ func CreateRole(w http.ResponseWriter, req *http.Request, ps httprouter.Params) var obj = &security.UserRole{} err := api.DecodeJSON(req, obj) if err != nil { - api.WriteError(w, err.Error(), http.StatusInternalServerError) + api.WriteError(w, err.Error(), http.StatusBadRequest) return } if obj.Name == "admin" { - panic("can not use the reserved role name") + api.WriteError(w, errReservedRoleName, http.StatusBadRequest) + return } api.MustValidateInput(w, obj) exists, _ := GetRoleByName(obj.Name) if exists { - panic("same role name already exists") + api.WriteError(w, errRoleAlreadyExists, http.StatusConflict) + return } ctx := orm.NewContextWithParent(req.Context()) @@ -242,7 +264,8 @@ func (provider *SecurityBackendProvider) GetPermissionKeysByRoles(ctx1 context.C result := []security.UserRole{} err, _ := elastic.SearchV2WithResultItemMapper(ctx, &result, qb, nil) if err != nil { - panic(err) + log.Errorf("failed to load permissions for roles %v: %v", roles, err) + return []security.PermissionKey{} } allowed := make(map[security.PermissionKey]struct{}, 128) From 3ad0518d99b237657846375ef53aca11665817d8 Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 17:45:49 +0800 Subject: [PATCH 20/24] Inline RBAC account routes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- modules/security/rbac/account_login.go | 22 ---------------------- modules/security/rbac/init.go | 19 ++++++++++++++++++- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/modules/security/rbac/account_login.go b/modules/security/rbac/account_login.go index eba0ce312..e3e813f86 100644 --- a/modules/security/rbac/account_login.go +++ b/modules/security/rbac/account_login.go @@ -59,28 +59,6 @@ type accountLoginRequest struct { Proof string `json:"proof"` } -func registerAccountRoutes() { - // These endpoints are only registered from rbac.Init(), so they exist only when - // native authentication is enabled and the native user backend is ready. - api.HandleUIMethod(api.POST, "/account/replay_nonce", - api.RequireSecureTransport(IssueReplayNonce), - api.AllowPublicAccess(), - api.AllowOPTIONSS(), - api.Feature(api.FeatureCORS)) - - api.HandleUIMethod(api.POST, "/account/login/challenge", - api.RequireSecureTransport(LoginChallenge), - api.AllowPublicAccess(), - api.AllowOPTIONSS(), - api.Feature(api.FeatureCORS)) - - api.HandleUIMethod(api.POST, "/account/login", - api.RequireSecureTransport(Login), - api.AllowPublicAccess(), - api.AllowOPTIONSS(), - api.Feature(api.FeatureCORS)) -} - // IssueReplayNonce mints a short-lived nonce bound to the caller and target request scope. func IssueReplayNonce(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { var req struct { diff --git a/modules/security/rbac/init.go b/modules/security/rbac/init.go index 1a2a022ab..3b6290421 100644 --- a/modules/security/rbac/init.go +++ b/modules/security/rbac/init.go @@ -18,7 +18,24 @@ func Init() { provider := SecurityBackendProvider{} security.RegisterAuthenticationProvider(security.DefaultNativeAuthBackend, &provider) security.RegisterAuthorizationProvider(security.DefaultNativeAuthBackend, &provider) - registerAccountRoutes() + + api.HandleUIMethod(api.POST, "/account/replay_nonce", + api.RequireSecureTransport(IssueReplayNonce), + api.AllowPublicAccess(), + api.AllowOPTIONSS(), + api.Feature(api.FeatureCORS)) + + api.HandleUIMethod(api.POST, "/account/login/challenge", + api.RequireSecureTransport(LoginChallenge), + api.AllowPublicAccess(), + api.AllowOPTIONSS(), + api.Feature(api.FeatureCORS)) + + api.HandleUIMethod(api.POST, "/account/login", + api.RequireSecureTransport(Login), + api.AllowPublicAccess(), + api.AllowOPTIONSS(), + api.Feature(api.FeatureCORS)) orm.MustRegisterSchemaWithIndexName(&security.UserAccount{}, "app-users") orm.MustRegisterSchemaWithIndexName(&security.UserRole{}, "app-roles") From 481215303f355f149b26594445a26d78f2726e7f Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 17:49:36 +0800 Subject: [PATCH 21/24] Rehome account hook helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/security/account_flow.go | 88 ------------------------------- core/security/service_registry.go | 42 +++++++++++++++ core/security/session.go | 21 ++++++++ 3 files changed, 63 insertions(+), 88 deletions(-) delete mode 100644 core/security/account_flow.go diff --git a/core/security/account_flow.go b/core/security/account_flow.go deleted file mode 100644 index 9cf0e886c..000000000 --- a/core/security/account_flow.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (C) INFINI Labs & INFINI LIMITED. -// -// The INFINI Framework is offered under the GNU Affero General Public License v3.0 -// and as commercial software. -// -// For commercial licensing, contact us at: -// - Website: infinilabs.com -// - Email: hello@infini.ltd -// -// Open Source licensed under AGPL V3: -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package security - -import "sync" - -// AccountPasswordLoginProvider lets applications keep their own password-auth realms -// while reusing the framework-owned /account/login HTTP flow and session issuance. -type AccountPasswordLoginProvider interface { - AuthenticateByPassword(login, password string) (*UserSessionInfo, error) -} - -var accountPasswordLoginProviders = sync.Map{} - -func RegisterAccountPasswordLoginProvider(name string, provider AccountPasswordLoginProvider) { - accountPasswordLoginProviders.Store(name, provider) -} - -// AuthenticateAccountPasswordLogin tries application-provided password login providers -// after the native framework account path has either not matched or not succeeded. -func AuthenticateAccountPasswordLogin(login, password string) (*UserSessionInfo, error) { - var out *UserSessionInfo - var lastErr error - - accountPasswordLoginProviders.Range(func(key, value any) bool { - provider, ok := value.(AccountPasswordLoginProvider) - if !ok { - return true - } - - sessionUser, err := provider.AuthenticateByPassword(login, password) - if err != nil { - lastErr = err - return true - } - if sessionUser != nil { - out = sessionUser - return false - } - return true - }) - - if out != nil { - return out, nil - } - return nil, lastErr -} - -// SessionTokenResponseDecorator lets applications enrich the shared login/refresh -// response with app-specific fields while reusing the framework session pipeline. -type SessionTokenResponseDecorator func(token map[string]interface{}, user *UserSessionInfo) - -var sessionTokenResponseDecorators = sync.Map{} - -func RegisterSessionTokenResponseDecorator(name string, decorator SessionTokenResponseDecorator) { - sessionTokenResponseDecorators.Store(name, decorator) -} - -func applySessionTokenResponseDecorators(token map[string]interface{}, user *UserSessionInfo) { - sessionTokenResponseDecorators.Range(func(key, value any) bool { - decorator, ok := value.(SessionTokenResponseDecorator) - if ok { - decorator(token, user) - } - return true - }) -} diff --git a/core/security/service_registry.go b/core/security/service_registry.go index 6f375906b..494c4ec3f 100644 --- a/core/security/service_registry.go +++ b/core/security/service_registry.go @@ -21,6 +21,12 @@ type AuthorizationBackend interface { GetPermissionKeysByRoles(ctx context.Context, roles []string) []PermissionKey } +// AccountPasswordLoginProvider lets applications keep their own password-auth realms +// while reusing the shared framework account login handler and session issuance. +type AccountPasswordLoginProvider interface { + AuthenticateByPassword(login, password string) (*UserSessionInfo, error) +} + var authorizationBackendProviders = sync.Map{} func RegisterAuthorizationProvider(name string, provider AuthorizationBackend) { @@ -33,6 +39,12 @@ func RegisterAuthenticationProvider(name string, provider AuthenticationBackend) authenticationBackendBackendProviders.Store(name, provider) } +var accountPasswordLoginProviders = sync.Map{} + +func RegisterAccountPasswordLoginProvider(name string, provider AccountPasswordLoginProvider) { + accountPasswordLoginProviders.Store(name, provider) +} + func MustGetAuthenticationProvider(provider string) AuthenticationBackend { value, ok := authenticationBackendBackendProviders.Load(provider) if ok { @@ -99,3 +111,33 @@ func GetUserByLogin(login string) (bool, *UserAccount, error) { return false, nil, errors.New("not found") } + +// AuthenticateAccountPasswordLogin tries application-provided password login providers +// after the native framework account path has either not matched or not succeeded. +func AuthenticateAccountPasswordLogin(login, password string) (*UserSessionInfo, error) { + var out *UserSessionInfo + var lastErr error + + accountPasswordLoginProviders.Range(func(key, value any) bool { + provider, ok := value.(AccountPasswordLoginProvider) + if !ok { + return true + } + + sessionUser, err := provider.AuthenticateByPassword(login, password) + if err != nil { + lastErr = err + return true + } + if sessionUser != nil { + out = sessionUser + return false + } + return true + }) + + if out != nil { + return out, nil + } + return nil, lastErr +} diff --git a/core/security/session.go b/core/security/session.go index 97270100f..50869a2e0 100644 --- a/core/security/session.go +++ b/core/security/session.go @@ -7,6 +7,7 @@ package security import ( "fmt" "net/http" + "sync" "time" "github.com/golang-jwt/jwt/v4" @@ -18,10 +19,20 @@ import ( const UserAccessTokenSessionName = "user_session_access_token" const UserAccessTokenTTL = 24 * time.Hour +// SessionTokenResponseDecorator lets applications enrich the shared login/refresh +// response with app-specific fields while reusing the framework session pipeline. +type SessionTokenResponseDecorator func(token map[string]interface{}, user *UserSessionInfo) + +var sessionTokenResponseDecorators = sync.Map{} + func init() { RegisterHTTPAuthFilterProvider("session_token", byAccessTokenSession) } +func RegisterSessionTokenResponseDecorator(name string, decorator SessionTokenResponseDecorator) { + sessionTokenResponseDecorators.Store(name, decorator) +} + func byAccessTokenSession(w http.ResponseWriter, r *http.Request) (claims *UserClaims, err error) { exists, sessToken := api.GetSession(w, r, UserAccessTokenSessionName) if !exists || sessToken == nil { @@ -154,3 +165,13 @@ func tokenExpiresAtUnix(value interface{}) int64 { return 0 } } + +func applySessionTokenResponseDecorators(token map[string]interface{}, user *UserSessionInfo) { + sessionTokenResponseDecorators.Range(func(key, value any) bool { + decorator, ok := value.(SessionTokenResponseDecorator) + if ok { + decorator(token, user) + } + return true + }) +} From 016ca119cf86884cbc280d871664957a7dbb6e4d Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 17:59:02 +0800 Subject: [PATCH 22/24] Tighten account login semantics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- modules/security/rbac/account_login.go | 16 ++++------------ modules/security/rbac/user.go | 23 +++++++++++++++-------- modules/security/rbac/user_test.go | 26 +++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/modules/security/rbac/account_login.go b/modules/security/rbac/account_login.go index e3e813f86..2e630fb97 100644 --- a/modules/security/rbac/account_login.go +++ b/modules/security/rbac/account_login.go @@ -99,7 +99,7 @@ func LoginChallenge(w http.ResponseWriter, r *http.Request, ps httprouter.Params return } - exists, user, err := lookupAccountByLogin(login) + exists, user, err := GetUserByLogin(login) if err != nil { api.WriteError(w, err.Error(), http.StatusInternalServerError) return @@ -124,7 +124,7 @@ func Login(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { } usedChallenge := req.ChallengeID != "" || req.Proof != "" - exists, user, err := lookupAccountByLogin(login) + exists, user, err := GetUserByLogin(login) if err != nil { api.WriteError(w, err.Error(), http.StatusInternalServerError) return @@ -236,15 +236,6 @@ func authenticateLogin(user *security.UserAccount, login, password, challengeID, return false, nil, nil, errInvalidLoginCredentials } -// lookupAccountByLogin normalizes the service-registry "not found" result into a regular miss. -func lookupAccountByLogin(login string) (bool, *security.UserAccount, error) { - exists, user, err := GetUserByLogin(login) - if err != nil && err.Error() == "not found" { - return false, nil, nil - } - return exists, user, err -} - // validateReplayNonce keeps challenge login replay-safe while leaving older password-only // clients working until they adopt the explicit nonce negotiation endpoint. func validateReplayNonce(r *http.Request, required bool) error { @@ -271,9 +262,10 @@ func upgradePasswordChallenge(user *security.UserAccount, password string) { // Persist the verifier after a successful legacy password login so subsequent // logins can move onto the challenge flow without an explicit migration step. + // This upgrade is best-effort; the current login already succeeded, so it should + // not wait for an index refresh before returning to the caller. ctx := orm.NewContext() ctx.DirectAccess() - ctx.Refresh = orm.WaitForRefresh if err := orm.Update(ctx, user); err != nil { log.Warnf("failed to persist password challenge for user [%s]: %v", user.Email, err) } diff --git a/modules/security/rbac/user.go b/modules/security/rbac/user.go index f8ea5c6a2..018f58f3c 100644 --- a/modules/security/rbac/user.go +++ b/modules/security/rbac/user.go @@ -5,6 +5,7 @@ package rbac import ( + "fmt" "net/http" log "github.com/cihub/seelog" @@ -172,14 +173,8 @@ func GetUserByLogin(email string) (bool, *security.UserAccount, error) { if err != nil { return false, nil, err } - if len(items) > 0 { - if len(items) == 1 { - return true, &items[0], nil - } else { - log.Warnf("invalid users, more than one account was associated with the same email: %v", email) - } - } - return false, nil, nil + + return resolveUserByLogin(email, items) } func (provider *SecurityBackendProvider) GetUserByLogin(email string) (bool, *security.UserAccount, error) { @@ -299,3 +294,15 @@ func validateSecurePassword(password string) error { } return cerr.NewWithHTTPCode(http.StatusBadRequest, errInsecurePassword) } + +func resolveUserByLogin(login string, items []security.UserAccount) (bool, *security.UserAccount, error) { + switch len(items) { + case 0: + return false, nil, nil + case 1: + return true, &items[0], nil + default: + log.Warnf("invalid users, more than one account was associated with the same email: %v", login) + return false, nil, fmt.Errorf("multiple accounts found for login %q", login) + } +} diff --git a/modules/security/rbac/user_test.go b/modules/security/rbac/user_test.go index be4a9333f..163f138eb 100644 --- a/modules/security/rbac/user_test.go +++ b/modules/security/rbac/user_test.go @@ -23,7 +23,12 @@ package rbac -import "testing" +import ( + "strings" + "testing" + + "infini.sh/framework/core/security" +) // Weak passwords should now fail as normal validation errors instead of aborting // the request flow via panic. @@ -36,3 +41,22 @@ func TestValidateSecurePassword(t *testing.T) { t.Fatalf("expected strong password to pass validation, got %v", err) } } + +func TestResolveUserByLogin(t *testing.T) { + found, user, err := resolveUserByLogin("missing@example.org", nil) + if err != nil || found || user != nil { + t.Fatalf("expected empty result for missing user, got found=%v user=%#v err=%v", found, user, err) + } + + items := []security.UserAccount{{}} + items[0].Email = "admin@example.org" + found, user, err = resolveUserByLogin("admin@example.org", items) + if err != nil || !found || user == nil || user.Email != "admin@example.org" { + t.Fatalf("expected single user match, got found=%v user=%#v err=%v", found, user, err) + } + + _, _, err = resolveUserByLogin("dup@example.org", []security.UserAccount{{}, {}}) + if err == nil || !strings.Contains(err.Error(), "multiple accounts found") { + t.Fatalf("expected duplicate login error, got %v", err) + } +} From 80f5daed2ffcf55a4f7c3ea9f3a530f9cce3e07e Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 19:10:00 +0800 Subject: [PATCH 23/24] Drop legacy claim aliases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/security/user_session.go | 63 ------------------------------ core/security/user_session_test.go | 46 ++++++---------------- 2 files changed, 12 insertions(+), 97 deletions(-) diff --git a/core/security/user_session.go b/core/security/user_session.go index 5451435f1..b640921d1 100644 --- a/core/security/user_session.go +++ b/core/security/user_session.go @@ -24,9 +24,7 @@ package security import ( - "encoding/json" "fmt" - "strings" "time" log "github.com/cihub/seelog" @@ -49,67 +47,6 @@ func NewUserClaims() *UserClaims { } } -type userSessionInfoAlias UserSessionInfo - -// MarshalJSON keeps the framework claims readable by older console clients while the -// token/session stack is converging onto the shared framework implementation. -func (c UserClaims) MarshalJSON() ([]byte, error) { - sessionUser := c.UserSessionInfo - if sessionUser == nil { - sessionUser = &UserSessionInfo{} - } - - claims := c.RegisteredClaims - if claims == nil { - claims = &jwt.RegisteredClaims{} - } - - return json.Marshal(struct { - *jwt.RegisteredClaims - *userSessionInfoAlias - Username string `json:"username,omitempty"` - UserID string `json:"user_id,omitempty"` - }{ - RegisteredClaims: claims, - userSessionInfoAlias: (*userSessionInfoAlias)(sessionUser), - Username: sessionUser.Login, - UserID: sessionUser.UserID, - }) -} - -// UnmarshalJSON accepts both the framework-native field names and the older console -// aliases so apps can switch validators without forcing a token-format fork first. -func (c *UserClaims) UnmarshalJSON(data []byte) error { - aux := struct { - *jwt.RegisteredClaims - *userSessionInfoAlias - Username string `json:"username,omitempty"` - UserID string `json:"user_id,omitempty"` - }{ - RegisteredClaims: &jwt.RegisteredClaims{}, - userSessionInfoAlias: &userSessionInfoAlias{}, - } - - if err := json.Unmarshal(data, &aux); err != nil { - return err - } - - sessionUser := (*UserSessionInfo)(aux.userSessionInfoAlias) - if sessionUser == nil { - sessionUser = &UserSessionInfo{} - } - if strings.TrimSpace(sessionUser.Login) == "" { - sessionUser.Login = strings.TrimSpace(aux.Username) - } - if strings.TrimSpace(sessionUser.UserID) == "" { - sessionUser.UserID = strings.TrimSpace(aux.UserID) - } - - c.RegisteredClaims = aux.RegisteredClaims - c.UserSessionInfo = sessionUser - return nil -} - // auth user info type UserSessionInfo struct { param.Parameters diff --git a/core/security/user_session_test.go b/core/security/user_session_test.go index d383b829a..e857865bb 100644 --- a/core/security/user_session_test.go +++ b/core/security/user_session_test.go @@ -25,15 +25,12 @@ package security import ( "encoding/json" - "strings" "testing" "github.com/golang-jwt/jwt/v4" ) -// Framework-issued tokens need to remain readable by console clients until the two -// stacks finish converging on a single session claim format. -func TestUserClaimsMarshalIncludesLegacyConsoleAliases(t *testing.T) { +func TestUserClaimsMarshalUsesFrameworkFields(t *testing.T) { claims := UserClaims{ RegisteredClaims: &jwt.RegisteredClaims{}, UserSessionInfo: &UserSessionInfo{ @@ -49,41 +46,22 @@ func TestUserClaimsMarshalIncludesLegacyConsoleAliases(t *testing.T) { t.Fatalf("marshal claims: %v", err) } - text := string(payload) - for _, expected := range []string{ - `"login":"admin@example.org"`, - `"username":"admin@example.org"`, - `"userid":"user-1"`, - `"user_id":"user-1"`, - } { - if !strings.Contains(text, expected) { - t.Fatalf("expected %s in %s", expected, text) - } + var data map[string]any + if err := json.Unmarshal(payload, &data); err != nil { + t.Fatalf("unmarshal claims json: %v", err) } -} -// Older console tokens only carried username/user_id, so the framework parser must -// backfill its native login/userid fields from those aliases during migration. -func TestUserClaimsUnmarshalAcceptsLegacyConsoleAliases(t *testing.T) { - var claims UserClaims - err := json.Unmarshal([]byte(`{ - "provider":"native", - "username":"admin@example.org", - "user_id":"user-1", - "roles":["admin"] - }`), &claims) - if err != nil { - t.Fatalf("unmarshal claims: %v", err) + if data["login"] != "admin@example.org" { + t.Fatalf("expected login field, got %#v", data["login"]) } - - if claims.Login != "admin@example.org" { - t.Fatalf("expected login to be backfilled from username, got %q", claims.Login) + if data["userid"] != "user-1" { + t.Fatalf("expected userid field, got %#v", data["userid"]) } - if claims.UserID != "user-1" { - t.Fatalf("expected user id to be backfilled from user_id, got %q", claims.UserID) + if _, exists := data["username"]; exists { + t.Fatalf("did not expect legacy username alias in claims: %s", payload) } - if claims.Provider != "native" { - t.Fatalf("expected provider to be preserved, got %q", claims.Provider) + if _, exists := data["user_id"]; exists { + t.Fatalf("did not expect legacy user_id alias in claims: %s", payload) } } From 69bfe44c680ec7e0d98602a4b7713e98fd63e359 Mon Sep 17 00:00:00 2001 From: hardy Date: Tue, 26 May 2026 21:12:01 +0800 Subject: [PATCH 24/24] Protect framework roles in use Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- modules/security/rbac/role.go | 42 ++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/modules/security/rbac/role.go b/modules/security/rbac/role.go index 404f627a0..d876b22a4 100644 --- a/modules/security/rbac/role.go +++ b/modules/security/rbac/role.go @@ -24,6 +24,7 @@ const ( errCannotUpdateOwnRole = "you can not update the roles for you" errReservedRoleName = "can not use the reserved role name" errRoleAlreadyExists = "same role name already exists" + errRoleAssignedToUsers = "role is still assigned to users" ) func GetRole(w http.ResponseWriter, req *http.Request, ps httprouter.Params) { @@ -109,8 +110,28 @@ func DeleteRole(w http.ResponseWriter, req *http.Request, ps httprouter.Params) obj.ID = id ctx := orm.NewContextWithParent(req.Context()) ctx.DirectAccess() + + exists, err := orm.GetV2(ctx, &obj) + if err != nil { + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + if !exists { + api.WriteJSON(w, api.NotFoundResponse(id), http.StatusNotFound) + return + } + inUse, err := roleHasAssignedUsers(req.Context(), obj.Name) + if err != nil { + api.WriteError(w, err.Error(), http.StatusInternalServerError) + return + } + if inUse { + api.WriteError(w, errRoleAssignedToUsers, http.StatusConflict) + return + } + ctx.Refresh = orm.WaitForRefresh - err := orm.Delete(ctx, &obj) + err = orm.Delete(ctx, &obj) if err != nil { api.WriteError(w, err.Error(), http.StatusInternalServerError) return @@ -292,3 +313,22 @@ func (provider *SecurityBackendProvider) GetPermissionKeysByRoles(ctx1 context.C } return keys } + +func roleHasAssignedUsers(ctx1 context.Context, roleName string) (bool, error) { + if roleName == "" { + return false, nil + } + + ctx := orm.NewContextWithParent(ctx1) + ctx.DirectReadAccess() + ctx.PermissionScope(security.PermissionScopePlatform) + orm.WithModel(ctx, &security.UserAccount{}) + + qb := orm.NewQuery() + qb.Must(orm.TermQuery("roles", roleName)) + err, result := elastic.SearchV2WithResultItemMapper(ctx, nil, qb, nil) + if err != nil { + return false, err + } + return result != nil && result.Total > 0, nil +}