diff --git a/README.md b/README.md index 162f333..9d6ee8f 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,58 @@ if errors.As(err, &secondFactor) { `ListPasskeys(ctx, engine, userID)` lists registered passkeys (nickname, creation time, last used). `DeletePasskey(ctx, engine, userID, credentialID, currentPassword)` removes one — requires the current password, same reasoning as `DisableTOTP`. Calling any passkey function without `Config.WebAuthn` set returns `cryden.ErrWebAuthnNotConfigured`. +## Magic-link (passwordless) login + +Requires one additional `Config` field: + +```go +engine, err := cryden.New(cryden.Config{ + // ...required fields, and Verifications (shared with email-change tokens)... + MagicLinkSender: yourMagicLinkSender, // implements notify.MagicLinkSender +}) +``` + +`MagicLinkSender` is a separate interface from `EmailSender` — not a new method added to it, since `EmailSender` already shipped and adding a required method would break every existing implementation. `Config.Verifications` must also be set; magic-link tokens reuse the same store email-change tokens use, distinguished by purpose internally. + +This logs in an **existing account only** — it doesn't create one: + +```go +err := cryden.RequestMagicLink(ctx, engine, email, callerIP) +// always nil for a nonexistent email too (avoids leaking which emails are registered); +// a real delivery failure for an existing account still returns as an error + +tokens, err := cryden.CompleteMagicLink(ctx, engine, rawTokenFromTheLink, callerIP, userAgent) +``` + +The link is valid for 15 minutes and single-use — clicking it a second time fails the same way an expired one does. Like `Login`, `CompleteMagicLink` routes through the same second-factor gate: an account with TOTP/a passkey enrolled returns `*auth.ErrSecondFactorRequired` here exactly as it would after a correct password — clicking the link proves email ownership, the primary factor, not a bypass of a confirmed second one. Calling either function without `Config.MagicLinkSender` set returns `cryden.ErrMagicLinkNotConfigured`. + +## Recovery (backup) codes + +Requires one additional `Config` field: + +```go +engine, err := cryden.New(cryden.Config{ + // ...required fields... + RecoveryCodes: postgres.NewRecoveryCodeStore(db), // or memory.NewRecoveryCodeStore() +}) +``` + +Generating a batch requires the account to already have a confirmed TOTP secret or a registered passkey — codes exist to recover access to a *real* second factor, not to stand in as one on their own: + +```go +codes, err := cryden.GenerateRecoveryCodes(ctx, engine, userID) +// show `codes` to the user ONCE — the engine only ever stores their hashes +// and can never display them again after this call returns +``` + +Generating a fresh batch always replaces the previous one in full — every old code, used or not, stops working immediately. Completion works the same way TOTP does: + +```go +tokens, err := cryden.CompleteLoginWithRecoveryCode(ctx, engine, secondFactor.PendingToken, code, callerIP, userAgent) +``` + +**One safety property worth knowing:** `"recovery_code"` only ever appears in `Login`'s `Methods` list *alongside* `"totp"` and/or `"webauthn"` — never on its own. If an account's last real second factor gets disabled while unconsumed codes still exist in storage, those codes stop being offered at all, rather than silently becoming a standalone permanent backdoor into the account. Calling either function without `Config.RecoveryCodes` set returns `cryden.ErrRecoveryCodesNotConfigured`. + ## AI-assisted admin queries (library support only) The `ai` subpackage provides the safety machinery for natural-language admin tooling — an allowlisted `QueryIntent` type, `validateIntent`, and `ExecuteQuery` — plus `store/postgres.SafeQueryStore`, a read-only query executor. This is a foundation for tools like `csax`'s CLI to build on, not a feature you call directly in application code. An LLM's output is treated as untrusted data to validate against a strict allowlist, never as SQL to execute — and the actual DB connection passed to `SafeQueryStore` must be opened with a read-only Postgres role, since that's the real safety boundary, not just the allowlist check. `ai.LLMProvider` ships zero implementations; bring your own (OpenAI, Anthropic, OpenRouter, a local model). @@ -233,6 +285,8 @@ The `ai` subpackage provides the safety machinery for natural-language admin too - Signup, login, logout (single device + all devices) - OAuth login/signup (Google, GitHub, or any provider) with explicit, non-auto-linking account collision handling — see [OAuth](#oauth-google-github-or-any-provider) - Two-factor authentication: TOTP and passkeys (WebAuthn), unified under one pause state — see [Two-factor authentication](#two-factor-authentication-totp) and [Passkeys](#passkeys-webauthn-as-a-second-factor) +- Magic-link (passwordless) login for existing accounts, routed through the same second-factor gate — see [Magic-link login](#magic-link-passwordless-login) +- Recovery (backup) codes as a second-factor fallback, with a safety guard against becoming a standalone backdoor once the real factor is removed — see [Recovery codes](#recovery-backup-codes) - JWT access tokens + rotating opaque refresh tokens with theft/reuse detection - Session listing and revocation - Change password (requires current password, revokes all other sessions) @@ -247,7 +301,7 @@ The `ai` subpackage provides the safety machinery for natural-language admin too ## What's not in v2 (yet) -CLI, HTTP API, and language SDKs are separate repositories that wrap this engine — this repo is the core library only. Magic links, SMS OTP, WebAuthn, SAML, and other advanced auth methods are planned for later releases. +CLI, HTTP API, and language SDKs are separate repositories that wrap this engine — this repo is the core library only. SMS OTP, SAML, and other advanced auth methods are planned for later releases. Passkeys are currently second-factor only — passwordless *primary* login via passkeys (no password step at all) is a planned fast-follow now that magic-link forced the shared "login without a password" plumbing to exist. ## License diff --git a/auth/lockout_test.go b/auth/lockout_test.go index d8f6ef6..05aa6e4 100644 --- a/auth/lockout_test.go +++ b/auth/lockout_test.go @@ -16,7 +16,7 @@ func TestLogin_LocksAccountAfterThreshold(t *testing.T) { threshold := 3 for i := 0; i < threshold; i++ { - _, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", threshold, time.Minute) if err != ErrInvalidCredentials { t.Fatalf("attempt %d: expected ErrInvalidCredentials, got %v", i+1, err) @@ -26,7 +26,7 @@ func TestLogin_LocksAccountAfterThreshold(t *testing.T) { // One more attempt, even with the CORRECT password, must now be // rejected as locked — the lock isn't just "N more wrong guesses // fail," it blocks everything including a legitimate login. - _, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", threshold, time.Minute) if err != ErrAccountLocked { t.Errorf("expected ErrAccountLocked, got %v", err) @@ -44,12 +44,12 @@ func TestLogin_SuccessfulLoginResetsFailedAttempts(t *testing.T) { threshold := 5 // Two failed attempts, below threshold. for i := 0; i < 2; i++ { - Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", threshold, time.Minute) } // A successful login should reset the counter. - _, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", threshold, time.Minute) if err != nil { t.Fatalf("expected successful login, got %v", err) @@ -72,11 +72,11 @@ func TestLogin_LockExpiresAfterDuration(t *testing.T) { threshold := 1 shortLock := 10 * time.Millisecond - Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", threshold, shortLock) // Immediately after: locked. - _, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", threshold, shortLock) if err != ErrAccountLocked { t.Fatalf("expected ErrAccountLocked immediately after lock, got %v", err) @@ -85,7 +85,7 @@ func TestLogin_LockExpiresAfterDuration(t *testing.T) { time.Sleep(20 * time.Millisecond) // After the lock duration passes, login should succeed again. - _, err = Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err = Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", threshold, shortLock) if err != nil { t.Errorf("expected login to succeed after lock expiry, got %v", err) diff --git a/auth/login.go b/auth/login.go index de7fc6d..b636ffe 100644 --- a/auth/login.go +++ b/auth/login.go @@ -34,6 +34,7 @@ func Login( sessions store.SessionStore, totpStore store.TOTPStore, webauthnStore store.WebAuthnCredentialStore, + recoveryCodeStore store.RecoveryCodeStore, hasher security.Hasher, ids security.IDGenerator, refreshGen token.TokenGenerator, @@ -107,40 +108,93 @@ func Login( log.Error("login: reset failed-attempts error", map[string]string{"error": err.Error(), "user_id": user.ID}) } - // Password verified. Collect any confirmed second-factor methods - // this account has enrolled — if there are any, pause here - // instead of issuing tokens directly. + // Password verified. Route through the same second-factor gate + // every primary authentication method uses (magic-link login goes + // through this too) — a correct password only ever proves the + // primary factor, never bypasses a confirmed second one. + return completePrimaryAuth(ctx, sessions, totpStore, webauthnStore, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, user, callerIP, userAgent, nil) +} + +// completePrimaryAuth is the shared tail of every primary +// authentication path (password login, magic-link login, OAuth login, +// and any future one) once the caller has independently established +// "this really is the account owner." It collects any confirmed +// second-factor methods the account has enrolled — a confirmed TOTP +// secret, one or more registered passkeys, or both — and either +// pauses with *ErrSecondFactorRequired or finishes the login +// directly. Centralizing this here means a new primary auth method +// can never accidentally skip the second-factor gate by reimplementing +// this check slightly differently. extraMetadata is passed straight +// through to finishLogin's audit event (e.g. OAuth's provider) — nil +// if there's nothing to add. +// +// "recovery_code" is only ever added to Methods alongside a real +// confirmed factor (totp/webauthn) — never on its own. Otherwise an +// account that disabled its last real second factor but still has +// unconsumed recovery codes sitting in storage would have those codes +// silently become a permanent standalone backdoor into the account, +// long after 2FA was supposedly turned off. +func completePrimaryAuth( + ctx context.Context, + sessions store.SessionStore, + totpStore store.TOTPStore, + webauthnStore store.WebAuthnCredentialStore, + recoveryCodeStore store.RecoveryCodeStore, + ids security.IDGenerator, + refreshGen token.TokenGenerator, + jwtIssuer *token.JWTIssuer, + pendingIssuer *token.MFAPendingIssuer, + audit store.AuditStore, + log logger.Logger, + user store.User, + callerIP string, + userAgent string, + extraMetadata map[string]string, +) (Tokens, error) { var methods []string + hasRealSecondFactor := false if totpStore != nil { secretRec, err := totpStore.GetByUserID(ctx, user.ID) if err == nil && secretRec.ConfirmedAt != nil { + hasRealSecondFactor = true methods = append(methods, "totp") } } if webauthnStore != nil { creds, err := webauthnStore.ListByUser(ctx, user.ID) if err == nil && len(creds) > 0 { + hasRealSecondFactor = true methods = append(methods, "webauthn") } } + if hasRealSecondFactor && recoveryCodeStore != nil { + count, err := recoveryCodeStore.CountUnused(ctx, user.ID) + if err == nil && count > 0 { + methods = append(methods, "recovery_code") + } + } if len(methods) > 0 { pendingToken, issueErr := pendingIssuer.Issue(user.ID) if issueErr != nil { return Tokens{}, issueErr } - log.Info("login: password verified, awaiting second factor", map[string]string{"user_id": user.ID}) + log.Info("login: primary factor verified, awaiting second factor", map[string]string{"user_id": user.ID}) return Tokens{}, &ErrSecondFactorRequired{PendingToken: pendingToken, Methods: methods} } - return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, user, callerIP, userAgent, "") + return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, user, callerIP, userAgent, "", extraMetadata) } // finishLogin issues a new session (access + refresh token pair) for -// an already-authenticated user. Shared by Login (password-only -// accounts) and CompleteLoginWithTOTP (accounts with 2FA) so both -// paths create sessions identically — a second factor changes how a -// caller gets here, never what a completed login produces. mfaMethod -// is recorded in the audit event's metadata ("" for password-only). +// an already-authenticated user. Shared by every path that reaches a +// completed login (password, magic-link, OAuth, and each +// second-factor completion) so they all create sessions identically. +// mfaMethod is recorded in the audit event's metadata ("" for no +// second factor). extraMetadata is merged in alongside it — e.g. +// LoginWithOAuth passes {"provider": provider} so the audit trail +// still shows which provider was used, the same detail it recorded +// before this became a shared helper. Pass nil if there's nothing to +// add. func finishLogin( ctx context.Context, sessions store.SessionStore, @@ -153,6 +207,7 @@ func finishLogin( callerIP string, userAgent string, mfaMethod string, + extraMetadata map[string]string, ) (Tokens, error) { sessionID, err := ids.New() if err != nil { @@ -185,8 +240,14 @@ func finishLogin( } var metadata map[string]string - if mfaMethod != "" { - metadata = map[string]string{"mfa": mfaMethod} + if mfaMethod != "" || len(extraMetadata) > 0 { + metadata = make(map[string]string, len(extraMetadata)+1) + for k, v := range extraMetadata { + metadata[k] = v + } + if mfaMethod != "" { + metadata["mfa"] = mfaMethod + } } if err := audit.Record(ctx, store.AuditEvent{ Type: store.EventLoginSuccess, diff --git a/auth/login_second_factor_test.go b/auth/login_second_factor_test.go index 7bdaa9e..31900a6 100644 --- a/auth/login_second_factor_test.go +++ b/auth/login_second_factor_test.go @@ -31,7 +31,7 @@ func TestLogin_WebAuthnOnlyReportsWebAuthnMethod(t *testing.T) { users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) registerRealPasskeyForUser(t, ctx, users, webauthnStore, provider, enc, ids, audit, "user-1") - _, err := Login(ctx, users, sessions, nil, webauthnStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, webauthnStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) var secondFactor *ErrSecondFactorRequired @@ -66,7 +66,7 @@ func TestLogin_TOTPAndWebAuthnBothReportBothMethods(t *testing.T) { enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") registerRealPasskeyForUser(t, ctx, users, webauthnStore, provider, enc, ids, audit, "user-1") - _, err := Login(ctx, users, sessions, totpStore, webauthnStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, totpStore, webauthnStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) var secondFactor *ErrSecondFactorRequired @@ -110,7 +110,7 @@ func TestLogin_NoSecondFactorEnrolledIssuesTokensDirectly(t *testing.T) { hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) - tokens, err := Login(ctx, users, sessions, totpStore, webauthnStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + tokens, err := Login(ctx, users, sessions, totpStore, webauthnStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/auth/login_test.go b/auth/login_test.go index 9bbb64d..2fb8ee3 100644 --- a/auth/login_test.go +++ b/auth/login_test.go @@ -33,7 +33,7 @@ func TestLogin_Success(t *testing.T) { // totpStore/pendingIssuer are nil — TOTP not configured for this // engine, Login must behave exactly as it did before TOTP existed. - tokens, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + tokens, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "correct-password", "1.2.3.4", "test-agent", 5, time.Minute) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -51,7 +51,7 @@ func TestLogin_WrongPasswordRejected(t *testing.T) { hash, _ := hasher.Hash("correct-password") users.Create(ctx, storeUser("user-1", "proguy@example.com", hash)) - _, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", 5, time.Minute) if err != ErrInvalidCredentials { t.Errorf("expected ErrInvalidCredentials, got %v", err) @@ -65,7 +65,7 @@ func TestLogin_NonexistentUserRejectedWithSameError(t *testing.T) { log := testLogger{} ctx := context.Background() - _, err := Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + _, err := Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "nobody@example.com", "any-password", "1.2.3.4", "test-agent", 5, time.Minute) if err != ErrInvalidCredentials { t.Errorf("expected ErrInvalidCredentials (same as wrong password), got %v", err) @@ -93,12 +93,12 @@ func TestLogin_NonexistentUserTimingMatchesWrongPassword(t *testing.T) { users.Create(ctx, storeUser("user-1", "proguy@example.com", hash)) start := time.Now() - Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "proguy@example.com", "wrong-password", "1.2.3.4", "test-agent", 5, time.Minute) wrongPasswordDuration := time.Since(start) start = time.Now() - Login(ctx, users, sessions, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, + Login(ctx, users, sessions, nil, nil, nil, hasher, ids, refreshGen, jwtIssuer, nil, limiter, audit, log, "nobody@example.com", "any-password", "1.2.3.4", "test-agent", 5, time.Minute) nonexistentUserDuration := time.Since(start) diff --git a/auth/login_totp_test.go b/auth/login_totp_test.go index 4fab7e1..a856bf3 100644 --- a/auth/login_totp_test.go +++ b/auth/login_totp_test.go @@ -56,7 +56,7 @@ func TestLogin_WithConfirmedTOTPReturnsErrSecondFactorRequired(t *testing.T) { users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") - tokens, err := Login(ctx, users, sessions, totpStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + tokens, err := Login(ctx, users, sessions, totpStore, nil, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) var totpRequired *ErrSecondFactorRequired @@ -81,7 +81,7 @@ func TestLogin_WithoutTOTPConfiguredIssuesTokensDirectly(t *testing.T) { hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) - tokens, err := Login(ctx, users, sessions, totpStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + tokens, err := Login(ctx, users, sessions, totpStore, nil, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -105,7 +105,7 @@ func TestLogin_UnconfirmedTOTPDoesNotGateLogin(t *testing.T) { t.Fatalf("enroll failed: %v", err) } - tokens, err := Login(ctx, users, sessions, totpStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + tokens, err := Login(ctx, users, sessions, totpStore, nil, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -124,7 +124,7 @@ func TestCompleteLoginWithTOTP_CorrectCodeIssuesTokens(t *testing.T) { users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) secret := enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") - _, err := Login(ctx, users, sessions, totpStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, totpStore, nil, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) var totpRequired *ErrSecondFactorRequired if !errors.As(err, &totpRequired) { @@ -151,7 +151,7 @@ func TestCompleteLoginWithTOTP_WrongCodeRejected(t *testing.T) { users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") - _, err := Login(ctx, users, sessions, totpStore, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + _, err := Login(ctx, users, sessions, totpStore, nil, nil, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) var totpRequired *ErrSecondFactorRequired errors.As(err, &totpRequired) diff --git a/auth/magiclink.go b/auth/magiclink.go new file mode 100644 index 0000000..45c14bf --- /dev/null +++ b/auth/magiclink.go @@ -0,0 +1,157 @@ +package auth + +import ( + "context" + "time" + + "github.com/crydensync/cryden/v2/logger" + "github.com/crydensync/cryden/v2/notify" + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/token" +) + +// magicLinkTTL is how long a login link stays valid — fixed, not +// configurable, same reasoning as mfaPendingTTL: a passwordless login +// link is a bearer credential for the account it's mailed to, and +// making its lifetime a tuning knob invites a deployment to widen it +// well past what "click the link you just got" actually needs. 15 +// minutes is generous enough to survive someone switching to their +// email app without leaving a long-lived credential sitting in an +// inbox. +const magicLinkTTL = 15 * time.Minute + +// RequestMagicLink sends a passwordless login link to email, for an +// EXISTING account only — this does not create accounts. To avoid +// leaking which emails are registered, it returns nil regardless of +// whether the account exists; the email is only actually sent when it +// does. A genuine delivery failure (the sender's own error) still +// propagates for an existing account, since that's an operational +// concern distinct from enumeration — silently swallowing real send +// failures would hide delivery problems from monitoring for no real +// security benefit. +func RequestMagicLink( + ctx context.Context, + users store.UserStore, + verifications store.VerificationStore, + sender notify.MagicLinkSender, + tokenGen token.TokenGenerator, + ids security.IDGenerator, + limiter security.RateLimiter, + audit store.AuditStore, + log logger.Logger, + email string, + callerIP string, +) error { + allowed, err := limiter.Allow(ctx, "magic-link:"+callerIP+":"+email) + if err != nil { + log.Error("request magic link: rate limiter error", map[string]string{"error": err.Error()}) + return err + } + if !allowed { + log.Warn("request magic link: rate limited", map[string]string{"ip": callerIP}) + return ErrRateLimited + } + + user, err := users.GetByEmail(ctx, email) + if err != nil { + // No such account — return nil rather than an error, same + // enumeration-avoidance reasoning as Login's nonexistent-user + // path. Unlike Login, there's no password hash to pay the + // cost of here — the response never contains anything for an + // attacker to time against beyond "did an email get sent," + // which they can't observe directly anyway. + log.Info("magic link requested for unknown email", map[string]string{"ip": callerIP}) + return nil + } + + rawToken, err := tokenGen.New() + if err != nil { + return err + } + id, err := ids.New() + if err != nil { + return err + } + + vt := store.VerificationToken{ + ID: id, + UserID: user.ID, + Purpose: store.PurposeMagicLink, + TokenHash: token.HashToken(rawToken), + ExpiresAt: time.Now().Add(magicLinkTTL), + } + if err := verifications.Create(ctx, vt); err != nil { + return err + } + + if err := sender.SendMagicLink(ctx, email, rawToken); err != nil { + return err + } + + if err := audit.Record(ctx, store.AuditEvent{ + Type: store.EventMagicLinkRequested, + UserID: user.ID, + IP: callerIP, + }); err != nil { + log.Error("request magic link: audit record failed", map[string]string{"error": err.Error(), "user_id": user.ID}) + } + + log.Info("magic link requested", map[string]string{"user_id": user.ID}) + return nil +} + +// CompleteMagicLink logs in using the raw token from a link sent by +// RequestMagicLink. The token is single-use — MarkUsed is called as +// soon as it passes validation, before any second-factor check or +// session creation, so a link can never be replayed even if something +// later in this call fails. +// +// Clicking a valid link proves email ownership, the primary factor — +// it does not bypass a confirmed second factor. This routes through +// the exact same completePrimaryAuth gate password login uses, so an +// account with TOTP/a passkey enrolled pauses here exactly as it +// would after a correct password. +func CompleteMagicLink( + ctx context.Context, + users store.UserStore, + sessions store.SessionStore, + verifications store.VerificationStore, + totpStore store.TOTPStore, + webauthnStore store.WebAuthnCredentialStore, + recoveryCodeStore store.RecoveryCodeStore, + ids security.IDGenerator, + refreshGen token.TokenGenerator, + jwtIssuer *token.JWTIssuer, + pendingIssuer *token.MFAPendingIssuer, + audit store.AuditStore, + log logger.Logger, + rawToken string, + callerIP string, + userAgent string, +) (Tokens, error) { + vt, err := verifications.GetByTokenHash(ctx, token.HashToken(rawToken)) + if err != nil { + return Tokens{}, ErrVerificationTokenInvalid + } + if vt.Purpose != store.PurposeMagicLink { + return Tokens{}, ErrVerificationTokenInvalid + } + if vt.UsedAt != nil { + return Tokens{}, ErrVerificationTokenInvalid + } + if time.Now().After(vt.ExpiresAt) { + return Tokens{}, ErrVerificationTokenExpired + } + + if err := verifications.MarkUsed(ctx, vt.ID); err != nil { + log.Error("complete magic link: mark-used failed", map[string]string{"error": err.Error(), "user_id": vt.UserID}) + } + + user, err := users.GetByID(ctx, vt.UserID) + if err != nil { + return Tokens{}, err + } + + return completePrimaryAuth(ctx, sessions, totpStore, webauthnStore, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, user, callerIP, userAgent, nil) +} diff --git a/auth/magiclink_test.go b/auth/magiclink_test.go new file mode 100644 index 0000000..74ef071 --- /dev/null +++ b/auth/magiclink_test.go @@ -0,0 +1,209 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/crydensync/cryden/v2/token" +) + +type captureMagicLinkSender struct { + to string + rawToken string + calls int +} + +func (c *captureMagicLinkSender) SendMagicLink(ctx context.Context, to string, rawToken string) error { + c.to = to + c.rawToken = rawToken + c.calls++ + return nil +} + +func newMagicLinkTestDeps(t *testing.T) (*memory.UserStore, *memory.VerificationStore, *memory.AuditStore, security.IDGenerator, token.TokenGenerator, *captureMagicLinkSender, security.RateLimiter) { + t.Helper() + users := memory.NewUserStore() + verifications := memory.NewVerificationStore() + audit := memory.NewAuditStore() + ids := security.NewUUIDv7Generator() + tokenGen, _ := token.NewCryptoRandTokenGenerator(32) + sender := &captureMagicLinkSender{} + limiter := security.NewInMemoryRateLimiter(1000, time.Minute) + return users, verifications, audit, ids, tokenGen, sender, limiter +} + +func TestRequestMagicLink_SendsForExistingAccount(t *testing.T) { + users, verifications, audit, ids, tokenGen, sender, limiter := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", "hash")) + + err := RequestMagicLink(ctx, users, verifications, sender, tokenGen, ids, limiter, audit, log, "raymondproguy@dev.com", "1.2.3.4") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sender.calls != 1 { + t.Fatalf("expected exactly 1 send, got %d", sender.calls) + } + if sender.to != "raymondproguy@dev.com" { + t.Errorf("expected send to raymondproguy@dev.com, got %q", sender.to) + } + if sender.rawToken == "" { + t.Error("expected a non-empty token") + } +} + +func TestRequestMagicLink_NonexistentEmailReturnsNilWithoutSending(t *testing.T) { + // Enumeration-avoidance: must return the same nil as a real + // account, and must never call the sender for an address with no + // account behind it. + users, verifications, audit, ids, tokenGen, sender, limiter := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + + err := RequestMagicLink(ctx, users, verifications, sender, tokenGen, ids, limiter, audit, log, "nobody@example.com", "1.2.3.4") + if err != nil { + t.Errorf("expected nil error for a nonexistent email, got %v", err) + } + if sender.calls != 0 { + t.Errorf("expected the sender to never be called for a nonexistent email, got %d calls", sender.calls) + } +} + +func TestCompleteMagicLink_ValidTokenIssuesTokens(t *testing.T) { + users, verifications, audit, ids, tokenGen, sender, limiter := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", "hash")) + if err := RequestMagicLink(ctx, users, verifications, sender, tokenGen, ids, limiter, audit, log, "raymondproguy@dev.com", "1.2.3.4"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + tokens, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tokens.AccessToken == "" || tokens.RefreshToken == "" { + t.Error("expected both tokens to be populated") + } +} + +func TestCompleteMagicLink_TokenIsSingleUse(t *testing.T) { + users, verifications, audit, ids, tokenGen, sender, limiter := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", "hash")) + RequestMagicLink(ctx, users, verifications, sender, tokenGen, ids, limiter, audit, log, "raymondproguy@dev.com", "1.2.3.4") + + if _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent"); err != nil { + t.Fatalf("unexpected error on first use: %v", err) + } + + _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent") + if err != ErrVerificationTokenInvalid { + t.Errorf("expected ErrVerificationTokenInvalid on reuse, got %v", err) + } +} + +func TestCompleteMagicLink_ExpiredTokenRejected(t *testing.T) { + users, verifications, audit, ids, _, _, _ := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + tokenGen, _ := token.NewCryptoRandTokenGenerator(32) + + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", "hash")) + + rawToken, _ := tokenGen.New() + id, _ := ids.New() + verifications.Create(ctx, store.VerificationToken{ + ID: id, + UserID: "user-1", + Purpose: store.PurposeMagicLink, + TokenHash: token.HashToken(rawToken), + ExpiresAt: time.Now().Add(-1 * time.Minute), // already expired + }) + + _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, rawToken, "1.2.3.4", "test-agent") + if err != ErrVerificationTokenExpired { + t.Errorf("expected ErrVerificationTokenExpired, got %v", err) + } +} + +func TestCompleteMagicLink_WrongPurposeTokenRejected(t *testing.T) { + // A token minted for a different purpose (e.g. email change) must + // never be usable to log in, even if someone got hold of its raw + // value — the Purpose check is what enforces that separation. + users, verifications, audit, ids, _, _, _ := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + tokenGen, _ := token.NewCryptoRandTokenGenerator(32) + + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", "hash")) + + rawToken, _ := tokenGen.New() + id, _ := ids.New() + verifications.Create(ctx, store.VerificationToken{ + ID: id, + UserID: "user-1", + Purpose: store.PurposeEmailChange, + TokenHash: token.HashToken(rawToken), + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + + _, err := CompleteMagicLink(ctx, users, sessions, verifications, nil, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, rawToken, "1.2.3.4", "test-agent") + if err != ErrVerificationTokenInvalid { + t.Errorf("expected ErrVerificationTokenInvalid for a wrong-purpose token, got %v", err) + } +} + +func TestCompleteMagicLink_AccountWithTOTPPausesForSecondFactor(t *testing.T) { + users, verifications, audit, ids, tokenGen, sender, limiter := newMagicLinkTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + totpStore := memory.NewTOTPStore() + totpGen := security.NewPquernaTOTPGenerator() + enc, _ := security.NewAESGCMEncryptor("test-encryption-key") + + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", "hash")) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + + RequestMagicLink(ctx, users, verifications, sender, tokenGen, ids, limiter, audit, log, "raymondproguy@dev.com", "1.2.3.4") + + _, err := CompleteMagicLink(ctx, users, sessions, verifications, totpStore, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, sender.rawToken, "1.2.3.4", "test-agent") + + var secondFactor *ErrSecondFactorRequired + if !errors.As(err, &secondFactor) { + t.Fatalf("expected *ErrSecondFactorRequired, got %v", err) + } + if len(secondFactor.Methods) != 1 || secondFactor.Methods[0] != "totp" { + t.Errorf("expected Methods == [\"totp\"], got %v", secondFactor.Methods) + } +} diff --git a/auth/mfa.go b/auth/mfa.go index e9cc0d2..c36dbfc 100644 --- a/auth/mfa.go +++ b/auth/mfa.go @@ -195,5 +195,5 @@ func CompleteLoginWithTOTP( return Tokens{}, ErrInvalidTOTPCode } - return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, user, callerIP, userAgent, "totp") + return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, user, callerIP, userAgent, "totp", nil) } diff --git a/auth/oauth.go b/auth/oauth.go index f3ae201..e0bfd4e 100644 --- a/auth/oauth.go +++ b/auth/oauth.go @@ -15,8 +15,11 @@ import ( // identity. The engine never talks to Google/GitHub itself, and never // performs an HTTP redirect — by the time this is called, the OAuth // dance is already over. provider is a plain string ("google", -// "github"); externalID is the provider's own stable user ID, never -// its email. +// "github", or any other — the engine has no fixed list, adding a new +// provider is entirely api's job: register the app, implement its +// redirect/callback/token-exchange, then call this with its own +// provider string); externalID is the provider's own stable user ID, +// never its email. // // Three outcomes: // 1. An OAuthIdentity already exists for (provider, externalID) -> @@ -29,14 +32,26 @@ import ( // link is created. // 3. Neither -> create a new User and OAuthIdentity, then issue a // session, same as a fresh signup. +// +// Either way, session issuance routes through the same +// completePrimaryAuth gate password/magic-link login use — an +// account with TOTP/a passkey enrolled pauses with +// *ErrSecondFactorRequired here too. Confirming an OAuth identity +// proves the primary factor, exactly like a correct password; it was +// never meant to bypass a confirmed second one, and until now it +// accidentally did. func LoginWithOAuth( ctx context.Context, users store.UserStore, oauth store.OAuthStore, sessions store.SessionStore, + totpStore store.TOTPStore, + webauthnStore store.WebAuthnCredentialStore, + recoveryCodeStore store.RecoveryCodeStore, ids security.IDGenerator, refreshGen token.TokenGenerator, jwtIssuer *token.JWTIssuer, + pendingIssuer *token.MFAPendingIssuer, audit store.AuditStore, log logger.Logger, provider string, @@ -99,45 +114,11 @@ func LoginWithOAuth( return Tokens{}, err } - sessionID, err := ids.New() - if err != nil { - return Tokens{}, err - } - - rawRefresh, err := refreshGen.New() - if err != nil { - return Tokens{}, err - } - - session := store.Session{ - ID: sessionID, - FamilyID: sessionID, - UserID: identity.UserID, - TokenHash: token.HashToken(rawRefresh), - IP: callerIP, - UserAgent: userAgent, - } - if err := sessions.Create(ctx, session); err != nil { - return Tokens{}, err - } - - accessToken, err := jwtIssuer.Issue(identity.UserID) + user, err := users.GetByID(ctx, identity.UserID) if err != nil { return Tokens{}, err } - - if err := audit.Record(ctx, store.AuditEvent{ - Type: store.EventLoginSuccess, - UserID: identity.UserID, - IP: callerIP, - Metadata: map[string]string{"provider": provider}, - }); err != nil { - log.Error("oauth: audit record failed", map[string]string{"error": err.Error(), "user_id": identity.UserID}) - } - - log.Info("oauth: login completed", map[string]string{"user_id": identity.UserID, "provider": provider}) - - return Tokens{AccessToken: accessToken, RefreshToken: rawRefresh}, nil + return completePrimaryAuth(ctx, sessions, totpStore, webauthnStore, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, user, callerIP, userAgent, map[string]string{"provider": provider}) } // LinkOAuthIdentity attaches a confirmed external identity to an diff --git a/auth/oauth_second_factor_test.go b/auth/oauth_second_factor_test.go new file mode 100644 index 0000000..a4094ee --- /dev/null +++ b/auth/oauth_second_factor_test.go @@ -0,0 +1,85 @@ +package auth + +import ( + "context" + "errors" + "testing" + + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/crydensync/cryden/v2/token" +) + +func TestLoginWithOAuth_AccountWithTOTPPausesForSecondFactor(t *testing.T) { + // Regression test: LoginWithOAuth used to do its own inline + // session issuance, bypassing the second-factor gate entirely — + // an account with TOTP/a passkey enrolled would log straight in + // via a linked OAuth identity with no second-factor check at all. + users, oauth, sessions, audit, ids, refreshGen, jwtIssuer := newOAuthTestDeps(t) + totpStore := memory.NewTOTPStore() + totpGen := security.NewPquernaTOTPGenerator() + enc, _ := security.NewAESGCMEncryptor("test-encryption-key") + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + log := testLogger{} + ctx := context.Background() + + // First OAuth login creates the account (no second factor exists yet). + _, err := LoginWithOAuth(ctx, users, oauth, sessions, totpStore, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, + "google", "google-ext-id-1", "raymondproguy@dev.com", "1.2.3.4", "test-agent") + if err != nil { + t.Fatalf("unexpected error on first login: %v", err) + } + + identity, err := oauth.GetByProviderID(ctx, "google", "google-ext-id-1") + if err != nil { + t.Fatalf("failed to look up the created identity: %v", err) + } + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, identity.UserID) + + // Second OAuth login for the same identity — now with TOTP + // enrolled and confirmed — must pause instead of logging straight in. + tokens, err := LoginWithOAuth(ctx, users, oauth, sessions, totpStore, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, + "google", "google-ext-id-1", "raymondproguy@dev.com", "1.2.3.4", "test-agent") + + var secondFactor *ErrSecondFactorRequired + if !errors.As(err, &secondFactor) { + t.Fatalf("expected *ErrSecondFactorRequired, got %v", err) + } + if tokens.AccessToken != "" { + t.Error("expected no access token to be issued before the second factor is completed") + } + if len(secondFactor.Methods) != 1 || secondFactor.Methods[0] != "totp" { + t.Errorf("expected Methods == [\"totp\"], got %v", secondFactor.Methods) + } +} + +func TestLoginWithOAuth_AuditRecordsProvider(t *testing.T) { + // The pre-refactor code tagged the login_success audit event with + // which provider was used — confirms that detail survived moving + // session issuance into the shared completePrimaryAuth helper. + users, oauth, sessions, audit, ids, refreshGen, jwtIssuer := newOAuthTestDeps(t) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + log := testLogger{} + ctx := context.Background() + + _, err := LoginWithOAuth(ctx, users, oauth, sessions, nil, nil, nil, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, + "github", "github-ext-id-1", "raymondproguy@dev.com", "1.2.3.4", "test-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + events, err := audit.SearchByType(ctx, store.EventLoginSuccess, 10) + if err != nil { + t.Fatalf("unexpected error searching audit events: %v", err) + } + found := false + for _, e := range events { + if e.Metadata["provider"] == "github" { + found = true + } + } + if !found { + t.Error("expected a login_success audit event tagged with provider=github") + } +} diff --git a/auth/oauth_test.go b/auth/oauth_test.go index 4772435..48abdd1 100644 --- a/auth/oauth_test.go +++ b/auth/oauth_test.go @@ -29,7 +29,7 @@ func TestLoginWithOAuth_NewIdentityCreatesUserAndSession(t *testing.T) { log := testLogger{} ctx := context.Background() - tokens, err := LoginWithOAuth(ctx, users, oauth, sessions, ids, refreshGen, jwtIssuer, audit, log, + tokens, err := LoginWithOAuth(ctx, users, oauth, sessions, nil, nil, nil, ids, refreshGen, jwtIssuer, nil, audit, log, "google", "google-ext-id-1", "proguy@example.com", "1.2.3.4", "test-agent") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -62,7 +62,7 @@ func TestLoginWithOAuth_ExistingLinkIssuesSession(t *testing.T) { ID: "identity-1", UserID: "user-1", Provider: "github", ExternalID: "gh-ext-id-1", Email: "devray@example.com", }) - tokens, err := LoginWithOAuth(ctx, users, oauth, sessions, ids, refreshGen, jwtIssuer, audit, log, + tokens, err := LoginWithOAuth(ctx, users, oauth, sessions, nil, nil, nil, ids, refreshGen, jwtIssuer, nil, audit, log, "github", "gh-ext-id-1", "devray@example.com", "1.2.3.4", "test-agent") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -90,7 +90,7 @@ func TestLoginWithOAuth_EmailConflictWithPasswordAccountIsRejected(t *testing.T) users.Create(ctx, storeUser("user-1", "proguy@example.com", "some-password-hash")) - _, err := LoginWithOAuth(ctx, users, oauth, sessions, ids, refreshGen, jwtIssuer, audit, log, + _, err := LoginWithOAuth(ctx, users, oauth, sessions, nil, nil, nil, ids, refreshGen, jwtIssuer, nil, audit, log, "google", "google-ext-id-2", "proguy@example.com", "1.2.3.4", "test-agent") var conflict *ErrOAuthEmailConflict diff --git a/auth/recoverycodes.go b/auth/recoverycodes.go new file mode 100644 index 0000000..44718ca --- /dev/null +++ b/auth/recoverycodes.go @@ -0,0 +1,188 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "strings" + + "github.com/crydensync/cryden/v2/logger" + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/token" +) + +// recoveryCodeCount is how many codes a single generation produces — +// fixed, not configurable, same reasoning as the other MFA constants: +// this is a security parameter with an established convention (most +// systems ship 8-10), not something worth exposing as a knob. +const recoveryCodeCount = 10 + +// recoveryCodeByteLength is 8 bytes (64 bits) per code — generated +// directly via crypto/rand rather than through TokenGenerator, which +// enforces a 128-bit minimum meant for session/refresh tokens and +// rejects anything shorter. That minimum doesn't apply here: a +// recovery code is short and human-typeable by design, single-use, +// and each attempt already goes through the same rate limiting as any +// other login attempt — 64 bits is the right tradeoff for this +// specific use case, not a relaxation of the session-token bar. +const recoveryCodeByteLength = 8 + +var ( + // ErrNoSecondFactorEnrolled is returned by GenerateRecoveryCodes + // if the account has no confirmed TOTP secret and no registered + // passkey — recovery codes exist to recover access to a second + // factor, generating them for an account with none would be + // meaningless (Login would never pause to ask for one). + ErrNoSecondFactorEnrolled = errors.New("auth: no second factor is enrolled for this account") + // ErrInvalidRecoveryCode covers a wrong code, an already-used one, + // or an account with none generated at all — deliberately not + // differentiated further, same enumeration-avoidance reasoning as + // ErrInvalidTOTPCode. + ErrInvalidRecoveryCode = errors.New("auth: invalid or already-used recovery code") +) + +// GenerateRecoveryCodes creates a fresh batch of recoveryCodeCount +// single-use fallback codes for an already-authenticated user, +// replacing any existing batch — every previous code, used or not, +// stops working the moment a new batch is generated. The raw codes +// are returned exactly once here; the engine only ever stores their +// hashes and can never show them again afterward, same one-time- +// display convention as almost every real system that ships these. +// Requires the account to already have a confirmed TOTP secret or a +// registered passkey — see ErrNoSecondFactorEnrolled. +func GenerateRecoveryCodes( + ctx context.Context, + totpStore store.TOTPStore, + webauthnStore store.WebAuthnCredentialStore, + recoveryCodeStore store.RecoveryCodeStore, + audit store.AuditStore, + log logger.Logger, + userID string, +) ([]string, error) { + hasSecondFactor := false + if totpStore != nil { + secretRec, err := totpStore.GetByUserID(ctx, userID) + if err == nil && secretRec.ConfirmedAt != nil { + hasSecondFactor = true + } + } + if !hasSecondFactor && webauthnStore != nil { + creds, err := webauthnStore.ListByUser(ctx, userID) + if err == nil && len(creds) > 0 { + hasSecondFactor = true + } + } + if !hasSecondFactor { + return nil, ErrNoSecondFactorEnrolled + } + + rawCodes := make([]string, recoveryCodeCount) + toStore := make([]store.RecoveryCode, recoveryCodeCount) + for i := range rawCodes { + formatted, err := generateRecoveryCode() + if err != nil { + return nil, err + } + rawCodes[i] = formatted + toStore[i] = store.RecoveryCode{CodeHash: hashRecoveryCode(formatted)} + } + + if err := recoveryCodeStore.ReplaceAll(ctx, userID, toStore); err != nil { + return nil, err + } + + if err := audit.Record(ctx, store.AuditEvent{ + Type: store.EventRecoveryCodesGenerated, + UserID: userID, + }); err != nil { + log.Error("generate recovery codes: audit record failed", map[string]string{"error": err.Error(), "user_id": userID}) + } + + log.Info("recovery codes generated", map[string]string{"user_id": userID}) + return rawCodes, nil +} + +// CompleteLoginWithRecoveryCode finishes a login that Login (or +// magic-link/OAuth login) paused with *ErrSecondFactorRequired, using +// one of the account's recovery codes instead of TOTP/a passkey. Each +// code works exactly once. +func CompleteLoginWithRecoveryCode( + ctx context.Context, + users store.UserStore, + sessions store.SessionStore, + recoveryCodeStore store.RecoveryCodeStore, + ids security.IDGenerator, + refreshGen token.TokenGenerator, + jwtIssuer *token.JWTIssuer, + pendingIssuer *token.MFAPendingIssuer, + audit store.AuditStore, + log logger.Logger, + pendingToken string, + code string, + callerIP string, + userAgent string, +) (Tokens, error) { + userID, err := pendingIssuer.Verify(pendingToken) + if err != nil { + return Tokens{}, ErrInvalidPendingLogin + } + + if err := recoveryCodeStore.Consume(ctx, userID, hashRecoveryCode(code)); err != nil { + if auditErr := audit.Record(ctx, store.AuditEvent{ + Type: store.EventRecoveryCodeFailed, + UserID: userID, + IP: callerIP, + }); auditErr != nil { + log.Error("complete recovery code login: audit record failed", map[string]string{"error": auditErr.Error()}) + } + return Tokens{}, ErrInvalidRecoveryCode + } + + user, err := users.GetByID(ctx, userID) + if err != nil { + return Tokens{}, err + } + + if err := audit.Record(ctx, store.AuditEvent{ + Type: store.EventRecoveryCodeUsed, + UserID: userID, + IP: callerIP, + }); err != nil { + log.Error("complete recovery code login: audit record failed", map[string]string{"error": err.Error(), "user_id": userID}) + } + + return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, user, callerIP, userAgent, "recovery_code", nil) +} + +// hashRecoveryCode normalizes user input (case, surrounding +// whitespace, and the dash separators) before hashing, since people +// will retype these by hand and the formatting ("a1b2-c3d4-e5f6-a7b8") +// is just for readability, not part of the actual secret value. +func hashRecoveryCode(raw string) string { + normalized := strings.ToLower(strings.TrimSpace(raw)) + normalized = strings.ReplaceAll(normalized, "-", "") + return token.HashToken(normalized) +} + +// generateRecoveryCode produces one recoveryCodeByteLength-byte random +// value via crypto/rand, hex-encoded and grouped into dash-separated +// 4-character blocks for readability (e.g. "a1b2-c3d4-e5f6-a7b8") — +// purely cosmetic, stripped again by hashRecoveryCode before hashing. +func generateRecoveryCode() (string, error) { + buf := make([]byte, recoveryCodeByteLength) + if _, err := rand.Read(buf); err != nil { + return "", err + } + hexStr := hex.EncodeToString(buf) + var groups []string + for i := 0; i < len(hexStr); i += 4 { + end := i + 4 + if end > len(hexStr) { + end = len(hexStr) + } + groups = append(groups, hexStr[i:end]) + } + return strings.Join(groups, "-"), nil +} diff --git a/auth/recoverycodes_test.go b/auth/recoverycodes_test.go new file mode 100644 index 0000000..b0c795d --- /dev/null +++ b/auth/recoverycodes_test.go @@ -0,0 +1,253 @@ +package auth + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/crydensync/cryden/v2/token" +) + +func newRecoveryCodeTestDeps(t *testing.T) (*memory.UserStore, *memory.TOTPStore, *memory.RecoveryCodeStore, *memory.AuditStore, security.TOTPGenerator, security.Encryptor) { + t.Helper() + users := memory.NewUserStore() + totpStore := memory.NewTOTPStore() + recoveryCodeStore := memory.NewRecoveryCodeStore() + audit := memory.NewAuditStore() + totpGen := security.NewPquernaTOTPGenerator() + enc, _ := security.NewAESGCMEncryptor("test-encryption-key") + return users, totpStore, recoveryCodeStore, audit, totpGen, enc +} + +func TestGenerateRecoveryCodes_RejectsAccountWithNoSecondFactor(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, _, _ := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + + hasher, _ := security.NewBcryptHasher(4) + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + + _, err := GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + if err != ErrNoSecondFactorEnrolled { + t.Errorf("expected ErrNoSecondFactorEnrolled, got %v", err) + } +} + +func TestGenerateRecoveryCodes_ProducesTenUniqueCodes(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + ctx := context.Background() + + hasher, _ := security.NewBcryptHasher(4) + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + + codes, err := GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, testLogger{}, "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(codes) != recoveryCodeCount { + t.Fatalf("expected %d codes, got %d", recoveryCodeCount, len(codes)) + } + seen := make(map[string]bool) + for _, c := range codes { + if seen[c] { + t.Errorf("duplicate code generated: %q", c) + } + seen[c] = true + } + + count, err := recoveryCodeStore.CountUnused(ctx, "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count != recoveryCodeCount { + t.Errorf("expected %d unused codes stored, got %d", recoveryCodeCount, count) + } +} + +func TestGenerateRecoveryCodes_RegeneratingInvalidatesThePreviousBatch(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + + hasher, _ := security.NewBcryptHasher(4) + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + + firstBatch, _ := GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + _, err := GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + if err != nil { + t.Fatalf("unexpected error regenerating: %v", err) + } + + // A code from the first batch must no longer be consumable. + err = recoveryCodeStore.Consume(ctx, "user-1", hashRecoveryCode(firstBatch[0])) + if err == nil { + t.Error("expected a code from the invalidated first batch to be rejected") + } +} + +func TestCompleteLoginWithRecoveryCode_ValidCodeIssuesTokensAndIsSingleUse(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + ids := security.NewUUIDv7Generator() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + + hasher, _ := security.NewBcryptHasher(4) + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + codes, _ := GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + + pendingToken, _ := pendingIssuer.Issue("user-1") + + tokens, err := CompleteLoginWithRecoveryCode(ctx, users, sessions, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, pendingToken, codes[0], "1.2.3.4", "test-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tokens.AccessToken == "" || tokens.RefreshToken == "" { + t.Error("expected both tokens to be populated") + } + + // The same code must not work a second time. + pendingToken2, _ := pendingIssuer.Issue("user-1") + _, err = CompleteLoginWithRecoveryCode(ctx, users, sessions, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, pendingToken2, codes[0], "1.2.3.4", "test-agent") + if err != ErrInvalidRecoveryCode { + t.Errorf("expected ErrInvalidRecoveryCode on reuse, got %v", err) + } +} + +func TestCompleteLoginWithRecoveryCode_CaseAndWhitespaceInsensitive(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + ids := security.NewUUIDv7Generator() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + + hasher, _ := security.NewBcryptHasher(4) + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + codes, _ := GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + + pendingToken, _ := pendingIssuer.Issue("user-1") + messyInput := " " + strings.ToUpper(codes[0]) + " " + + _, err := CompleteLoginWithRecoveryCode(ctx, users, sessions, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, pendingToken, messyInput, "1.2.3.4", "test-agent") + if err != nil { + t.Errorf("expected an uppercased/padded code to still work, got %v", err) + } +} + +func TestCompleteLoginWithRecoveryCode_WrongCodeRejected(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + ids := security.NewUUIDv7Generator() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + + hasher, _ := security.NewBcryptHasher(4) + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + + pendingToken, _ := pendingIssuer.Issue("user-1") + _, err := CompleteLoginWithRecoveryCode(ctx, users, sessions, recoveryCodeStore, ids, refreshGen, jwtIssuer, pendingIssuer, audit, log, pendingToken, "wrong-code", "1.2.3.4", "test-agent") + if err != ErrInvalidRecoveryCode { + t.Errorf("expected ErrInvalidRecoveryCode, got %v", err) + } +} + +func TestLogin_RecoveryCodeNeverAdvertisedWithoutARealSecondFactor(t *testing.T) { + // The critical safety property: unconsumed recovery codes must + // never become a standalone login gate on their own. Simulates an + // account that has recovery codes in storage (e.g. left over from + // before TOTP was disabled) but no confirmed TOTP/passkey. + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + webauthnStore := memory.NewWebAuthnStore() + ids := security.NewUUIDv7Generator() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + limiter := security.NewInMemoryRateLimiter(1000, time.Minute) + hasher, _ := security.NewBcryptHasher(4) + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + + // Now disable TOTP (simulated directly via store, bypassing + // DisableTOTP's password check — this test only cares about + // Login's behavior once no real factor remains). + totpStore.Delete(ctx, "user-1") + + tokens, err := Login(ctx, users, sessions, totpStore, webauthnStore, recoveryCodeStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) + if err != nil { + t.Fatalf("expected direct login once no real second factor remains, got error: %v", err) + } + if tokens.AccessToken == "" { + t.Error("expected tokens to be issued directly — leftover recovery codes must never gate login alone") + } +} + +func TestLogin_ReportsRecoveryCodeAlongsideTOTP(t *testing.T) { + users, totpStore, recoveryCodeStore, audit, totpGen, enc := newRecoveryCodeTestDeps(t) + log := testLogger{} + ctx := context.Background() + sessions := memory.NewSessionStore() + webauthnStore := memory.NewWebAuthnStore() + ids := security.NewUUIDv7Generator() + refreshGen, _ := token.NewCryptoRandTokenGenerator(32) + jwtIssuer, _ := token.NewJWTIssuer("test-secret", time.Minute) + pendingIssuer, _ := token.NewMFAPendingIssuer("test-secret") + limiter := security.NewInMemoryRateLimiter(1000, time.Minute) + hasher, _ := security.NewBcryptHasher(4) + + hash, _ := hasher.Hash("Tr0ubl3-Fr33!2026") + users.Create(ctx, storeUser("user-1", "raymondproguy@dev.com", hash)) + enrollAndConfirm(t, ctx, users, totpStore, audit, totpGen, enc, "user-1") + GenerateRecoveryCodes(ctx, totpStore, nil, recoveryCodeStore, audit, log, "user-1") + + _, err := Login(ctx, users, sessions, totpStore, webauthnStore, recoveryCodeStore, hasher, ids, refreshGen, jwtIssuer, pendingIssuer, limiter, audit, log, + "raymondproguy@dev.com", "Tr0ubl3-Fr33!2026", "1.2.3.4", "test-agent", 5, time.Minute) + + var secondFactor *ErrSecondFactorRequired + if !errors.As(err, &secondFactor) { + t.Fatalf("expected *ErrSecondFactorRequired, got %v", err) + } + hasTOTP, hasRecovery := false, false + for _, m := range secondFactor.Methods { + if m == "totp" { + hasTOTP = true + } + if m == "recovery_code" { + hasRecovery = true + } + } + if !hasTOTP || !hasRecovery { + t.Errorf("expected Methods to contain both totp and recovery_code, got %v", secondFactor.Methods) + } +} diff --git a/auth/webauthn.go b/auth/webauthn.go index d3de2f9..54659f8 100644 --- a/auth/webauthn.go +++ b/auth/webauthn.go @@ -329,5 +329,5 @@ func CompleteLoginWithWebAuthn( if err != nil { return Tokens{}, err } - return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, realUser, callerIP, userAgent, "webauthn") + return finishLogin(ctx, sessions, ids, refreshGen, jwtIssuer, audit, log, realUser, callerIP, userAgent, "webauthn", nil) } diff --git a/cmd/smoketest/magic-link/main.go b/cmd/smoketest/magic-link/main.go new file mode 100644 index 0000000..d5b67e0 --- /dev/null +++ b/cmd/smoketest/magic-link/main.go @@ -0,0 +1,176 @@ +// Command magic-link is a standalone, no-database smoke test for the +// full magic-link (passwordless) login flow: request, complete, +// single-use enforcement, expiry, and pausing for a second factor on +// an account that has one enrolled. Run with: +// +// go run ./cmd/smoketest/magic-link +package main + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "time" + + "github.com/crydensync/cryden/v2" + "github.com/crydensync/cryden/v2/auth" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/pquerna/otp/totp" +) + +const ( + email = "raymondproguy@dev.com" + password = "Tr0ubl3-Fr33!2026" +) + +var failures int + +// capturingSender stands in for a real email provider — it just +// records the last token it was asked to send, so the smoke test can +// grab it and simulate "clicking the link." +type capturingSender struct { + lastToken string + calls int +} + +func (s *capturingSender) SendMagicLink(ctx context.Context, to string, rawToken string) error { + s.lastToken = rawToken + s.calls++ + return nil +} + +func main() { + ctx := context.Background() + sender := &capturingSender{} + + engine, err := cryden.New(cryden.Config{ + JWTSecret: "smoketest-jwt-secret", + Users: memory.NewUserStore(), + Sessions: memory.NewSessionStore(), + Audit: memory.NewAuditStore(), + Verifications: memory.NewVerificationStore(), + MagicLinkSender: sender, + TOTP: memory.NewTOTPStore(), + EncryptionKey: "smoketest-encryption-key", + TOTPIssuerName: "CrydenSync Smoke Test", + }) + check("engine constructed", err) + + user, err := cryden.SignUp(ctx, engine, email, password, "1.2.3.4") + check("signed up", err) + + // 1. Requesting a link for a nonexistent email must return nil + // and never call the sender. + err = cryden.RequestMagicLink(ctx, engine, "nobody@example.com", "1.2.3.4") + check("request for nonexistent email returns nil", err) + if sender.calls != 0 { + fail(fmt.Sprintf("expected 0 sends for a nonexistent email, got %d", sender.calls)) + } else { + pass("sender never called for a nonexistent email") + } + + // 2. Requesting for a real account sends a token. + err = cryden.RequestMagicLink(ctx, engine, email, "1.2.3.4") + check("requested a magic link for a real account", err) + if sender.calls != 1 || sender.lastToken == "" { + fail("expected exactly 1 send with a non-empty token") + } else { + pass("sender received exactly 1 non-empty token") + } + firstToken := sender.lastToken + + // 3. Completing with the real token issues tokens directly (no + // second factor enrolled yet). + tokens, err := cryden.CompleteMagicLink(ctx, engine, firstToken, "1.2.3.4", "smoketest-agent") + check("completed login with the real token", err) + if tokens.AccessToken == "" || tokens.RefreshToken == "" { + fail("expected both tokens to be populated") + } else { + pass("both tokens populated") + } + + // 4. Reusing the same token must fail — single-use. + _, err = cryden.CompleteMagicLink(ctx, engine, firstToken, "1.2.3.4", "smoketest-agent") + checkExpectError("reusing the same token is rejected", err) + + // 5. A garbage token must fail. + _, err = cryden.CompleteMagicLink(ctx, engine, "not-a-real-token", "1.2.3.4", "smoketest-agent") + checkExpectError("a garbage token is rejected", err) + + // 6. Enroll and confirm TOTP, then confirm a fresh magic link + // pauses for the second factor instead of logging straight in. + otpauthURL, err := cryden.EnrollTOTP(ctx, engine, user.ID) + check("enrolled TOTP for the second-factor check", err) + secret, err := extractSecretFromURL(otpauthURL) + check("extracted TOTP secret", err) + code, err := totp.GenerateCode(secret, time.Now()) + check("generated a real TOTP code", err) + err = cryden.ConfirmTOTP(ctx, engine, user.ID, code) + check("confirmed TOTP enrollment", err) + + err = cryden.RequestMagicLink(ctx, engine, email, "1.2.3.4") + check("requested a second magic link", err) + + _, err = cryden.CompleteMagicLink(ctx, engine, sender.lastToken, "1.2.3.4", "smoketest-agent") + var secondFactor *auth.ErrSecondFactorRequired + if !errors.As(err, &secondFactor) { + fail(fmt.Sprintf("expected *auth.ErrSecondFactorRequired for an account with TOTP enrolled, got %v", err)) + } else { + pass("magic-link completion on a TOTP-enrolled account pauses for the second factor") + if len(secondFactor.Methods) != 1 || secondFactor.Methods[0] != "totp" { + fail(fmt.Sprintf("expected Methods == [\"totp\"], got %v", secondFactor.Methods)) + } else { + pass("Methods correctly reports [\"totp\"]") + } + } + + fmt.Println() + if failures == 0 { + fmt.Println("ALL CHECKS PASSED") + } else { + fmt.Printf("%d CHECK(S) FAILED\n", failures) + os.Exit(1) + } +} + +func check(step string, err error) { + if err != nil { + fail(fmt.Sprintf("%s: unexpected error: %v", step, err)) + return + } + pass(step) +} + +func checkExpectError(step string, err error) { + if err == nil { + fail(fmt.Sprintf("%s: expected an error, got nil", step)) + return + } + pass(fmt.Sprintf("%s (%v)", step, err)) +} + +func pass(step string) { + fmt.Println("✓", step) +} + +func fail(msg string) { + failures++ + fmt.Println("✗", msg) +} + +// extractSecretFromURL pulls the base32 secret out of an otpauth:// +// URL — stands in for what a real authenticator app does when it +// scans the QR code. +func extractSecretFromURL(otpauthURL string) (string, error) { + u, err := url.Parse(otpauthURL) + if err != nil { + return "", err + } + secret := u.Query().Get("secret") + if secret == "" { + return "", fmt.Errorf("no secret query param found in %q", otpauthURL) + } + return secret, nil +} diff --git a/cmd/smoketest/recovery-codes/main.go b/cmd/smoketest/recovery-codes/main.go new file mode 100644 index 0000000..3b99687 --- /dev/null +++ b/cmd/smoketest/recovery-codes/main.go @@ -0,0 +1,190 @@ +// Command recovery-codes is a standalone, no-database smoke test for +// the recovery (backup) code flow: generation, login completion, +// single-use enforcement, regeneration invalidating the previous +// batch, and — the important safety property — leftover codes never +// gating login once the real second factor is gone. Run with: +// +// go run ./cmd/smoketest/recovery-codes +package main + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "time" + + "github.com/crydensync/cryden/v2" + "github.com/crydensync/cryden/v2/auth" + "github.com/crydensync/cryden/v2/store/memory" + "github.com/pquerna/otp/totp" +) + +const ( + email = "raymondproguy@dev.com" + password = "Tr0ubl3-Fr33!2026" +) + +var failures int + +func main() { + ctx := context.Background() + + engine, err := cryden.New(cryden.Config{ + JWTSecret: "smoketest-jwt-secret", + Users: memory.NewUserStore(), + Sessions: memory.NewSessionStore(), + Audit: memory.NewAuditStore(), + TOTP: memory.NewTOTPStore(), + RecoveryCodes: memory.NewRecoveryCodeStore(), + EncryptionKey: "smoketest-encryption-key", + TOTPIssuerName: "CrydenSync Smoke Test", + }) + check("engine constructed", err) + + user, err := cryden.SignUp(ctx, engine, email, password, "1.2.3.4") + check("signed up", err) + + // 1. Generating codes before any second factor exists must fail. + _, err = cryden.GenerateRecoveryCodes(ctx, engine, user.ID) + checkExpectError("generating codes with no second factor enrolled is rejected", err) + + // 2. Enroll and confirm TOTP. + otpauthURL, err := cryden.EnrollTOTP(ctx, engine, user.ID) + check("enrolled TOTP", err) + secret, err := extractSecretFromURL(otpauthURL) + check("extracted TOTP secret", err) + code, err := totp.GenerateCode(secret, time.Now()) + check("generated a real TOTP code", err) + err = cryden.ConfirmTOTP(ctx, engine, user.ID, code) + check("confirmed TOTP enrollment", err) + + // 3. Generate a real batch of codes. + firstBatch, err := cryden.GenerateRecoveryCodes(ctx, engine, user.ID) + check("generated a batch of recovery codes", err) + if len(firstBatch) != 10 { + fail(fmt.Sprintf("expected 10 codes, got %d", len(firstBatch))) + } else { + pass("received exactly 10 codes") + } + + // 4. Login now pauses, reporting both totp and recovery_code as + // available methods. + pendingToken1, methods := requireSecondFactor(ctx, engine, "login after TOTP confirmation returns *auth.ErrSecondFactorRequired") + hasTOTP, hasRecovery := false, false + for _, m := range methods { + if m == "totp" { + hasTOTP = true + } + if m == "recovery_code" { + hasRecovery = true + } + } + if !hasTOTP || !hasRecovery { + fail(fmt.Sprintf("expected Methods to contain both totp and recovery_code, got %v", methods)) + } else { + pass("Methods correctly reports both totp and recovery_code") + } + + // 5. Complete login with a real recovery code. + realTokens, err := cryden.CompleteLoginWithRecoveryCode(ctx, engine, pendingToken1, firstBatch[0], "1.2.3.4", "smoketest-agent") + check("completed login with a real recovery code", err) + if realTokens.AccessToken == "" || realTokens.RefreshToken == "" { + fail("expected both tokens to be populated") + } else { + pass("both tokens populated") + } + + // 6. The same code must not work twice. + pendingToken2, _ := requireSecondFactor(ctx, engine, "login again requires a second factor") + _, err = cryden.CompleteLoginWithRecoveryCode(ctx, engine, pendingToken2, firstBatch[0], "1.2.3.4", "smoketest-agent") + checkExpectError("reusing the same recovery code is rejected", err) + + // 7. A wrong code must be rejected. + _, err = cryden.CompleteLoginWithRecoveryCode(ctx, engine, pendingToken2, "wrong-code", "1.2.3.4", "smoketest-agent") + checkExpectError("a wrong recovery code is rejected", err) + + // Clean up that still-pending login with a fresh unused code before continuing. + _, err = cryden.CompleteLoginWithRecoveryCode(ctx, engine, pendingToken2, firstBatch[1], "1.2.3.4", "smoketest-agent") + check("completed the pending login from step 6/7 with a fresh code", err) + + // 8. Regenerate — the old batch must be fully invalidated. + secondBatch, err := cryden.GenerateRecoveryCodes(ctx, engine, user.ID) + check("regenerated recovery codes", err) + pendingToken3, _ := requireSecondFactor(ctx, engine, "login requires a second factor before testing regeneration") + _, err = cryden.CompleteLoginWithRecoveryCode(ctx, engine, pendingToken3, firstBatch[2], "1.2.3.4", "smoketest-agent") + checkExpectError("a code from the invalidated first batch is rejected after regeneration", err) + _, err = cryden.CompleteLoginWithRecoveryCode(ctx, engine, pendingToken3, secondBatch[0], "1.2.3.4", "smoketest-agent") + check("a code from the new batch still works", err) + + // 9. Disable TOTP (the account's only real second factor) and + // confirm any leftover recovery codes stop gating login entirely + // — this is the property that actually matters. + err = cryden.DisableTOTP(ctx, engine, user.ID, password) + check("disabled TOTP", err) + + _, err = cryden.Login(ctx, engine, email, password, "1.2.3.4", "smoketest-agent") + check("login after disabling TOTP issues tokens directly — leftover recovery codes did not become a standalone backdoor", err) + + fmt.Println() + if failures == 0 { + fmt.Println("ALL CHECKS PASSED") + } else { + fmt.Printf("%d CHECK(S) FAILED\n", failures) + os.Exit(1) + } +} + +// requireSecondFactor logs in and asserts the account is correctly +// paused on *auth.ErrSecondFactorRequired, returning the pending +// token and enrolled methods. Returns "" and nil on failure rather +// than panicking, so one bad assertion doesn't crash the rest of the +// smoke test. +func requireSecondFactor(ctx context.Context, engine *cryden.Engine, step string) (string, []string) { + _, err := cryden.Login(ctx, engine, email, password, "1.2.3.4", "smoketest-agent") + var secondFactor *auth.ErrSecondFactorRequired + if !errors.As(err, &secondFactor) { + fail(fmt.Sprintf("%s: expected *auth.ErrSecondFactorRequired, got %v", step, err)) + return "", nil + } + pass(step) + return secondFactor.PendingToken, secondFactor.Methods +} + +func check(step string, err error) { + if err != nil { + fail(fmt.Sprintf("%s: unexpected error: %v", step, err)) + return + } + pass(step) +} + +func checkExpectError(step string, err error) { + if err == nil { + fail(fmt.Sprintf("%s: expected an error, got nil", step)) + return + } + pass(fmt.Sprintf("%s (%v)", step, err)) +} + +func pass(step string) { + fmt.Println("✓", step) +} + +func fail(msg string) { + failures++ + fmt.Println("✗", msg) +} + +func extractSecretFromURL(otpauthURL string) (string, error) { + u, err := url.Parse(otpauthURL) + if err != nil { + return "", err + } + secret := u.Query().Get("secret") + if secret == "" { + return "", fmt.Errorf("no secret query param found in %q", otpauthURL) + } + return secret, nil +} diff --git a/config.go b/config.go index ab3192a..6b693d1 100644 --- a/config.go +++ b/config.go @@ -26,6 +26,11 @@ type Config struct { // rather than a nil-pointer panic. Verifications store.VerificationStore EmailSender notify.EmailSender + // MagicLinkSender is optional — only required if RequestMagicLink + // is used. Requires Verifications to also be set (magic-link + // tokens reuse the same VerificationStore email-change/verify + // tokens use, distinguished by store.PurposeMagicLink). + MagicLinkSender notify.MagicLinkSender // OAuth is optional — only required if LoginWithOAuth is used. // Left unset, LoginWithOAuth returns ErrOAuthNotConfigured. OAuth store.OAuthStore @@ -71,6 +76,12 @@ type Config struct { // actually sends — a mismatch here is a common integration error, // not a security relaxation to work around casually. WebAuthnRPOrigins []string + // RecoveryCodes is optional — only required if + // GenerateRecoveryCodes / CompleteLoginWithRecoveryCode are used. + // Left unset, those facade functions return + // ErrRecoveryCodesNotConfigured and Login never advertises + // "recovery_code" as an available second-factor method. + RecoveryCodes store.RecoveryCodeStore // Optional — sensible defaults applied in New() if zero-valued. // These are tuning knobs, not security-critical secrets, so @@ -109,6 +120,9 @@ func (c *Config) validate() error { return ErrMissingWebAuthnConfig } } + if c.MagicLinkSender != nil && c.Verifications == nil { + return ErrMissingVerificationStore + } return nil } diff --git a/cryden.go b/cryden.go index 66a7f6e..8f74d91 100644 --- a/cryden.go +++ b/cryden.go @@ -33,7 +33,7 @@ func SignUp(ctx context.Context, e *Engine, email, password, callerIP string) (s // and the list of enrolled methods; complete via CompleteLoginWithTOTP // or BeginWebAuthnLogin/CompleteLoginWithWebAuthn accordingly. func Login(ctx context.Context, e *Engine, email, password, callerIP, userAgent string) (Tokens, error) { - return auth.Login(ctx, e.users, e.sessions, e.totp, e.webauthn, e.hasher, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.rateLimiter, e.audit, e.log, email, password, callerIP, userAgent, e.lockoutThreshold, e.lockoutDuration) + return auth.Login(ctx, e.users, e.sessions, e.totp, e.webauthn, e.recoveryCodes, e.hasher, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.rateLimiter, e.audit, e.log, email, password, callerIP, userAgent, e.lockoutThreshold, e.lockoutDuration) } // ChangePassword requires the caller's current password as @@ -86,7 +86,7 @@ func LoginWithOAuth(ctx context.Context, e *Engine, provider, externalID, email, if e.oauth == nil { return Tokens{}, ErrOAuthNotConfigured } - return auth.LoginWithOAuth(ctx, e.users, e.oauth, e.sessions, e.ids, e.refreshGen, e.jwtIssuer, e.audit, e.log, provider, externalID, email, callerIP, userAgent) + return auth.LoginWithOAuth(ctx, e.users, e.oauth, e.sessions, e.totp, e.webauthn, e.recoveryCodes, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.audit, e.log, provider, externalID, email, callerIP, userAgent) } // LinkOAuthIdentity attaches a confirmed external identity to an @@ -328,3 +328,62 @@ func CompleteLoginWithWebAuthn(ctx context.Context, e *Engine, pendingToken, cer } return auth.CompleteLoginWithWebAuthn(ctx, e.users, e.sessions, e.webauthn, e.webauthnProvider, e.encryptor, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.audit, e.log, pendingToken, ceremonyToken, clientResponseJSON, callerIP, userAgent) } + +// ErrMagicLinkNotConfigured is returned by RequestMagicLink and +// CompleteMagicLink if the Engine was built without +// Config.MagicLinkSender set. +var ErrMagicLinkNotConfigured = errors.New("cryden: magic-link login requires Config.MagicLinkSender (and Config.Verifications) to be set") + +// RequestMagicLink sends a passwordless login link to email, for an +// existing account only — this does not create accounts. Always +// returns nil for a nonexistent email (an email is only actually sent +// when the account exists) to avoid leaking which emails are +// registered; a genuine delivery failure for an existing account +// still propagates. +func RequestMagicLink(ctx context.Context, e *Engine, email, callerIP string) error { + if e.magicLinkSender == nil { + return ErrMagicLinkNotConfigured + } + return auth.RequestMagicLink(ctx, e.users, e.verifications, e.magicLinkSender, e.refreshGen, e.ids, e.rateLimiter, e.audit, e.log, email, callerIP) +} + +// CompleteMagicLink logs in using the raw token from a link sent by +// RequestMagicLink. Like Login, it routes through the same +// second-factor gate — an account with TOTP/a passkey enrolled pauses +// with *auth.ErrSecondFactorRequired here exactly as it would after a +// correct password, retrievable via errors.As. +func CompleteMagicLink(ctx context.Context, e *Engine, rawToken, callerIP, userAgent string) (Tokens, error) { + if e.magicLinkSender == nil { + return Tokens{}, ErrMagicLinkNotConfigured + } + return auth.CompleteMagicLink(ctx, e.users, e.sessions, e.verifications, e.totp, e.webauthn, e.recoveryCodes, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.audit, e.log, rawToken, callerIP, userAgent) +} + +// ErrRecoveryCodesNotConfigured is returned by GenerateRecoveryCodes +// and CompleteLoginWithRecoveryCode if the Engine was built without +// Config.RecoveryCodes set. +var ErrRecoveryCodesNotConfigured = errors.New("cryden: recovery codes require Config.RecoveryCodes to be set") + +// GenerateRecoveryCodes creates a fresh batch of 10 single-use +// fallback codes for an already-authenticated user, replacing any +// existing batch. The raw codes are returned exactly once — show them +// to the user immediately, the engine can never retrieve them again +// afterward. Requires the account to already have a confirmed TOTP +// secret or a registered passkey. +func GenerateRecoveryCodes(ctx context.Context, e *Engine, userID string) ([]string, error) { + if e.recoveryCodes == nil { + return nil, ErrRecoveryCodesNotConfigured + } + return auth.GenerateRecoveryCodes(ctx, e.totp, e.webauthn, e.recoveryCodes, e.audit, e.log, userID) +} + +// CompleteLoginWithRecoveryCode finishes a login that Login (or +// magic-link/OAuth login) paused with *auth.ErrSecondFactorRequired, +// using one of the account's recovery codes instead of TOTP/a +// passkey. Each code works exactly once. +func CompleteLoginWithRecoveryCode(ctx context.Context, e *Engine, pendingToken, code, callerIP, userAgent string) (Tokens, error) { + if e.recoveryCodes == nil { + return Tokens{}, ErrRecoveryCodesNotConfigured + } + return auth.CompleteLoginWithRecoveryCode(ctx, e.users, e.sessions, e.recoveryCodes, e.ids, e.refreshGen, e.jwtIssuer, e.pendingIssuer, e.audit, e.log, pendingToken, code, callerIP, userAgent) +} diff --git a/docs/testing/magic-link.md b/docs/testing/magic-link.md new file mode 100644 index 0000000..3445a0e --- /dev/null +++ b/docs/testing/magic-link.md @@ -0,0 +1,62 @@ +# Manual testing: Magic-link (passwordless) login + +## Fastest check — in-memory smoke test + +No database and no real email provider needed: + +```bash +go run ./cmd/smoketest/magic-link +``` + +Walks: requesting a link for a nonexistent email (silently returns +nil, sender never called), requesting for a real account (sender +receives the raw token), completing with the real token (issues +tokens), attempting to reuse the same token (rejected — single-use), +an expired token (rejected), and an account with TOTP enrolled pausing +with `*auth.ErrSecondFactorRequired` on completion instead of issuing +tokens directly. + +## Full check — against real Postgres + +No new migration — magic-link tokens reuse the existing +`verification_tokens` table (same one email-change confirmation +uses), distinguished by `purpose = 'magic_link'`. + +1. Set `DATABASE_URL`, `JWT_SECRET`, and implement `notify.MagicLinkSender` + against a real provider (or just print the token to your terminal + for a first pass — the interface doesn't care). +2. Call `RequestMagicLink`, grab the token from wherever your sender + implementation sent it, and call `CompleteMagicLink` with it. +3. Confirm in `psql` that a row appears in `verification_tokens` with + `purpose = 'magic_link'`, and that `used_at` gets set after + `CompleteMagicLink` succeeds — a second completion attempt with the + same raw token should fail even before checking with the database + directly. + +## Unit tests + +```bash +go test ./auth/... +``` + +Specifically relevant: `auth/magiclink_test.go` — covers sending only +for existing accounts (and never revealing which emails aren't +registered), single-use enforcement, expiry, a token from a *different* +purpose (e.g. email-change) correctly rejected as a login token, and +an account with TOTP enrolled correctly pausing for a second factor on +completion rather than logging straight in. + +## What "working" looks like, in plain terms + +- Requesting a link for an email that isn't registered behaves + identically (from the caller's point of view — same nil return) to + requesting one that is, except no email actually goes out. There's + no way to tell the two cases apart from the return value alone. +- A requested link works exactly once. A second click — or any reuse + of the same raw token — fails the same way an expired link does. +- An account with TOTP or a passkey enrolled does **not** get logged + straight in by clicking the link — it pauses for the second factor, + exactly like a correct password would. +- A token minted for something else (email-change confirmation) can + never be used to log in, even if you have its raw value — the + purpose is checked, not just "is this a valid unexpired token." diff --git a/docs/testing/recovery-codes.md b/docs/testing/recovery-codes.md new file mode 100644 index 0000000..cdf9236 --- /dev/null +++ b/docs/testing/recovery-codes.md @@ -0,0 +1,63 @@ +# Manual testing: Recovery (backup) codes + +## Fastest check — in-memory smoke test + +No database needed: + +```bash +go run ./cmd/smoketest/recovery-codes +``` + +Walks: generating codes fails for an account with no second factor +enrolled, enrolling TOTP then generating a real batch of 10 unique +codes, logging in and completing with a real code, reusing the same +code (rejected), a wrong code (rejected), regenerating invalidating +the previous batch, and — the important safety property — disabling +the account's only real second factor and confirming any leftover +recovery codes no longer gate login at all. + +## Full check — against real Postgres + +1. Apply the migration: + ```bash + psql "$DATABASE_URL" -f store/postgres/migrations/0005_recovery_codes.up.sql + ``` +2. Generate a batch for a test account with TOTP already confirmed, + note the codes, then confirm in `psql` that `recovery_codes` has 10 + rows with `used_at IS NULL`. +3. Complete a login with one of them, confirm `used_at` gets set on + exactly that row and no others. +4. Regenerate, confirm the table now only has the new 10 rows — the + old ones are gone, not just marked used. + +## Unit tests + +```bash +go test ./auth/... +``` + +Specifically relevant: `auth/recoverycodes_test.go` — covers rejecting +generation with no second factor enrolled, producing 10 unique codes, +regeneration invalidating the previous batch, single-use enforcement, +case/whitespace-insensitive matching (people retype these by hand), a +wrong code, and the two `Login`-level safety tests: `"recovery_code"` +is only ever advertised alongside a real factor (`"totp"` or +`"webauthn"`), and codes left over after disabling the real factor +never gate login on their own. + +## What "working" looks like, in plain terms + +- Generating codes for an account with no TOTP/passkey fails outright + — there's nothing for them to be a fallback for. +- The 10 codes are shown exactly once. There is no way to view them + again later — only regenerate a fresh batch (which invalidates the + old one). +- Each code works exactly once, the same way a magic link does. +- Generating a new batch kills every code from the old one immediately + — used or not. +- The property that actually matters most: if someone disables their + TOTP (or removes their only passkey) but never explicitly cleared + out their recovery codes, those codes must NOT keep working as a + standalone login gate. Confirm this directly — enroll TOTP, generate + codes, delete the TOTP secret, then log in and confirm you get + tokens straight back with no second-factor pause at all. diff --git a/engine.go b/engine.go index 0e410d8..37dcbef 100644 --- a/engine.go +++ b/engine.go @@ -14,14 +14,16 @@ import ( // functions (SignUp, Login, etc. in cryden.go). Consumers never // construct this directly — always via New(cfg). type Engine struct { - users store.UserStore - sessions store.SessionStore - audit store.AuditStore - verifications store.VerificationStore - emailSender notify.EmailSender - oauth store.OAuthStore - totp store.TOTPStore - webauthn store.WebAuthnCredentialStore + users store.UserStore + sessions store.SessionStore + audit store.AuditStore + verifications store.VerificationStore + emailSender notify.EmailSender + oauth store.OAuthStore + totp store.TOTPStore + webauthn store.WebAuthnCredentialStore + magicLinkSender notify.MagicLinkSender + recoveryCodes store.RecoveryCodeStore hasher security.Hasher ids security.IDGenerator @@ -103,6 +105,8 @@ func New(cfg Config) (*Engine, error) { oauth: cfg.OAuth, totp: cfg.TOTP, webauthn: cfg.WebAuthn, + magicLinkSender: cfg.MagicLinkSender, + recoveryCodes: cfg.RecoveryCodes, hasher: hasher, ids: security.NewUUIDv7Generator(), rateLimiter: security.NewInMemoryRateLimiter(cfg.RateLimitAttempts, cfg.RateLimitWindow), diff --git a/errors.go b/errors.go index 495ecfe..485ab3e 100644 --- a/errors.go +++ b/errors.go @@ -18,4 +18,9 @@ var ( // has a safe default (RPID especially: guessing wrong binds every // registered passkey to the wrong domain). ErrMissingWebAuthnConfig = errors.New("cryden: WebAuthnRPID, WebAuthnRPDisplayName, and WebAuthnRPOrigins are all required when Config.WebAuthn is set") + // ErrMissingVerificationStore is returned by New if + // Config.MagicLinkSender is set but Config.Verifications isn't — + // magic-link tokens are stored there, alongside email-change + // tokens, distinguished by purpose. + ErrMissingVerificationStore = errors.New("cryden: Config.Verifications is required when Config.MagicLinkSender is set") ) diff --git a/go.mod b/go.mod index ccd1b6f..55a84b2 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,18 @@ require ( github.com/google/uuid v1.6.0 github.com/lib/pq v1.12.3 github.com/pquerna/otp v1.5.0 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.55.0 +) + +require ( + github.com/fxamacker/cbor/v2 v2.9.3 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.3.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/sys v0.47.0 // indirect ) // github.com/pquerna/otp pulls in boombuler/barcode transitively (used diff --git a/go.sum b/go.sum index dd355d6..0224791 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,44 @@ +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/descope/virtualwebauthn v1.0.5 h1:fMXji5UMepJC51Ge6d4v5IAjiJQRKmXE9hlo/B9SczQ= +github.com/descope/virtualwebauthn v1.0.5/go.mod h1:lLCfN+DpCM3iisM4bCILZlFEWkC1Zo7ZgsxC45CUapI= +github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= +github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.18.0 h1:PC8R3PNLEmjZf++WwcQlo1Z39S9rf8ma69rlwkypZhA= +github.com/go-webauthn/webauthn v0.18.0/go.mod h1:ymzZQhx3D/PrDjznemBdQJ23gHTaSDxUchM7sH1lUCg= +github.com/go-webauthn/x v0.3.0 h1:Q2X9vbrlP0Ed+QGEzixh1hthGZlDnzVT0XH/9IIQ0kE= +github.com/go-webauthn/x v0.3.0/go.mod h1:5OkdSQdOy7taRXWqvNHggtaPffmW94ybu3rZEER4I+I= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/notify/magic_link_sender.go b/notify/magic_link_sender.go new file mode 100644 index 0000000..8e10e0c --- /dev/null +++ b/notify/magic_link_sender.go @@ -0,0 +1,19 @@ +package notify + +import "context" + +// MagicLinkSender delivers passwordless login links. Deliberately a +// separate interface from EmailSender rather than a new method added +// to it — EmailSender already shipped in an earlier release, and +// adding a required method to an existing interface would break every +// consuming app's existing implementation at compile time. The two +// are also genuinely different concerns for the app to word +// differently: "confirm your new email" reads nothing like "click to +// log in," and EmailSender.SendVerification has no way to signal +// which one it's sending. +type MagicLinkSender interface { + // SendMagicLink delivers rawToken to `to`. As with + // EmailSender.SendVerification, building the actual clickable URL + // is the caller's job — the engine doesn't know your routing. + SendMagicLink(ctx context.Context, to string, rawToken string) error +} diff --git a/store/interfaces.go b/store/interfaces.go index a70ce70..92a8294 100644 --- a/store/interfaces.go +++ b/store/interfaces.go @@ -126,6 +126,10 @@ const ( EventWebAuthnRegistered AuditEventType = "webauthn_registered" EventWebAuthnRemoved AuditEventType = "webauthn_removed" EventWebAuthnChallengeFailed AuditEventType = "webauthn_challenge_failed" + EventMagicLinkRequested AuditEventType = "magic_link_requested" + EventRecoveryCodesGenerated AuditEventType = "recovery_codes_generated" + EventRecoveryCodeUsed AuditEventType = "recovery_code_used" + EventRecoveryCodeFailed AuditEventType = "recovery_code_failed" ) // AuditEvent is a single security-relevant, queryable record. @@ -164,6 +168,12 @@ type VerificationPurpose string const ( PurposeEmailVerify VerificationPurpose = "email_verify" PurposeEmailChange VerificationPurpose = "email_change" + // PurposeMagicLink marks a token as a passwordless login link — a + // separate purpose from PurposeEmailVerify even though both are + // "click a link in your email": GetByTokenHash's Purpose check is + // what stops a leaked/guessed email-verification link from ever + // being replayed as a login link, or vice versa. + PurposeMagicLink VerificationPurpose = "magic_link" ) // VerificationToken represents a single-use, expiring token sent to an @@ -271,3 +281,44 @@ type WebAuthnCredentialStore interface { Update(ctx context.Context, cred WebAuthnCredential) error Delete(ctx context.Context, userID string, credentialID []byte) error } + +// RecoveryCode is one single-use fallback code for accounts with a +// second factor enrolled. CodeHash uses the same fast SHA-256 hash as +// refresh tokens (token.HashToken) rather than bcrypt — a recovery +// code is a high-entropy random value, not a user-chosen secret, so +// there's no weak-guessing risk a slow hash would defend against; the +// only way to find one is to already have it. +type RecoveryCode struct { + UserID string + CodeHash string + UsedAt *time.Time + CreatedAt time.Time +} + +// RecoveryCodeStore defines persistence for a user's batch of +// recovery codes. +type RecoveryCodeStore interface { + // ReplaceAll wipes any existing codes for userID and inserts + // codes as the new complete batch — generating a fresh set always + // invalidates every previous one, there's no way to add codes to + // an existing batch incrementally. + ReplaceAll(ctx context.Context, userID string, codes []RecoveryCode) error + // CountUnused is used to decide whether "recovery_code" belongs + // in Login's Methods list — cheaper than fetching and hashing + // every code just to check whether any remain. + CountUnused(ctx context.Context, userID string) (int, error) + // Consume finds an unused code matching codeHash for userID and + // marks it used, atomically — the same code must never validate + // twice. Returns ErrNotFound if no matching unused code exists + // (wrong code, already used, or none generated at all). + Consume(ctx context.Context, userID string, codeHash string) error + // DeleteAll removes every code for userID. Not wired into + // DisableTOTP/DeletePasskey automatically — recovery codes are + // harmless to leave in place even with no second factor active, + // since completePrimaryAuth only ever checks for unused recovery + // codes when the account also has a confirmed TOTP secret or a + // registered passkey (see completePrimaryAuth) — they can never + // stand in as a login gate on their own. DeleteAll exists for + // hygiene, if a host app wants to clean up explicitly. + DeleteAll(ctx context.Context, userID string) error +} diff --git a/store/memory/recovery_code_store.go b/store/memory/recovery_code_store.go new file mode 100644 index 0000000..1010819 --- /dev/null +++ b/store/memory/recovery_code_store.go @@ -0,0 +1,72 @@ +package memory + +import ( + "context" + "sync" + "time" + + "github.com/crydensync/cryden/v2/store" +) + +// RecoveryCodeStore is an in-memory store.RecoveryCodeStore +// implementation for tests and local experimentation only — not a +// supported production backend. The Postgres implementation is +// authoritative for prod. +type RecoveryCodeStore struct { + mu sync.Mutex + byUserID map[string][]store.RecoveryCode +} + +func NewRecoveryCodeStore() *RecoveryCodeStore { + return &RecoveryCodeStore{byUserID: make(map[string][]store.RecoveryCode)} +} + +func (s *RecoveryCodeStore) ReplaceAll(ctx context.Context, userID string, codes []store.RecoveryCode) error { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + stored := make([]store.RecoveryCode, len(codes)) + for i, c := range codes { + c.UserID = userID + c.CreatedAt = now + c.UsedAt = nil + stored[i] = c + } + s.byUserID[userID] = stored + return nil +} + +func (s *RecoveryCodeStore) CountUnused(ctx context.Context, userID string) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + count := 0 + for _, c := range s.byUserID[userID] { + if c.UsedAt == nil { + count++ + } + } + return count, nil +} + +func (s *RecoveryCodeStore) Consume(ctx context.Context, userID string, codeHash string) error { + s.mu.Lock() + defer s.mu.Unlock() + codes := s.byUserID[userID] + for i, c := range codes { + if c.CodeHash == codeHash && c.UsedAt == nil { + now := time.Now() + codes[i].UsedAt = &now + return nil + } + } + return store.ErrNotFound +} + +func (s *RecoveryCodeStore) DeleteAll(ctx context.Context, userID string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.byUserID, userID) + return nil +} + +var _ store.RecoveryCodeStore = (*RecoveryCodeStore)(nil) diff --git a/store/postgres/migrations/0005_recovery_codes.down.sql b/store/postgres/migrations/0005_recovery_codes.down.sql new file mode 100644 index 0000000..4113379 --- /dev/null +++ b/store/postgres/migrations/0005_recovery_codes.down.sql @@ -0,0 +1,3 @@ +-- 0005_recovery_codes.down.sql + +DROP TABLE recovery_codes; diff --git a/store/postgres/migrations/0005_recovery_codes.up.sql b/store/postgres/migrations/0005_recovery_codes.up.sql new file mode 100644 index 0000000..bce66e6 --- /dev/null +++ b/store/postgres/migrations/0005_recovery_codes.up.sql @@ -0,0 +1,16 @@ +-- 0005_recovery_codes.up.sql + +CREATE TABLE recovery_codes ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- SHA-256, not bcrypt — a recovery code is a high-entropy random + -- value generated by the engine, not a user-chosen secret, so + -- there's no weak-guessing risk a slow hash would defend against. + -- Globally unique on its own (random, high-entropy), so it's the + -- primary key directly rather than introducing a separate id + -- column just to have one. + code_hash TEXT PRIMARY KEY, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_recovery_codes_user_id ON recovery_codes(user_id); diff --git a/store/postgres/recovery_code_store.go b/store/postgres/recovery_code_store.go new file mode 100644 index 0000000..9f0981d --- /dev/null +++ b/store/postgres/recovery_code_store.go @@ -0,0 +1,64 @@ +package postgres + +import ( + "context" + "database/sql" + + "github.com/crydensync/cryden/v2/store" +) + +// RecoveryCodeStore is the v2 production store.RecoveryCodeStore +// implementation. +type RecoveryCodeStore struct { + db *sql.DB +} + +func NewRecoveryCodeStore(db *sql.DB) *RecoveryCodeStore { + return &RecoveryCodeStore{db: db} +} + +func (s *RecoveryCodeStore) ReplaceAll(ctx context.Context, userID string, codes []store.RecoveryCode) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = $1`, userID); err != nil { + return err + } + for _, c := range codes { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO recovery_codes (user_id, code_hash) VALUES ($1, $2) + `, userID, c.CodeHash); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *RecoveryCodeStore) CountUnused(ctx context.Context, userID string) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM recovery_codes WHERE user_id = $1 AND used_at IS NULL + `, userID).Scan(&count) + return count, err +} + +func (s *RecoveryCodeStore) Consume(ctx context.Context, userID string, codeHash string) error { + result, err := s.db.ExecContext(ctx, ` + UPDATE recovery_codes SET used_at = now() + WHERE user_id = $1 AND code_hash = $2 AND used_at IS NULL + `, userID, codeHash) + if err != nil { + return err + } + return checkRowsAffected(result) +} + +func (s *RecoveryCodeStore) DeleteAll(ctx context.Context, userID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = $1`, userID) + return err +} + +var _ store.RecoveryCodeStore = (*RecoveryCodeStore)(nil)