Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/auth/service.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/auth/service.go |
| Original SHA-256 | df3016b38216cec03a54f05bcfafc6ebe0d91b5735b2efdb2c1896784768135b |
| Git blob SHA | 8de0d3d62b3615381feed32afeff67ae47dceee8 |
| Reading-copy SHA-256 | 637443233f360a88e2b34306326c45f3e055b7f17421e70709dcc7ec6dfb26e3 |
Recovered source transcript
Download complete source text ↓930 lines · 38.4 KB
Credential-bearing and unrelated personal details were removed from this reading copy. The source identity and original checksum remain recorded.
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())
}
}