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) + } +}