diff --git a/core/api/security.go b/core/api/security.go
new file mode 100644
index 000000000..89a74cd48
--- /dev/null
+++ b/core/api/security.go
@@ -0,0 +1,155 @@
+// 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 (
+ "net/http"
+ "strings"
+
+ httprouter "infini.sh/framework/core/api/router"
+ replaysecurity "infini.sh/framework/core/security/replay"
+)
+
+type SecureTransportOptions struct {
+ // TrustForwardHeaders allows HTTPS detection to honor reverse-proxy forwarding headers.
+ TrustForwardHeaders bool
+}
+
+const (
+ // 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 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
+ }
+ 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"))
+}
+
+// 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) {
+ if !RequestUsesSecureTransport(r, resolved) {
+ 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)
+ }
+}
+
+// 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 {
+ handler.WriteError(w, err.Error(), http.StatusUnauthorized)
+ return
+ }
+ h(w, r, ps)
+ }
+}
+
+// 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) {
+ Feature(FeatureRequireSecureTransport)(o)
+ Label(LabelTrustForwardHeaders, resolved.TrustForwardHeaders)(o)
+ }
+}
+
+// ReplayProtectionOption annotates a UI route so SecurityFilter enforces replay-nonce validation.
+func ReplayProtectionOption() Option {
+ return Feature(FeatureRequireReplayProtection)
+}
+
+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..ce9002e09
--- /dev/null
+++ b/core/api/security_test.go
@@ -0,0 +1,160 @@
+// 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 (
+ "crypto/tls"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ httprouter "infini.sh/framework/core/api/router"
+ 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
+ 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)
+ }
+ })
+ }
+}
+
+// The wrapper should fail fast before running the protected handler on plain HTTP.
+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)
+ }
+}
+
+// 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)
+ 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)
+ }
+}
+
+// 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)
+
+ 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])
+ }
+}
+
+// Replay protection uses a single feature flag because the filter reads no extra labels.
+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/core/security/password_challenge.go b/core/security/password_challenge.go
new file mode 100644
index 000000000..c007031cd
--- /dev/null
+++ b/core/security/password_challenge.go
@@ -0,0 +1,153 @@
+// 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 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
+
+// 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 != ""
+}
+
+// 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 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 = material.Hash
+ user.PasswordSalt = material.Salt
+ user.PasswordVerifier = material.Verifier
+ 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")
+ }
+ if CanUsePasswordChallenge(user) {
+ 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 {
+ return err
+ }
+
+ user.PasswordSalt = salt
+ user.PasswordVerifier = verifier
+ 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")
+ }
+ if user.Password == "" {
+ return errors.New("password is not set")
+ }
+ 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/password_challenge_test.go b/core/security/password_challenge_test.go
new file mode 100644
index 000000000..c2f68a145
--- /dev/null
+++ b/core/security/password_challenge_test.go
@@ -0,0 +1,108 @@
+// 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"
+
+// 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 {
+ 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)
+ }
+}
+
+// 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 {
+ 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")
+ }
+}
+
+// The framework wrapper should produce proofs compatible with the lower-level package.
+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")
+ }
+}
+
+// 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 {
+ 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/core/security/passwordchallenge/password_challenge.go b/core/security/passwordchallenge/password_challenge.go
new file mode 100644
index 000000000..f16190bdb
--- /dev/null
+++ b/core/security/passwordchallenge/password_challenge.go
@@ -0,0 +1,184 @@
+// 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 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 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 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
+}
+
+// Store tracks outstanding login challenges until they are consumed or expire.
+type Store struct {
+ mu sync.Mutex
+ ttl time.Duration
+ challenges map[string]Challenge
+}
+
+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 {
+ ttl = DefaultTTL
+ }
+ return &Store{
+ ttl: ttl,
+ challenges: map[string]Challenge{},
+ }
+}
+
+// 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")
+ }
+ 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
+}
+
+// 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 {
+ 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
+}
+
+// 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 {
+ 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)
+}
+
+// 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()
+ 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
+}
+
+// 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()
+
+ 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..8b0ce6aac
--- /dev/null
+++ b/core/security/passwordchallenge/password_challenge_test.go
@@ -0,0 +1,79 @@
+// 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"
+ "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 {
+ 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")
+ }
+}
+
+// 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")
+
+ if _, err := store.Consume(challenge.ID, "guest"); err == nil {
+ t.Fatal("expected challenge subject mismatch to fail")
+ }
+}
+
+// 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")
+ }
+ if _, err := DeriveVerifier("admin", ""); err == nil {
+ t.Fatal("expected empty salt to fail")
+ }
+}
+
+// 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")
+
+ 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
new file mode 100644
index 000000000..b5a630eaf
--- /dev/null
+++ b/core/security/replay/replay.go
@@ -0,0 +1,213 @@
+// 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 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
+}
+
+type nonceRecord struct {
+ Subject string
+ Method string
+ Path string
+ ExpiresAt time.Time
+}
+
+// Store tracks issued replay nonces until they are consumed or expire.
+type Store struct {
+ mu sync.Mutex
+ ttl time.Duration
+ subjectExtractor SubjectExtractor
+ records map[string]nonceRecord
+}
+
+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 {
+ ttl = DefaultTTL
+ }
+ extractor := options.SubjectExtractor
+ if extractor == nil {
+ extractor = DefaultSubjectExtractor
+ }
+ return &Store{
+ ttl: ttl,
+ subjectExtractor: extractor,
+ records: map[string]nonceRecord{},
+ }
+}
+
+// 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"
+ }
+ authorizationHeader := strings.TrimSpace(r.Header.Get("Authorization"))
+ 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[:])
+}
+
+// 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 {
+ 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
+}
+
+// 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")
+ }
+
+ 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..414cd79b5
--- /dev/null
+++ b/core/security/replay/replay_test.go
@@ -0,0 +1,128 @@
+// 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 (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "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)
+
+ 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")
+ }
+}
+
+// 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)
+ 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")
+ }
+}
+
+// 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)
+
+ 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")
+ }
+}
+
+// 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" {
+ t.Fatalf("expected anonymous subject, got %q", got)
+ }
+}
+
+// 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)
+
+ 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)
+ }
+}
+
+// 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)
+
+ 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/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 cf455adf2..74678cd4d 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"
@@ -16,11 +17,22 @@ 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() {
RegisterHTTPAuthFilterProviderWithPriority("session_token", byAccessTokenSession, 10)
}
+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 {
@@ -65,7 +77,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
@@ -89,7 +101,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 +117,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 +125,53 @@ 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)
+ applySessionTokenResponseDecorators(token, 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
+ }
+}
+
+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/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_profile.go b/core/security/user_profile.go
index cd6936fe5..35155b0e2 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 }"` // 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/core/security/user_session.go b/core/security/user_session.go
index 95e514745..b640921d1 100644
--- a/core/security/user_session.go
+++ b/core/security/user_session.go
@@ -84,7 +84,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 {
@@ -93,7 +93,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
new file mode 100644
index 000000000..e857865bb
--- /dev/null
+++ b/core/security/user_session_test.go
@@ -0,0 +1,77 @@
+// 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"
+ "testing"
+
+ "github.com/golang-jwt/jwt/v4"
+)
+
+func TestUserClaimsMarshalUsesFrameworkFields(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)
+ }
+
+ var data map[string]any
+ if err := json.Unmarshal(payload, &data); err != nil {
+ t.Fatalf("unmarshal claims json: %v", err)
+ }
+
+ if data["login"] != "admin@example.org" {
+ t.Fatalf("expected login field, got %#v", data["login"])
+ }
+ if data["userid"] != "user-1" {
+ t.Fatalf("expected userid field, got %#v", data["userid"])
+ }
+ if _, exists := data["username"]; exists {
+ t.Fatalf("did not expect legacy username alias in claims: %s", payload)
+ }
+ if _, exists := data["user_id"]; exists {
+ t.Fatalf("did not expect legacy user_id alias in claims: %s", payload)
+ }
+}
+
+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.go b/core/security/validate.go
index d3574f427..d6f68a3de 100644
--- a/core/security/validate.go
+++ b/core/security/validate.go
@@ -16,11 +16,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")
@@ -64,6 +61,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..6ad896983
--- /dev/null
+++ b/core/security/validate_test.go
@@ -0,0 +1,95 @@
+// 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)
+ }
+}
+
+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/docs/content.en/docs/release-notes/_index.md b/docs/content.en/docs/release-notes/_index.md
index 0ac7ebe3a..d2c4d72fb 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 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/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/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/http_filters/security.go b/modules/security/http_filters/security.go
new file mode 100644
index 000000000..6e372a000
--- /dev/null
+++ b/modules/security/http_filters/security.go
@@ -0,0 +1,75 @@
+/* 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{})
+}
+
+// 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,
+ 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, "this endpoint requires HTTPS. use https:// directly or route through 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)
+ }
+}
+
+// trustForwardHeadersFromOptions extracts whether SecureTransportOption opted into proxy headers.
+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..775463a43
--- /dev/null
+++ b/modules/security/http_filters/security_test.go
@@ -0,0 +1,128 @@
+/* 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"
+)
+
+// Secure-transport enforcement should stop the request before the wrapped UI handler runs.
+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)
+ }
+}
+
+// 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{}
+ 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)
+ }
+}
+
+// Missing nonce headers must block replay-protected routes before business logic executes.
+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)
+ }
+}
+
+// Trusted forward headers let deployments behind HTTPS reverse proxies pass transport checks.
+func TestSecurityFilterWithTrustedForwardHeaders(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)
+ }
+}
+
+// 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")
+ }
+ if trustForwardHeadersFromOptions(&api.HandlerOptions{}) {
+ t.Fatal("expected missing label to disable trusted forward headers")
+ }
+}
diff --git a/modules/security/rbac/account_login.go b/modules/security/rbac/account_login.go
new file mode 100644
index 000000000..2e630fb97
--- /dev/null
+++ b/modules/security/rbac/account_login.go
@@ -0,0 +1,288 @@
+// 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 (
+ // Keep the password and challenge paths aligned on one user-facing failure message.
+ errInvalidLoginCredentials = errors.New("invalid login or password")
+ // 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"`
+ Username string `json:"username"`
+ UserName string `json:"userName"`
+ Password string `json:"password"`
+ ChallengeID string `json:"challenge_id"`
+ Proof string `json:"proof"`
+}
+
+// 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"`
+ 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),
+ })
+}
+
+// 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 {
+ 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 := GetUserByLogin(login)
+ if err != nil {
+ api.WriteError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ 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 {
+ api.WriteError(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ login := req.NormalizedLogin()
+ if login == "" {
+ api.WriteError(w, "login is required", http.StatusBadRequest)
+ return
+ }
+
+ usedChallenge := req.ChallengeID != "" || req.Proof != ""
+ exists, user, err := GetUserByLogin(login)
+ if err != nil {
+ api.WriteError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if usedChallenge && (!exists || user == nil) {
+ api.WriteError(w, errInvalidLoginCredentials.Error(), http.StatusForbidden)
+ return
+ }
+
+ if err := validateReplayNonce(r, usedChallenge); err != nil {
+ api.WriteError(w, err.Error(), http.StatusUnauthorized)
+ return
+ }
+
+ 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) {
+ statusCode = http.StatusBadRequest
+ }
+ api.WriteError(w, err.Error(), statusCode)
+ return
+ }
+
+ if !usedChallenge && nativeUser != nil {
+ upgradePasswordChallenge(nativeUser, req.Password)
+ }
+
+ 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)
+ }
+}
+
+// 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 != "" {
+ return value
+ }
+ }
+ 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
+ // locally without sending the raw password back to the server.
+ 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",
+ }
+}
+
+// authenticateLogin selects the correct credential validation path based on the request body.
+func authenticateLogin(user *security.UserAccount, login, password, challengeID, proof string) (bool, *security.UserSessionInfo, *security.UserAccount, error) {
+ if challengeID != "" || proof != "" {
+ if challengeID == "" || proof == "" {
+ 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, nil, nil, errInvalidLoginCredentials
+ }
+ if !security.VerifyPasswordProof(user.PasswordVerifier, login, challenge.ID, challenge.Nonce, proof) {
+ return true, nil, nil, errInvalidLoginCredentials
+ }
+ return true, newNativeSession(user, login), user, nil
+ }
+
+ if password == "" {
+ 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 sessionUser != nil {
+ return false, sessionUser, nil, nil
+ }
+
+ return false, nil, nil, errInvalidLoginCredentials
+}
+
+// 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 {
+ // 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)
+}
+
+// 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
+ }
+
+ if err := security.EnsurePasswordChallenge(user, password); err != nil {
+ log.Warnf("failed to derive password challenge for user [%s]: %v", user.Email, err)
+ return
+ }
+
+ // 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()
+ if err := orm.Update(ctx, user); err != nil {
+ log.Warnf("failed to persist password challenge for user [%s]: %v", user.Email, err)
+ }
+}
+
+// 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 == "" {
+ 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..6b4b60a75
--- /dev/null
+++ b/modules/security/rbac/account_login_test.go
@@ -0,0 +1,267 @@
+// 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"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "infini.sh/framework/core/security"
+ 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{
+ 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)
+ }
+}
+
+// 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 {
+ t.Fatalf("set password: %v", err)
+ }
+
+ 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.
+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, 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.
+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)
+ }
+}
+
+// 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 {
+ 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)
+ }
+}
+
+// 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"}
+ 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")
+ }
+}
+
+// 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 {
+ 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")
+ }
+}
+
+// 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 {
+ t.Fatalf("expected missing nonce to be allowed for legacy password login, got %v", err)
+ }
+}
+
+// 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 {
+ t.Fatal("expected missing nonce to be rejected for challenge login")
+ }
+}
+
+// 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")
+ 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)
+ }
+}
+
+// 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"
+
+ 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)
+ }
+}
+
+// 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"])
+ }
+}
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/init.go b/modules/security/rbac/init.go
index 4caa07f65..3b6290421 100644
--- a/modules/security/rbac/init.go
+++ b/modules/security/rbac/init.go
@@ -19,6 +19,24 @@ func Init() {
security.RegisterAuthenticationProvider(security.DefaultNativeAuthBackend, &provider)
security.RegisterAuthorizationProvider(security.DefaultNativeAuthBackend, &provider)
+ 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")
ReadPermissionLists := security.GetSimplePermission("generic", "security:permission", security.Read)
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..d876b22a4 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,15 @@ 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"
+ errRoleAssignedToUsers = "role is still assigned to users"
+)
+
func GetRole(w http.ResponseWriter, req *http.Request, ps httprouter.Params) {
id := ps.MustGetParameter("id")
@@ -28,8 +38,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 +58,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 +70,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
}
}
@@ -89,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
@@ -164,19 +205,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 +285,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)
@@ -269,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
+}
diff --git a/modules/security/rbac/user.go b/modules/security/rbac/user.go
index 569bc1917..018f58f3c 100644
--- a/modules/security/rbac/user.go
+++ b/modules/security/rbac/user.go
@@ -5,19 +5,27 @@
package rbac
import (
+ "fmt"
"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"
+ 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")
@@ -26,10 +34,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
}
@@ -43,7 +52,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)
@@ -52,10 +62,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
}
@@ -68,26 +79,32 @@ 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
}
}
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
} 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
}
- hash, err := bcrypt.GenerateFromPassword([]byte(obj.Password), bcrypt.DefaultCost)
- if err != nil {
- panic(err)
+ if err := security.SetPassword(&obj, obj.Password); err != nil {
+ api.WriteError(w, err.Error(), http.StatusInternalServerError)
+ return
}
- obj.Password = string(hash)
}
ctx.Refresh = orm.WaitForRefresh
err = orm.Update(ctx, &obj)
if err != nil {
- panic(err)
+ api.WriteError(w, err.Error(), http.StatusInternalServerError)
+ return
}
security.IncreasePermissionVersion()
@@ -103,13 +120,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)
@@ -118,7 +137,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()
@@ -128,12 +148,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)
}
}
@@ -152,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) {
@@ -186,17 +201,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{}
@@ -204,24 +219,22 @@ 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 {
+ return nil, err
+ }
ctx := orm.NewContext()
ctx.DirectAccess()
ctx.Refresh = orm.WaitForRefresh
err = orm.Save(ctx, obj)
if err != nil {
- panic(err)
+ return nil, err
}
return obj, nil
}
@@ -234,38 +247,62 @@ 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)
- hash, err := bcrypt.GenerateFromPassword([]byte(randStr), bcrypt.DefaultCost)
- if err != nil {
- panic(err)
+ if err := security.SetPassword(obj, randStr); err != nil {
+ api.WriteError(w, err.Error(), http.StatusInternalServerError)
+ return
}
- obj.Password = string(hash)
-
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
+ // 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)
}
+
+func validateSecurePassword(password string) error {
+ if util.ValidateSecure(password) {
+ return nil
+ }
+ 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
new file mode 100644
index 000000000..163f138eb
--- /dev/null
+++ b/modules/security/rbac/user_test.go
@@ -0,0 +1,62 @@
+// 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 (
+ "strings"
+ "testing"
+
+ "infini.sh/framework/core/security"
+)
+
+// 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)
+ }
+}
+
+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)
+ }
+}