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 | 39df94f92b2a33f852481fe1954dabed841524bb |
| Branch | w0-aedt-compliance |
| Path or archive member | internal/modules/auth/service.go |
| Original SHA-256 | 67131366f5ceee183807b2955be713744087eacb6fa59b91cf8fde738c984497 |
| Git blob SHA | 22c21bad6c7836e2b7568c5d26c0d0c34adc641c |
| Reading-copy SHA-256 | 75fa31d5eccaf4c821ff727b974771e0d7e7d22e774b15be0999e5ff47ea98ed |
Recovered source transcript
Download complete source text ↓715 lines · 25.1 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 := db.Exec(ctx,
`SELECT auth_create_session($1,$2,$3,'auth_pending',false,$4,$4,$5,$6)`,
tokenHash, row.userID, row.orgID, s.deps.PendingSeconds, 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}, nil
}
// ---------------------------------------------------------------------------
// 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
}