package auth import ( "context" "errors" "fmt" "log/slog" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Sentinel errors. Invalid credential and invalid session errors are deliberately generic so neither // the response nor its timing reveals whether an account exists. var ( ErrNoDatabase = errors.New("auth: no database configured") ErrInvalidCredentials = errors.New("auth: invalid credentials") ErrAccountLocked = errors.New("auth: account temporarily locked") ErrInvalidSession = errors.New("auth: invalid or expired session") ErrInvalidState = errors.New("auth: session is not in the required state") ErrNotFound = errors.New("auth: not found") ErrForbidden = errors.New("auth: not permitted") ErrBadRequest = errors.New("auth: bad request") ) const ( maxFailedLogins = 10 // failed password attempts per email, any source address, before a soft lock maxMFAFailures = 5 // failed MFA attempts before the pending session is revoked failWindowSeconds = 900 // 15 minute brute force window recoveryCodeCount = 10 ) // reason categories for login_attempts and audit, never the raw email or code. const ( reasonNoUser = "no_user" reasonBadPassword = "[credential removed]" reasonLocked = "locked" reasonMFAFailed = "mfa_failed" ) // Service implements the authentication and accounts flows. It calls the SECURITY DEFINER functions // in migration 0009 over the app pool; those functions are the only path to the secret bearing // tables, which the app role cannot read directly. type Service struct { deps Deps dummyHash string dummyPepper int } // NewService builds the service and precomputes a dummy password hash so the unknown user login path // burns the same PBKDF2 cost as a real verify (no timing oracle). func NewService(d Deps) (*Service, error) { if d.Hasher == nil || d.Tagger == nil { return nil, errors.New("auth: hasher and tagger are required") } dummy, version, err := d.Hasher.Hash("missionmatch equal cost dummy secret") if err != nil { return nil, fmt.Errorf("auth: precompute dummy hash: %w", err) } return &Service{deps: d, dummyHash: dummy, dummyPepper: version}, nil } func (s *Service) db() (dbgen.DBTX, error) { if s.deps.DB == nil || s.deps.DB.Pool == nil { return nil, ErrNoDatabase } return s.deps.DB.Pool, nil } // --------------------------------------------------------------------------- // Login: password verify then an auth_pending session. // --------------------------------------------------------------------------- type loginRow struct { userID uuid.UUID orgID uuid.UUID role string userStatus string passwordHash string pepperVer int mfaEnrolled bool mfaStatus *string secretCipher []byte secretNonce []byte keyVersion *int lastUsedStep *int64 } // Login verifies a password and issues an auth_pending session. The handler sets the returned token // as the session cookie and routes the SPA to enroll or verify based on MFAEnrolled. func (s *Service) Login(ctx context.Context, in LoginInput, ip, ua string) (LoginResult, error) { db, err := s.db() if err != nil { return LoginResult{}, err } locked, err := s.tooManyFailures(ctx, db, in.Email) if err != nil { return LoginResult{}, err } if locked { s.recordAttempt(ctx, db, in.Email, nil, ip, false, reasonLocked) // A failed spread read must not block the lock, so the count degrades to zero (unknown) here. spread, _ := s.failureSourceSpread(ctx, db, in.Email, reasonBadPassword) if aerr := s.deps.Audit.System(ctx, audit.Event{Action: "auth.account.locked", IPAddress: ip, UserAgent: ua, EntityType: "email", NewValues: lockedJSON(reasonLocked, spread)}); aerr != nil { return LoginResult{}, aerr } return LoginResult{}, ErrAccountLocked } row, found, err := s.lookupLogin(ctx, db, in.Email) if err != nil { return LoginResult{}, err } if !found { // Equal cost dummy verify and identical attempt accounting so existence is not revealed. _, _, _ = s.deps.Hasher.Verify(in.Password, s.dummyHash, s.dummyPepper) s.recordAttempt(ctx, db, in.Email, nil, ip, false, reasonNoUser) if aerr := s.failureAudit(ctx, nil, ip, ua, reasonNoUser); aerr != nil { return LoginResult{}, aerr } return LoginResult{}, ErrInvalidCredentials } ok, needsRehash, err := s.deps.Hasher.Verify(in.Password, row.passwordHash, row.pepperVer) if err != nil { return LoginResult{}, err } if !ok { s.recordAttempt(ctx, db, in.Email, &row.orgID, ip, false, reasonBadPassword) actor := row.userID if aerr := s.failureAudit(ctx, &actor, ip, ua, reasonBadPassword); aerr != nil { return LoginResult{}, aerr } return LoginResult{}, ErrInvalidCredentials } if needsRehash { if rehash, ver, herr := s.deps.Hasher.Hash(in.Password); herr == nil { _, _ = db.Exec(ctx, `SELECT auth_set_credential($1,$2,$3,$4,$5)`, row.userID, row.orgID, in.Email, rehash, ver) } } token, tokenHash, err := security.NewSessionToken() if err != nil { return LoginResult{}, err } // OPERATOR MFA IS MANDATORY, amendment 6 decision 1 and Track U item 4, and it does not wait for // Phase 6. mfaMandatory is true for the operator role whatever MM_MFA_REQUIRED says, so the platform // plane can never be reached with a password alone even in a pilot deployment that has MFA off. mfaMandatory := s.mfaMandatoryFor(row.role) if err := s.createInitialSession(ctx, db, tokenHash, row.userID, row.orgID, ip, ua, mfaMandatory); err != nil { return LoginResult{}, err } s.recordAttempt(ctx, db, in.Email, &row.orgID, ip, true, "") actor := row.userID if aerr := s.deps.Audit.System(ctx, audit.Event{Action: "auth.login.success", ActorUserID: &actor, IPAddress: ip, UserAgent: ua}); aerr != nil { return LoginResult{}, aerr } return LoginResult{Token: token, MFAEnrolled: row.mfaEnrolled, MFARequired: mfaMandatory}, nil } // mfaMandatoryFor reports whether this login must satisfy a second factor before its session becomes // active. It is the deployment setting for every tenant role, and unconditionally true for operator. // // The operator exception is amendment 6 decision 1 verbatim: MFA by TOTP is mandatory for operator // accounts from the day the role exists, not waiting for Phase 6. An operator account that has not // enrolled therefore cannot log in, in the only sense that matters: it never obtains an ACTIVE session, so // every authenticated route refuses it, and the only thing the pre MFA session it holds can do is enroll // the authenticator and prove one code. // // NOTED DEVIATION, directive Section 0 item 6. The amendment's words are that the login is refused. Taken // literally, with MM_MFA_REQUIRED false as this deployment runs, that would be a lockout with no way out: // EnrollMFA accepts only an auth_pending session, and the MFA disabled login path never creates one, so a // flat refusal at the password step would leave a promoted operator with no enrollment path at all and no // in application recovery. The smallest change satisfying both the rule and a working system is to enforce // the REQUIREMENT rather than the refusal: the account cannot get in, and the one step it can take is the // step that makes it compliant. This is recorded in BUILD_STATE rather than carried silently. // The operator exception is stood down only by an explicit deployment setting, which config refuses in a // production MM_ENV. Joseph directed on 28 July 2026 that his production operator account not be prompted, // and this is where that decision lives, recorded in BUILD_STATE, reversible with one environment variable // and no release. The default keeps the amendment's rule. func (s *Service) mfaMandatoryFor(role string) bool { if security.Role(role) == security.RoleOperator && !s.deps.OperatorMFADisabled { return true } return !s.deps.MFADisabled } // createInitialSession issues the first session after a password proof. With MFA required (the default, // MFADisabled false) it is a short lived auth_pending session that the MFA routes upgrade to active. // With MFA disabled for a pilot (MM_MFA_REQUIRED=false, forbidden in production) it is minted active and // MFA satisfied directly, so login is password only and no code path is left half authenticated. // // mfaMandatory is passed in rather than read from deps here, because the answer is now per ACCOUNT and not // only per deployment: operator always requires a second factor. Taking it as a parameter keeps the one // decision in mfaMandatoryFor instead of splitting the rule across two functions that could disagree. func (s *Service) createInitialSession(ctx context.Context, db dbgen.DBTX, tokenHash []byte, userID, orgID uuid.UUID, ip, ua string, mfaMandatory bool) error { if !mfaMandatory { _, err := db.Exec(ctx, `SELECT auth_create_session($1,$2,$3,'active',true,$4,$5,$6,$7)`, tokenHash, userID, orgID, s.deps.IdleSeconds, s.deps.AbsoluteSeconds, ip, ua) if err != nil { return err } // This branch, not rotate, is where a session becomes active when MFA is off, which is the // configuration this deployment runs. Stamping only in rotate left last_login_at NULL for every // account here, and the People screen showed "never signed in" beside the owner reading it. touchLastLogin(ctx, db, userID, orgID) return nil } _, err := db.Exec(ctx, `SELECT auth_create_session($1,$2,$3,'auth_pending',false,$4,$4,$5,$6)`, tokenHash, userID, orgID, s.deps.PendingSeconds, ip, ua) return err } // --------------------------------------------------------------------------- // MFA: enroll, activate, verify, recover. All run on the pre MFA auth_pending session. // --------------------------------------------------------------------------- // EnrollMFA generates a TOTP secret for a pending session and returns the provisioning material. func (s *Service) EnrollMFA(ctx context.Context, token string) (EnrollResult, error) { db, err := s.db() if err != nil { return EnrollResult{}, err } v, err := s.validatePending(ctx, db, token) if err != nil { return EnrollResult{}, err } // Refuse to re enroll an already active authenticator from a pre MFA session. Without this an // attacker holding the password could plant their own secret over the victim's. A genuine reset // goes through the authenticated admin ResetMFA path. The database WHERE status = 'pending' guard // in auth_mfa_enroll is the authoritative defense; this is the early, friendly rejection. if active, err := s.mfaIsActive(ctx, db, v.userID); err != nil { return EnrollResult{}, err } else if active { return EnrollResult{}, ErrInvalidState } email, err := s.credentialEmail(ctx, db, v.userID) if err != nil { return EnrollResult{}, err } secret, err := security.GenerateSecret() if err != nil { return EnrollResult{}, err } kek, ok := s.deps.TOTPKEKs[s.deps.TOTPKEKActive] if !ok { return EnrollResult{}, errors.New("auth: active TOTP key not configured") } ct, nonce, err := security.SealSecret(kek, secret) if err != nil { return EnrollResult{}, err } var enrolled bool if err := db.QueryRow(ctx, `SELECT auth_mfa_enroll($1,$2,$3,$4,$5)`, v.userID, v.orgID, ct, nonce, s.deps.TOTPKEKActive).Scan(&enrolled); err != nil { return EnrollResult{}, err } if !enrolled { // The row was already active, so the pending overwrite wrote nothing. Fail closed. return EnrollResult{}, ErrInvalidState } uri := security.ProvisioningURI(secret, email, s.deps.TOTPIssuer) return EnrollResult{OTPAuthURI: uri, Secret: security.EncodeSecret(secret)}, nil } // mfaIsActive reports whether the user already has an active TOTP secret. func (s *Service) mfaIsActive(ctx context.Context, db dbgen.DBTX, userID string) (bool, error) { var ct, nonce []byte var keyVer int var status string var lastStep int64 err := db.QueryRow(ctx, `SELECT secret_ciphertext, secret_nonce, key_version, status, last_used_step FROM auth_mfa_get($1)`, mustUUID(userID)). Scan(&ct, &nonce, &keyVer, &status, &lastStep) if errors.Is(err, pgx.ErrNoRows) { return false, nil } if err != nil { return false, err } return status == "active", nil } // ActivateMFA verifies the first code, activates the secret, upgrades the session to active, and // returns the one time recovery codes. It is the ONLY transition that sets mfa_enrolled and yields an // active, MFA satisfied session. func (s *Service) ActivateMFA(ctx context.Context, token string, in CodeInput, ip, ua string) (ActivateResult, error) { db, err := s.db() if err != nil { return ActivateResult{}, err } v, err := s.validatePending(ctx, db, token) if err != nil { return ActivateResult{}, err } used, err := s.verifyTOTPCode(ctx, db, v.userID, in.Code, "pending") if err != nil { return ActivateResult{}, err } codes, hashes, versions, tags, err := s.buildRecoveryCodes() if err != nil { return ActivateResult{}, err } newToken, newHash, err := security.NewSessionToken() if err != nil { return ActivateResult{}, err } err = s.inTx(ctx, db, func(tx pgx.Tx) error { var advanced int if e := tx.QueryRow(ctx, `SELECT auth_mfa_advance_step($1,$2)`, v.userID, used).Scan(&advanced); e != nil { return e } if advanced == 0 { return ErrInvalidCredentials // the activation code was already consumed concurrently } var activated bool if e := tx.QueryRow(ctx, `SELECT auth_mfa_activate($1)`, v.userID).Scan(&activated); e != nil { return e } if !activated { return ErrInvalidState } if _, e := tx.Exec(ctx, `SELECT auth_recovery_store($1,$2,$3,$4,$5)`, v.userID, v.orgID, hashes, versions, tags); e != nil { return e } return rotate(ctx, tx, s.tokenHash(token), newHash, true, s.deps.IdleSeconds, s.deps.AbsoluteSeconds, ip, ua) }) if err != nil { return ActivateResult{}, err } actor := mustUUID(v.userID) touchLastLogin(ctx, db, actor, mustUUID(v.orgID)) _ = s.deps.Audit.System(ctx, audit.Event{Action: "auth.mfa.activated", ActorUserID: &actor, IPAddress: ip, UserAgent: ua}) return ActivateResult{Token: newToken, RecoveryCodes: codes}, nil } // VerifyTOTP verifies a code for an already enrolled user and upgrades the pending session to active. func (s *Service) VerifyTOTP(ctx context.Context, token string, in CodeInput, ip, ua string) (TokenResult, error) { db, err := s.db() if err != nil { return TokenResult{}, err } v, err := s.validatePending(ctx, db, token) if err != nil { return TokenResult{}, err } used, verr := s.verifyTOTPCode(ctx, db, v.userID, in.Code, "active") if verr != nil { if aerr := s.afterMFAFailure(ctx, db, v, token, ip, ua); aerr != nil { return TokenResult{}, aerr } return TokenResult{}, ErrInvalidCredentials } newToken, err := s.advanceAndRotate(ctx, db, v, token, used, ip, ua, "auth.mfa.verify.success") if err != nil { return TokenResult{}, err } return TokenResult{Token: newToken}, nil } // RecoverMFA consumes a single use recovery code to upgrade the pending session to active. func (s *Service) RecoverMFA(ctx context.Context, token string, in CodeInput, ip, ua string) (TokenResult, error) { db, err := s.db() if err != nil { return TokenResult{}, err } v, err := s.validatePending(ctx, db, token) if err != nil { return TokenResult{}, err } tag := s.deps.Tagger.Tag(in.Code) var id uuid.UUID var codeHash string var ver int err = db.QueryRow(ctx, `SELECT id, code_hash, pepper_version FROM auth_recovery_lookup($1,$2)`, v.userID, tag).Scan(&id, &codeHash, &ver) if errors.Is(err, pgx.ErrNoRows) { if aerr := s.afterMFAFailure(ctx, db, v, token, ip, ua); aerr != nil { return TokenResult{}, aerr } return TokenResult{}, ErrInvalidCredentials } if err != nil { return TokenResult{}, err } ok, _, err := s.deps.Hasher.Verify(in.Code, codeHash, ver) if err != nil { return TokenResult{}, err } if !ok { if aerr := s.afterMFAFailure(ctx, db, v, token, ip, ua); aerr != nil { return TokenResult{}, aerr } return TokenResult{}, ErrInvalidCredentials } newToken, newHash, err := security.NewSessionToken() if err != nil { return TokenResult{}, err } err = s.inTx(ctx, db, func(tx pgx.Tx) error { var claimed int if e := tx.QueryRow(ctx, `SELECT auth_recovery_claim($1)`, id).Scan(&claimed); e != nil { return e } if claimed == 0 { return ErrInvalidCredentials // already spent by a concurrent attempt } return rotate(ctx, tx, s.tokenHash(token), newHash, true, s.deps.IdleSeconds, s.deps.AbsoluteSeconds, ip, ua) }) if err != nil { return TokenResult{}, err } actor := mustUUID(v.userID) _ = s.deps.Audit.System(ctx, audit.Event{Action: "auth.mfa.recovery_code.used", ActorUserID: &actor, IPAddress: ip, UserAgent: ua}) return TokenResult{Token: newToken}, nil } // --------------------------------------------------------------------------- // Session lifecycle. // --------------------------------------------------------------------------- // Logout revokes the presented session. func (s *Service) Logout(ctx context.Context, token string, ip, ua string) error { db, err := s.db() if err != nil { return err } if _, err := db.Exec(ctx, `SELECT auth_revoke_session($1)`, s.tokenHash(token)); err != nil { return err } _ = s.deps.Audit.System(ctx, audit.Event{Action: "auth.logout", IPAddress: ip, UserAgent: ua}) return nil } // LogoutAll revokes every session of the authenticated user except the current one. func (s *Service) LogoutAll(ctx context.Context, token string) error { db, err := s.db() if err != nil { return err } p, ok := tenant.UserFromContext(ctx) if !ok { return ErrForbidden } if _, err := db.Exec(ctx, `SELECT auth_revoke_user_sessions($1,$2)`, mustUUID(p.UserID), s.tokenHash(token)); err != nil { return err } return nil } // ChangePassword verifies the old password, rehashes the new one, revokes all other sessions, and // rotates the current session to a fresh token. func (s *Service) ChangePassword(ctx context.Context, token string, in ChangePasswordInput, ip, ua string) (TokenResult, error) { db, err := s.db() if err != nil { return TokenResult{}, err } p, ok := tenant.UserFromContext(ctx) if !ok { return TokenResult{}, ErrForbidden } uid := mustUUID(p.UserID) var orgID uuid.UUID var email, hash string var ver int err = db.QueryRow(ctx, `SELECT organization_id, email_normalized, password_hash, pepper_version FROM auth_get_credential($1)`, uid). Scan(&orgID, &email, &hash, &ver) if errors.Is(err, pgx.ErrNoRows) { return TokenResult{}, ErrNotFound } if err != nil { return TokenResult{}, err } okOld, _, err := s.deps.Hasher.Verify(in.OldPassword, hash, ver) if err != nil { return TokenResult{}, err } if !okOld { return TokenResult{}, ErrInvalidCredentials } newHash, newVer, err := s.deps.Hasher.Hash(in.NewPassword) if err != nil { return TokenResult{}, err // includes ErrPasswordTooLong } newToken, newSessHash, err := security.NewSessionToken() if err != nil { return TokenResult{}, err } err = s.inTx(ctx, db, func(tx pgx.Tx) error { if _, e := tx.Exec(ctx, `SELECT auth_set_credential($1,$2,$3,$4,$5)`, uid, orgID, email, newHash, newVer); e != nil { return e } if _, e := tx.Exec(ctx, `SELECT auth_revoke_user_sessions($1,$2)`, uid, s.tokenHash(token)); e != nil { return e } return rotate(ctx, tx, s.tokenHash(token), newSessHash, true, s.deps.IdleSeconds, s.deps.AbsoluteSeconds, ip, ua) }) if err != nil { return TokenResult{}, err } _ = s.deps.Audit.System(ctx, audit.Event{Action: "auth.password.changed", ActorUserID: &uid, IPAddress: ip, UserAgent: ua}) return TokenResult{Token: newToken}, nil } // --------------------------------------------------------------------------- // Internal helpers. // --------------------------------------------------------------------------- type pendingSession struct { sessionID string userID string orgID string role string } // validatePending validates a session and requires it to be auth_pending (password verified, MFA not // yet satisfied). It is the gate for the MFA routes. func (s *Service) validatePending(ctx context.Context, db dbgen.DBTX, token string) (pendingSession, error) { v, err := s.validate(ctx, db, token) if err != nil { return pendingSession{}, err } if v.state != "auth_pending" { return pendingSession{}, ErrInvalidState } return pendingSession{sessionID: v.sessionID, userID: v.userID, orgID: v.orgID, role: v.role}, nil } type validatedSession struct { sessionID string userID string orgID string role string state string mfaSatisfied bool userStatus string } // validate runs the atomic validate, slide, and revocation check. No row means invalid. func (s *Service) validate(ctx context.Context, db dbgen.DBTX, token string) (validatedSession, error) { var sid, uid, oid uuid.UUID var v validatedSession err := db.QueryRow(ctx, `SELECT session_id, user_id, organization_id, role, state, mfa_satisfied, user_status FROM auth_validate_session($1,$2)`, s.tokenHash(token), s.deps.IdleSeconds).Scan(&sid, &uid, &oid, &v.role, &v.state, &v.mfaSatisfied, &v.userStatus) if errors.Is(err, pgx.ErrNoRows) { return validatedSession{}, ErrInvalidSession } if err != nil { return validatedSession{}, err } v.sessionID, v.userID, v.orgID = sid.String(), uid.String(), oid.String() return v, nil } // Validate is the resolver facing check: it returns the principal only for an active, MFA satisfied // session, so a pre MFA session can never reach a tenant route. func (s *Service) Validate(ctx context.Context, token string) (tenant.Principal, string, error) { db, err := s.db() if err != nil { return tenant.Principal{}, "", err } v, err := s.validate(ctx, db, token) if err != nil { return tenant.Principal{}, "", err } if v.state != "active" || !v.mfaSatisfied || v.orgID == "" { return tenant.Principal{}, "", ErrInvalidSession } // Tool entitlement is resolved WITH the session so the per tool API gate is a context read rather // than a query on every request. It is read on the same pool as the rest of identity, outside a // tenant transaction, because this runs before the org GUC is set for the request. // // A failure here fails the session CLOSED rather than degrading to an empty tool set. Both outcomes // deny access, but an empty set would present as "this user has no products", which is // indistinguishable from a deliberate entitlement and would send an operator to the access screen to // debug a database error. tools, err := s.toolsForUser(ctx, v.userID, v.orgID) if err != nil { return tenant.Principal{}, "", err } // THE BY RULE WIDENING, amendment 6 decision 12, applied exactly once and exactly here. The top seats // hold their tools by rule rather than by grant, so a tenant superuser provisioned without grants (which // is what the design partner hit) still holds its tenant's products. Doing it at the single point the // principal is built means the API gate, the session payload the browser receives, and the entitlement // grid all read one consistent answer instead of each re deriving the rule and drifting. tools = security.EffectiveTools(security.Role(v.role), tools) return tenant.Principal{UserID: v.userID, Role: v.role, Tools: tools}, v.orgID, nil } // toolsForUser reads one user's granted tools. // // It MUST run with app.current_org_id set. user_tool_access carries FORCED row level security like every // other tenant table, so a query on a bare pool with no org in the session matches zero rows and succeeds: // the caller gets an empty tool set rather than an error, every product disappears for everyone, and the // cause looks like an entitlement decision rather than a missing GUC. That is precisely the fail closed // behaviour RLS is supposed to have, and precisely why this read cannot skip it. // // The org comes from the validated session, never the request, so setting it here is not trusting the // caller. It is set transaction locally, so it cannot leak to the next request on this pooled connection. func (s *Service) toolsForUser(ctx context.Context, userID, orgID string) ([]string, error) { var out []string err := s.deps.DB.RunInTenantTx(tenant.WithOrg(ctx, orgID), func(ctx context.Context, tx pgx.Tx) error { rows, e := tx.Query(ctx, `SELECT tool FROM user_tool_access WHERE user_id = $1 ORDER BY tool`, mustUUID(userID)) if e != nil { return e } defer rows.Close() var raw []string for rows.Next() { var t string if e := rows.Scan(&t); e != nil { return e } raw = append(raw, t) } if e := rows.Err(); e != nil { return e } out = security.ToolsOf(raw) return nil }) return out, err } func (s *Service) verifyTOTPCode(ctx context.Context, db dbgen.DBTX, userID, code, wantStatus string) (int64, error) { var ct, nonce []byte var keyVer int var status string var lastStep int64 err := db.QueryRow(ctx, `SELECT secret_ciphertext, secret_nonce, key_version, status, last_used_step FROM auth_mfa_get($1)`, mustUUID(userID)). Scan(&ct, &nonce, &keyVer, &status, &lastStep) if errors.Is(err, pgx.ErrNoRows) { return 0, ErrInvalidState } if err != nil { return 0, err } if status != wantStatus { return 0, ErrInvalidState } kek, ok := s.deps.TOTPKEKs[keyVer] if !ok { return 0, fmt.Errorf("auth: TOTP key version %d not configured", keyVer) } secret, err := security.OpenSecret(kek, ct, nonce) if err != nil { return 0, err } ok, used := s.deps.TOTP.Validate(secret, code, nowFn(), lastStep) if !ok { return 0, ErrInvalidCredentials } return used, nil } func (s *Service) advanceAndRotate(ctx context.Context, db dbgen.DBTX, v pendingSession, oldToken string, used int64, ip, ua, action string) (string, error) { newToken, newHash, err := security.NewSessionToken() if err != nil { return "", err } err = s.inTx(ctx, db, func(tx pgx.Tx) error { // Advancing the monotonic step is the authoritative single use gate. If a concurrent verify // already consumed this step the update affects zero rows, so we abort and mint no session. var advanced int if e := tx.QueryRow(ctx, `SELECT auth_mfa_advance_step($1,$2)`, mustUUID(v.userID), used).Scan(&advanced); e != nil { return e } if advanced == 0 { return ErrInvalidCredentials } return rotate(ctx, tx, s.tokenHash(oldToken), newHash, true, s.deps.IdleSeconds, s.deps.AbsoluteSeconds, ip, ua) }) if err != nil { return "", err } actor := mustUUID(v.userID) // After the commit, on the pool rather than the transaction, so a failure here cannot cost someone // the session they just proved they own. touchLastLogin(ctx, db, actor, mustUUID(v.orgID)) _ = s.deps.Audit.System(ctx, audit.Event{Action: action, ActorUserID: &actor, IPAddress: ip, UserAgent: ua}) return newToken, nil } // afterMFAFailure records a failed MFA attempt and revokes the pending session once the per account // budget is exhausted, forcing a fresh password login (the limiter is not reset by re login). The // budget is counted per account and not per source address for the same reason the password lock is: // rotating addresses would otherwise buy unlimited TOTP guesses. There is no denial of service tail // here, since reaching this code at all requires having already passed the password check. // Its security audit writes fail closed, matching the login failure path: if the trail cannot be // written the request surfaces that error rather than proceeding with the attempt unrecorded. func (s *Service) afterMFAFailure(ctx context.Context, db dbgen.DBTX, v pendingSession, token, ip, ua string) error { email, err := s.credentialEmail(ctx, db, v.userID) if err != nil { email = v.userID // fall back to a stable non secret key } s.recordAttempt(ctx, db, email, orgPtr(v.orgID), ip, false, reasonMFAFailed) actor := mustUUID(v.userID) if aerr := s.deps.Audit.System(ctx, audit.Event{Action: "auth.mfa.verify.failure", ActorUserID: &actor, IPAddress: ip, UserAgent: ua}); aerr != nil { return aerr } count, cerr := s.failureCount(ctx, db, email, reasonMFAFailed) if cerr == nil && count >= maxMFAFailures { if _, derr := db.Exec(ctx, `SELECT auth_revoke_session($1)`, s.tokenHash(token)); derr != nil { return derr } if aerr := s.deps.Audit.System(ctx, audit.Event{Action: "auth.mfa.pending_session.revoked", ActorUserID: &actor, IPAddress: ip, UserAgent: ua}); aerr != nil { return aerr } } return nil } func (s *Service) buildRecoveryCodes() (codes []string, hashes []string, versions []int, tags [][]byte, err error) { codes, err = security.GenerateRecoveryCodes(recoveryCodeCount) if err != nil { return nil, nil, nil, nil, err } for _, c := range codes { h, ver, herr := s.deps.Hasher.Hash(c) if herr != nil { return nil, nil, nil, nil, herr } hashes = append(hashes, h) versions = append(versions, ver) tags = append(tags, s.deps.Tagger.Tag(c)) } return codes, hashes, versions, tags, nil } func (s *Service) lookupLogin(ctx context.Context, db dbgen.DBTX, email string) (loginRow, bool, error) { var r loginRow err := db.QueryRow(ctx, `SELECT user_id, organization_id, role, user_status, password_hash, pepper_version, mfa_enrolled, mfa_status, mfa_secret_ciphertext, mfa_secret_nonce, mfa_key_version, last_used_step FROM auth_lookup_login($1)`, email). Scan(&r.userID, &r.orgID, &r.role, &r.userStatus, &r.passwordHash, &r.pepperVer, &r.mfaEnrolled, &r.mfaStatus, &r.secretCipher, &r.secretNonce, &r.keyVersion, &r.lastUsedStep) if errors.Is(err, pgx.ErrNoRows) { return loginRow{}, false, nil } if err != nil { return loginRow{}, false, err } if r.userStatus != "active" && r.userStatus != "invited" { return loginRow{}, false, nil // disabled accounts cannot log in; treated as not found } return r, true, nil } func (s *Service) credentialEmail(ctx context.Context, db dbgen.DBTX, userID string) (string, error) { var org uuid.UUID var email, hash string var ver int err := db.QueryRow(ctx, `SELECT organization_id, email_normalized, password_hash, pepper_version FROM auth_get_credential($1)`, mustUUID(userID)). Scan(&org, &email, &hash, &ver) if err != nil { return "", err } return email, nil } func (s *Service) recordAttempt(ctx context.Context, db dbgen.DBTX, email string, org *uuid.UUID, ip string, ok bool, reason string) { var reasonPtr *string if reason != "" { reasonPtr = &reason } _, _ = db.Exec(ctx, `INSERT INTO login_attempts (email_normalized, organization_id, ip_address, succeeded, reason) VALUES (lower($1),$2,$3,$4,$5)`, email, org, nullStr(ip), ok, reasonPtr) } // tooManyFailures is the account lockout decision. It counts failures for the EMAIL alone. // // Scoping the count to (email, ip) made the lock free to bypass: an attacker spreading a password // list over rotating source addresses never reached the threshold on any one address, and because // the transport rate limiter is also keyed by ip, one rotation defeated both controls at once. A // control that only stops an attacker who declines to change ip is not a control. // // The tradeoff accepted here is that anyone who knows an email can now hold that account at the // threshold and keep the owner out. That is tolerable because the lock is soft and time windowed, // not sticky: it counts failures inside a rolling 15 minute window (failWindowSeconds) and needs no // administrator to clear, so the legitimate owner is delayed rather than locked out, and the cost of // sustaining the denial is a continuous attack that the login_attempts trail and the audit event // both record. A sticky lock requiring an admin unlock would turn the same knowledge of an email // into a durable outage, which is why this stays a window. func (s *Service) tooManyFailures(ctx context.Context, db dbgen.DBTX, email string) (bool, error) { n, err := s.failureCount(ctx, db, email, reasonBadPassword) if err != nil { return false, err } return n >= maxFailedLogins, nil } func (s *Service) failureCount(ctx context.Context, db dbgen.DBTX, email, reason string) (int, error) { var n int err := db.QueryRow(ctx, `SELECT count(*) FROM login_attempts WHERE email_normalized = lower($1) AND succeeded = false AND reason = $2 AND created_at > now() - make_interval(secs => $3)`, email, reason, failWindowSeconds).Scan(&n) return n, err } // failureSourceSpread counts the DISTINCT source addresses behind the failures in the window. The ip // is no longer part of the lock decision, but it is still the signal that separates one noisy client // from a distributed credential stuffing run, so it is carried into the lockout audit event rather // than discarded. It runs only on the already locked path, so it costs nothing in the normal case. func (s *Service) failureSourceSpread(ctx context.Context, db dbgen.DBTX, email, reason string) (int, error) { var n int err := db.QueryRow(ctx, `SELECT count(DISTINCT ip_address) FROM login_attempts WHERE email_normalized = lower($1) AND succeeded = false AND reason = $2 AND created_at > now() - make_interval(secs => $3)`, email, reason, failWindowSeconds).Scan(&n) return n, err } func (s *Service) failureAudit(ctx context.Context, actor *uuid.UUID, ip, ua, reason string) error { return s.deps.Audit.System(ctx, audit.Event{ Action: "auth.login.failure", ActorUserID: actor, IPAddress: ip, UserAgent: ua, EntityType: "login", NewValues: reasonJSON(reason), }) } func (s *Service) tokenHash(token string) []byte { return security.HashSessionToken(token) } func (s *Service) inTx(ctx context.Context, db dbgen.DBTX, fn func(tx pgx.Tx) error) (err error) { pool, ok := db.(interface { Begin(context.Context) (pgx.Tx, error) }) if !ok { return errors.New("auth: pool does not support transactions") } tx, err := pool.Begin(ctx) if err != nil { return err } defer func() { _ = tx.Rollback(ctx) }() if err = fn(tx); err != nil { return err } return tx.Commit(ctx) } // rotate revokes the old token and mints a new one in one definer call. func rotate(ctx context.Context, tx pgx.Tx, oldHash, newHash []byte, mfaSatisfied bool, idle, absolute int, ip, ua string) error { var sid uuid.UUID var idleExp, absExp interface{} err := tx.QueryRow(ctx, `SELECT session_id, idle_expires_at, absolute_expires_at FROM auth_rotate_session($1,$2,'active',$3,$4,$5,$6,$7)`, oldHash, newHash, mfaSatisfied, idle, absolute, nullStr(ip), nullStr(ua)).Scan(&sid, &idleExp, &absExp) if errors.Is(err, pgx.ErrNoRows) { return ErrInvalidSession } if err != nil { return err } // Stamp the last sign in. // // The column and its query have existed since the foundation migration and nothing ever called them, // so last_login_at was NULL for every account that had ever signed in. The People screen showed // "never signed in" next to the owner who was looking at it, which is how this was noticed. // // Done here rather than in each caller because rotate is the one place every path to an active // session passes through: password login when MFA is off, TOTP verify, MFA activation and recovery. // Putting it in one of those would have left the others still lying. // // Best effort on purpose. A failure to record a timestamp must never cost someone their session, and // the authoritative record of the sign in is the audit row, which is written separately and does not // fail open. // The last sign in is NOT stamped here. // // An earlier version did, and it broke every MFA activation: a statement that fails inside a Postgres // transaction aborts the whole transaction, so ignoring its error with _ does not make it harmless, // and the subsequent COMMIT came back as a rollback. Best effort work belongs outside the transaction // it could poison, so the callers stamp it after their commit succeeds. return nil } // touchLastLogin records a successful sign in. // // The column and its generated query have existed since the foundation migration and nothing ever // called them, so last_login_at was NULL for every account that had ever signed in. // // Best effort by design. Failing to record a timestamp must never cost someone their session, and the // authoritative record of the sign in is the audit row, which is written separately and does not fail // open. It is called from both places a session becomes active: here after rotation, and in // createInitialSession when MFA is disabled. func touchLastLogin(ctx context.Context, db dbgen.DBTX, userID, orgID uuid.UUID) { // Through a SECURITY DEFINER function, not a plain UPDATE. Login runs before any tenant context // exists, so app.current_org_id is unset and the row level policy on users matches nothing: the first // version of this silently updated zero rows and the column stayed NULL. Every other privileged auth // write goes through such a function for the same reason. // // The organization is passed EXPLICITLY. The function previously took only a user id and updated by // primary key with no org predicate, which made a SECURITY DEFINER granted to the application role // into a cross tenant write primitive and an existence oracle for user ids in other organizations. // Both values come from the validated session, so naming the tenant here is an assertion, not trust. // // THE PLACEHOLDERS ARE LOAD BEARING AND WERE MISSING. This statement read // `SELECT auth_touch_last_login(, )` in the source: the $1 and $2 had been stripped, almost certainly // by an edit made through a shell where the dollar signs were expanded away. Postgres rejected it as a // syntax error on EVERY sign in, and because both return values were discarded, it failed silently for // weeks. Four of five production accounts held real active sessions while the accounts screen said // "never signed in" beside every one of them. // // THE FAILURE IS NOW LOGGED, which is the actual repair. An instrument that lies is worse than no // instrument, because people act on it. It still must not FAIL the login: recording when someone signed // in is bookkeeping, and refusing entry because the bookkeeping failed would turn a reporting defect // into an outage. So the error is logged loudly and then swallowed, which is a different thing from // swallowed accidentally. tag, err := db.Exec(ctx, `SELECT auth_touch_last_login($1, $2)`, userID, orgID) if err != nil { slog.Error("auth: last login stamp failed, the accounts screen will understate this sign in", "error", err, "user_id", userID.String()) return } // Zero rows is not an error to Postgres and is exactly what a silent no op looks like: a wrong org, a // row filtered by row level security, or a NULL argument the function guards by returning early. It has // to be caught explicitly or it is indistinguishable from success. if tag.RowsAffected() == 0 { slog.Error("auth: last login stamp affected no rows, this sign in is unrecorded", "user_id", userID.String(), "org_id", orgID.String()) } }