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 | 09015496d565a172bcaa15cfe9a8e4b072b13df0 |
| Branch | workstream-b-screenmatch |
| Path or archive member | internal/modules/auth/service.go |
| Original SHA-256 | 0717d458040e77b09991ae0de788e54aa0021ba3b5aa62bb8f483bc7d511ba89 |
| Git blob SHA | e51c7b509216d62db64ddf212517326114d08d18 |
| Reading-copy SHA-256 | 39c1ea0cb8f845d48d27ea6a13309b3d068a1fb77a375f6e8e07e6d16251c16d |
Every recorded occurrence
These 65 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-b-screenmatch | 09015496d565a172bcaa15cfe9a8e4b072b13df0 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-a-opportunity-intelligence | 1675031db74316e100782d063e9eda1a597fe3a1 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | master | 1e9249840a4302ec82e123b9c9cb6f75badea3e4 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-d-command-center | 255e7d1d420abf5e277d0cd8c9b14f6f72b6acb9 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-d-missionmatch | 3be46ba89edb737182075a2b320509b42dc916f8 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | m5-price-to-win | 41000ad30fff4129fdaf5ec84e15ba14c27d4890 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-e-dataflow-deploy | 433815a042f9741411e60fa553fe72143e9b10db | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | w3-ai-staffing-recommendations | 43d56387270ef4da4cabd794d97ca6a49909bc7d | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-e-commercial-stubs | 4437da660539284a400ee380b9b427a300c369ee | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws3-clearance | 4b8e7ce594c068621867f9c9deed0ec64ae6c553 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | collision-deconfliction | 4ee5a09972608ed89f8377c6665542d5f450f0a6 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-c-slatematch | 5cd60c918301b46ecd6ed3e03e20c395da0062e2 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ai-intelligence-layer | 5d5ddff70393a197c5f54a45280bb5dd42b24755 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws7-federal-signal | 6a9f6ce9cfb13345ffb47770d4960e7c2d9c17c2 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws6-retrieval | 6c56e29231ec05735927fa265c97d517499dfeaf | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | per-tenant-routing | 9bde0f9a74b89d01980fa658e5253acda7528d0c | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ai-brain-live | aac32fc49c878cc5bca099459307611562d0bac1 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | graphrag-foundation | b0ddeb9f633fd138d09309d1a17b7d13bc3a67f7 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws1-ontology-sync | b596f449a32a1016fbd403c36505cc00a1ac9ee6 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | time-to-fill-survival | b66e1e505863d4f17741a4b9ede73509e45bd6c6 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ruthless-debug-sweep | b7d742f8f032984be3817efffa3eb8b13b651c1f | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | mag-substrate | b82c7100a492dec67dfd1eda6b3f53def859be35 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | capture-readiness | bae6af7cb2997e819174a9e9cfff5e16067a1975 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | deploy-and-ops | bdacd9d0b4d5998d25fc74bf4007f4c3ec538c7a | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws8-teaching-loop | c823f0fb6bb2aba0f39001eb6de96f4000d9321f | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | phase2-eval-fairness | d361af254701975fe239927656ababb56374655e | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-b-sam-contract-awards | d5101cceb98e70000cebb31d3e7ac73d747d3a6d | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | phase2-connectors | e038d4c687aec16f8bd9bc4d907eea14d91f8704 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-a-shared-core | e0de03ac3a1ec826bd15d0e048e3b02baa23eeff | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws2-alias-relationship | e4dada4601c6eb101830f9d223a9655dfa120616 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | presentation-readiness | eb574acaa29d71ce4d0f852572e87c275c79fdb7 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws9-bench-ingestion | f585a9ef32fa554663ce1fba9a414a54b6e49cf0 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | vendor-intelligence | fe9796a19c261951d3f2565206c0c1b5ac294465 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | per-tenant-routing | 9bde0f9a74b89d01980fa658e5253acda7528d0c | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | collision-deconfliction | 4ee5a09972608ed89f8377c6665542d5f450f0a6 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws6-retrieval | 6c56e29231ec05735927fa265c97d517499dfeaf | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-a-shared-core | e0de03ac3a1ec826bd15d0e048e3b02baa23eeff | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | m5-price-to-win | 41000ad30fff4129fdaf5ec84e15ba14c27d4890 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-c-slatematch | 5cd60c918301b46ecd6ed3e03e20c395da0062e2 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws1-ontology-sync | b596f449a32a1016fbd403c36505cc00a1ac9ee6 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | master | 1e9249840a4302ec82e123b9c9cb6f75badea3e4 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws8-teaching-loop | c823f0fb6bb2aba0f39001eb6de96f4000d9321f | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | w3-ai-staffing-recommendations | 43d56387270ef4da4cabd794d97ca6a49909bc7d | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | mag-substrate | b82c7100a492dec67dfd1eda6b3f53def859be35 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws3-clearance | 4b8e7ce594c068621867f9c9deed0ec64ae6c553 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-d-command-center | 255e7d1d420abf5e277d0cd8c9b14f6f72b6acb9 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | phase2-eval-fairness | d361af254701975fe239927656ababb56374655e | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-e-dataflow-deploy | 433815a042f9741411e60fa553fe72143e9b10db | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ruthless-debug-sweep | b7d742f8f032984be3817efffa3eb8b13b651c1f | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | time-to-fill-survival | b66e1e505863d4f17741a4b9ede73509e45bd6c6 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ai-intelligence-layer | 5d5ddff70393a197c5f54a45280bb5dd42b24755 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | graphrag-foundation | b0ddeb9f633fd138d09309d1a17b7d13bc3a67f7 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-a-opportunity-intelligence | 1675031db74316e100782d063e9eda1a597fe3a1 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | deploy-and-ops | bdacd9d0b4d5998d25fc74bf4007f4c3ec538c7a | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws9-bench-ingestion | f585a9ef32fa554663ce1fba9a414a54b6e49cf0 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws2-alias-relationship | e4dada4601c6eb101830f9d223a9655dfa120616 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | presentation-readiness | eb574acaa29d71ce4d0f852572e87c275c79fdb7 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-b-screenmatch | 09015496d565a172bcaa15cfe9a8e4b072b13df0 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ws7-federal-signal | 6a9f6ce9cfb13345ffb47770d4960e7c2d9c17c2 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-e-commercial-stubs | 4437da660539284a400ee380b9b427a300c369ee | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | ai-brain-live | aac32fc49c878cc5bca099459307611562d0bac1 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-d-missionmatch | 3be46ba89edb737182075a2b320509b42dc916f8 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | phase2-connectors | e038d4c687aec16f8bd9bc4d907eea14d91f8704 | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | workstream-b-sam-contract-awards | d5101cceb98e70000cebb31d3e7ac73d747d3a6d | Open original |
| Janady13/missionmatch / internal/modules/auth/service.go | capture-readiness | bae6af7cb2997e819174a9e9cfff5e16067a1975 | Open original |
Recovered source transcript
Download complete source text ↓730 lines · 25.9 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"
"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 and ip 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, ip)
if err != nil {
return LoginResult{}, err
}
if locked {
s.recordAttempt(ctx, db, in.Email, nil, ip, false, reasonLocked)
if aerr := s.deps.Audit.System(ctx, audit.Event{Action: "auth.account.locked", IPAddress: ip, UserAgent: ua, EntityType: "email", NewValues: reasonJSON(reasonLocked)}); 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
}
if err := s.createInitialSession(ctx, db, tokenHash, row.userID, row.orgID, ip, ua); 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: !s.deps.MFADisabled}, nil
}
// 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.
func (s *Service) createInitialSession(ctx context.Context, db dbgen.DBTX, tokenHash []byte, userID, orgID uuid.UUID, ip, ua string) error {
if s.deps.MFADisabled {
_, 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)
return err
}
_, 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)
_ = 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
}
return tenant.Principal{UserID: v.userID, Role: v.role}, v.orgID, nil
}
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)
_ = 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
// and ip budget is exhausted, forcing a fresh password login (the limiter is not reset by re login).
// 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, ip, 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)
}
func (s *Service) tooManyFailures(ctx context.Context, db dbgen.DBTX, email, ip string) (bool, error) {
n, err := s.failureCount(ctx, db, email, ip, reasonBadPassword)
if err != nil {
return false, err
}
return n >= maxFailedLogins, nil
}
func (s *Service) failureCount(ctx context.Context, db dbgen.DBTX, email, ip, 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 (ip_address = $3 OR $3 IS NULL) AND created_at > now() - make_interval(secs => $4)`,
email, reason, nullStr(ip), 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
}
return err
}