From d3769b5c314795dfbba3c0fa8fe5153abb895250 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 14:47:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=B7=BB=E5=8A=A0=E6=88=96?= =?UTF-8?q?=E5=88=A0=E9=99=A4=20passkey=E3=80=81=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E5=89=8D=E9=A1=BB=205=20=E5=88=86=E9=92=9F?= =?UTF-8?q?=E5=86=85=E7=94=A8=E5=B7=B2=E6=9C=89=E5=9B=A0=E5=AD=90=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E9=AA=8C=E8=AF=81=EF=BC=8C=E5=8F=98=E6=9B=B4=E5=90=8E?= =?UTF-8?q?=E9=82=AE=E4=BB=B6=E9=80=9A=E7=9F=A5=E8=B4=A6=E6=88=B7=EF=BC=8C?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=8A=A0=E7=A1=AE=E8=AE=A4=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86=E4=B8=8E=E4=BF=AE=E6=94=B9=E9=82=AE=E7=AE=B1=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/openapi.yaml | 244 +++++++- internal/api/account_notice.go | 125 ++++ internal/api/api.go | 10 + internal/api/api_test.go | 21 +- internal/api/audit_test.go | 2 +- internal/api/auth.go | 4 + internal/api/handlers_account_migrate.go | 162 +---- .../api/handlers_account_sessions_test.go | 4 +- internal/api/handlers_auth_email.go | 2 +- internal/api/handlers_email_otp.go | 15 +- internal/api/handlers_onboard.go | 2 +- internal/api/handlers_op_login.go | 2 +- internal/api/handlers_passkey.go | 15 +- internal/api/handlers_passkey_discoverable.go | 2 +- internal/api/handlers_setup.go | 2 +- internal/api/handlers_setup_test.go | 2 +- internal/api/pgrepo.go | 23 +- internal/api/reauth.go | 416 +++++++++++++ internal/api/reauth_test.go | 557 ++++++++++++++++++ internal/api/repo.go | 9 + internal/api/session.go | 24 +- internal/pgint/sessions_test.go | 56 ++ .../store/migrations/0028_session_reauth.sql | 7 + panel/dev/mockApi.ts | 118 +++- panel/src/components/ReauthDialog.tsx | 286 +++++++++ panel/src/i18n/resources/en-US/account.json | 17 +- panel/src/i18n/resources/en-US/errors.json | 3 + panel/src/i18n/resources/zh-CN/account.json | 17 +- panel/src/i18n/resources/zh-CN/errors.json | 3 + panel/src/lib/api.test.ts | 12 + panel/src/lib/api.ts | 27 + panel/src/lib/openapi.gen.ts | 341 ++++++++++- panel/src/lib/passkey.ts | 31 + panel/src/pages/Account.tsx | 90 ++- panel/src/pages/AccountReauth.test.tsx | 303 ++++++++++ panel/src/pages/Login.tsx | 69 +-- 36 files changed, 2735 insertions(+), 288 deletions(-) create mode 100644 internal/api/account_notice.go create mode 100644 internal/api/reauth.go create mode 100644 internal/api/reauth_test.go create mode 100644 internal/store/migrations/0028_session_reauth.sql create mode 100644 panel/src/components/ReauthDialog.tsx create mode 100644 panel/src/lib/passkey.ts create mode 100644 panel/src/pages/AccountReauth.test.tsx diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 7b203aa..e3bef91 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -157,6 +157,27 @@ components: content: application/json: schema: { $ref: '#/components/schemas/Error' } + Reauthed: + description: This session is reauthed until the returned time. + content: + application/json: + schema: + type: object + required: [ok, until] + properties: + ok: { type: boolean, const: true } + until: { type: string, format: date-time } + ReauthRequired: + description: > + reauth_required: this change adds, removes or moves a way into the account, + and the account has a passkey or a verified email, so the session must have + proven one of them within the last 5 minutes. Signing in by passkey, email + code, op-login or the setup token counts; a bind-code sign-in does not. + GET /api/v1/account/reauth lists the factors that can give the proof, then + retry the change. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } MailUndeliverable: description: > The configured SMTP relay refused the message (code mail_undeliverable), so no @@ -3977,7 +3998,8 @@ paths: Generates a one-time code bound to the authenticated principal and the supplied address, persists only its hash, and delivers it out of band. The code is never returned in the response. A re-request supersedes the prior - unconsumed code. + unconsumed code. Once the account has a passkey or a verified email, the + session must have reauthed within 5 minutes (403 reauth_required). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] @@ -4008,6 +4030,8 @@ paths: schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ReauthRequired' '429': description: >- Resend requested before the cooldown elapsed (otp_resend_cooldown); or the @@ -4030,7 +4054,9 @@ paths: success the user's email is written and email_verified is set true. When the new address replaces a different verified one, every other session of the caller is signed out: sign-in codes now go to the new address, so a session - opened through the old one ends. Too many + opened through the old one ends; the old address is mailed a notice with the + new one masked. A verified code also counts as a reauth for this session. + Too many incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, @@ -4082,7 +4108,9 @@ paths: Writes the supplied address to the authenticated principal's user row and clears email_verified (already false for a fresh Owner). The setup bootstrap has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP - flow proves control of the address via /account/email/verify. + flow proves control of the address via /account/email/verify. Clearing a + verified address strips a factor, so once the account has one the session + must have reauthed within 5 minutes (403 reauth_required). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] @@ -4112,6 +4140,8 @@ paths: schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ReauthRequired' /api/v1/account/passkey/register/begin: post: @@ -4122,8 +4152,10 @@ paths: Mints a credential-creation challenge bound to the authenticated principal, stashes the server-side ceremony state under a short TTL, and returns the WebAuthn publicKey creation options for navigator.credentials.create(). The - challenge is never echoed by the client. Enrollment only — passkey login is a - deferred slice. 503 when the WebAuthn verifier is not configured on this instance. + challenge is never echoed by the client. Once the account has a passkey or a + verified email, the session must have reauthed within 5 minutes (403 + reauth_required). 503 when the WebAuthn verifier is not configured on this + instance. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] @@ -4137,6 +4169,8 @@ paths: description: Opaque WebAuthn PublicKeyCredentialCreationOptions, passed verbatim to the browser. '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ReauthRequired' '503': description: Passkey subsystem is not configured. content: @@ -4153,7 +4187,9 @@ paths: authenticator's attestation against the server-stashed ceremony state, and persists the public credential. A missing or expired ceremony is a 400; an attestation that fails verification is a 400; a credential already bound to any - account is a 409. 503 when the WebAuthn verifier is not configured. + account is a 409. The verified email is mailed a notice, and the ceremony + counts as a reauth for this session. 503 when the WebAuthn verifier is not + configured. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] @@ -4231,7 +4267,9 @@ paths: silently no-ops as success. The account's only passkey cannot be removed while its email is unverified (409 last_passkey): it is then the account's only durable way in. Removing a passkey signs out every other session of the - caller, so a session opened with that passkey ends with it. + caller, so a session opened with that passkey ends with it, and mails the + verified email a notice. The session must have reauthed within 5 minutes + (403 reauth_required). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] @@ -4246,6 +4284,8 @@ paths: description: Passkey unbound. '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ReauthRequired' '404': description: No such passkey for this caller. content: @@ -4257,6 +4297,196 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /api/v1/account/reauth: + get: + tags: [account] + operationId: reauthStatus + summary: Say whether a passkey or email change needs a reauth first, and how to give one. + description: > + needed is true when the account has a passkey or a verified email and this + session has not proven one within the last 5 minutes. until is when the + current proof stops counting. factors lists the ways this caller can + reauth, best first: passkey (an enrolled passkey), email (a player's + verified address), sign_in (an operator signs out and back in through + op-login or a passkey). + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + responses: + '200': + description: Where the caller stands. + content: + application/json: + schema: + type: object + required: [needed, factors] + properties: + needed: { type: boolean } + until: { type: string, format: date-time } + factors: + type: array + items: { type: string, enum: [passkey, email, sign_in] } + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/account/reauth/passkey/begin: + post: + tags: [account] + operationId: reauthPasskeyBegin + summary: Begin a passkey assertion that reauths this session. + description: > + Returns WebAuthn assertion request options over the caller's own passkeys, + bound to a fresh reauth-purpose challenge. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + responses: + '200': + description: WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get. + content: + application/json: + schema: { type: object, description: Opaque WebAuthn PublicKeyCredentialRequestOptions. } + '400': + description: The caller has no enrolled passkey (no_passkey), or no browser session to mark (no_session). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '503': + $ref: '#/components/responses/ServiceUnavailable' + + /api/v1/account/reauth/passkey/finish: + post: + tags: [account] + operationId: reauthPasskeyFinish + summary: Finish the passkey assertion and mark this session reauthed for 5 minutes. + description: > + Verifies the assertion against the reauth challenge with the login door's + clone check (a cloned authenticator is 400 passkey_login_invalid). + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [assertion] + properties: + assertion: + type: object + description: The navigator.credentials.get() PublicKeyCredential assertion. + responses: + '200': + $ref: '#/components/responses/Reauthed' + '400': + description: Assertion invalid, challenge stale, or a cloned authenticator (passkey_login_invalid); no browser session (no_session). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '503': + $ref: '#/components/responses/ServiceUnavailable' + + /api/v1/account/reauth/email/start: + post: + tags: [account] + operationId: reauthEmailStart + summary: Mail a reauth code to the caller's verified address. + description: > + For players with a verified email. Operators reauth with a passkey or by + signing in again (403 staff_reauth). + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + responses: + '202': + description: Code minted and dispatched. + content: + application/json: + schema: + type: object + required: [sent, expires_at] + properties: + sent: { type: boolean, const: true } + expires_at: { type: string, format: date-time } + '400': + description: No browser session to mark (no_session). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Operators cannot reauth by email (staff_reauth). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: The account has no verified email (no_step_up_factor). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '429': + description: >- + Resend requested before the cooldown elapsed (otp_resend_cooldown); or the + account's daily wrong-code budget is spent (otp_account_locked, with + Retry-After); or the install-wide mail budget is spent + (mail_rate_limited, with Retry-After). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '502': + $ref: '#/components/responses/MailUndeliverable' + + /api/v1/account/reauth/email/verify: + post: + tags: [account] + operationId: reauthEmailVerify + summary: Redeem the reauth code and mark this session reauthed for 5 minutes. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: { type: string } + responses: + '200': + $ref: '#/components/responses/Reauthed' + '400': + description: Invalid or expired code (invalid_code), or no browser session (no_session). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Operators cannot reauth by email (staff_reauth). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: The account has no verified email (no_step_up_factor). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '429': + description: >- + Too many incorrect attempts on this code (otp_locked), or the account's + daily wrong-code budget is spent (otp_account_locked, with Retry-After). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + /api/v1/account/sessions: get: tags: [account] diff --git a/internal/api/account_notice.go b/internal/api/account_notice.go new file mode 100644 index 0000000..526aef3 --- /dev/null +++ b/internal/api/account_notice.go @@ -0,0 +1,125 @@ +package api + +import ( + "fmt" + "log" + "net/http" + "strings" + "time" + + "felis.lolicon.best/internal/metrics" +) + +// Account change notices tell the owner of an account, at the verified address, +// that a way into it was just added, removed or moved: a passkey registered or +// removed, the email replaced (that notice goes to the OLD address, which is the +// one the owner still reads if someone else made the change). They carry the time +// and the source address and say what to do if the change was not theirs. +// Best effort, like the lock notice: the change already happened. + +// notifyAccountChange mails one notice to the given address. +func (a *API) notifyAccountChange(r *http.Request, to, subject, body string) { + if to == "" { + return + } + sender, ok := a.Mailer.(noticeSender) + if !ok { + log.Printf("auth: no notice mailer; account change notice %q was not sent (request_id=%s)", + subject, requestIDFromContext(r.Context())) + return + } + if ok, _ := a.mailGate().take(mailGateKey); !ok { + metrics.MailTotal.WithLabelValues("notice", "throttled").Inc() + log.Printf("auth: mail budget spent; account change notice %q was not sent (request_id=%s)", + subject, requestIDFromContext(r.Context())) + return + } + if err := sender.SendNotice(r.Context(), to, subject, body); err != nil { + metrics.MailTotal.WithLabelValues("notice", "failed").Inc() + log.Printf("auth: account change notice failed (request_id=%s): %v", requestIDFromContext(r.Context()), err) + return + } + metrics.MailTotal.WithLabelValues("notice", "sent").Inc() +} + +// verifiedEmail is where a notice about p's account goes: the address it proved, +// or nothing. +func verifiedEmail(p *Principal) string { + if !p.EmailVerified { + return "" + } + return p.Email +} + +func (a *API) notifyPasskeyAdded(r *http.Request, p *Principal) { + subject, body := accountChangeNotice( + "已添加 Passkey", "passkey added", + "你的 Felis 账户刚刚添加了一个 Passkey。", "A passkey was just added to your Felis account.", + "删除这个 Passkey", "remove that passkey", + a.now(), a.noticeIP(r)) + a.notifyAccountChange(r, verifiedEmail(p), subject, body) +} + +func (a *API) notifyPasskeyRemoved(r *http.Request, p *Principal) { + subject, body := accountChangeNotice( + "已删除 Passkey", "passkey removed", + "你的 Felis 账户刚刚删除了一个 Passkey,其它设备上的登录已全部退出。", + "A passkey was just removed from your Felis account, and every other device was signed out.", + "检查剩下的 Passkey", "check the passkeys that remain", + a.now(), a.noticeIP(r)) + a.notifyAccountChange(r, verifiedEmail(p), subject, body) +} + +// notifyEmailChanged tells the previous verified address where the account's +// mail now goes, masked so the notice does not hand the new address to whoever +// reads the old mailbox. +func (a *API) notifyEmailChanged(r *http.Request, oldEmail, newEmail string) { + masked := maskEmail(newEmail) + subject, body := accountChangeNotice( + "邮箱已更换", "email changed", + "你的 Felis 账户的邮箱刚刚更换为 "+masked+",这个地址以后不会再收到登录验证码。", + "The email on your Felis account was just changed to "+masked+". This address will no longer receive sign-in codes.", + "把邮箱改回来", "change the email back", + a.now(), a.noticeIP(r)) + a.notifyAccountChange(r, oldEmail, subject, body) +} + +func (a *API) noticeIP(r *http.Request) string { + if ip := a.clientIP(r); ip.IsValid() { + return ip.String() + } + return "" +} + +// accountChangeNotice renders a bilingual notice. zhUndo/enUndo name the step +// that reverses the change, for the "if this wasn't you" line. +func accountChangeNotice(zhTitle, enTitle, zhWhat, enWhat, zhUndo, enUndo string, at time.Time, ip string) (subject, body string) { + when := at.UTC().Format("2006-01-02 15:04 MST") + zhIP, enIP := ip, ip + if ip == "" { + zhIP, enIP = "未知", "unknown" + } + subject = "Felis " + zhTitle + " · " + enTitle + body = fmt.Sprintf(`%s +时间:%s +来源 IP:%s +如果不是你本人操作,请立即登录 Felis,在账户页%s并退出其它设备,然后联系服务器管理员。 + +%s +Time: %s +From IP: %s +If this wasn't you, sign in to Felis now, %s and sign out other devices on the Account page, then contact the server operator. +`, zhWhat, when, zhIP, zhUndo, enWhat, when, enIP, enUndo) + return subject, body +} + +// maskEmail keeps the first character of the local part and the domain: +// alice@example.com → a***@example.com. +func maskEmail(email string) string { + at := strings.LastIndexByte(email, '@') + if at <= 0 { + return "***" + } + first := []rune(email[:at])[0] + return string(first) + "***" + email[at:] +} diff --git a/internal/api/api.go b/internal/api/api.go index 45bcf6d..22d11a7 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -560,6 +560,16 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish}, {Method: "GET", Pattern: "/api/v1/account/passkey/credentials", SetupAllowed: true, h: a.handlePasskeyList}, {Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", SetupAllowed: true, h: a.handlePasskeyDelete}, + // Reauth (reauth.go): the fresh proof that passkey enrollment and removal and an + // email change require once the account has a factor. Status says whether one + // is needed and how to give it; the pairs below take a passkey assertion or an + // email code and mark the caller's session. SetupAllowed like the routes they + // unlock. + {Method: "GET", Pattern: "/api/v1/account/reauth", SetupAllowed: true, h: a.handleReauthStatus}, + {Method: "POST", Pattern: "/api/v1/account/reauth/passkey/begin", SetupAllowed: true, h: a.handleReauthPasskeyBegin}, + {Method: "POST", Pattern: "/api/v1/account/reauth/passkey/finish", SetupAllowed: true, h: a.handleReauthPasskeyFinish}, + {Method: "POST", Pattern: "/api/v1/account/reauth/email/start", SetupAllowed: true, h: a.handleReauthEmailStart}, + {Method: "POST", Pattern: "/api/v1/account/reauth/email/verify", SetupAllowed: true, h: a.handleReauthEmailVerify}, // The caller's own sessions (handlers_account_sessions.go): list every signed-in // device and sign out one or all the others. App-tier and scoped to the caller // inside the handler, like the passkey routes above. diff --git a/internal/api/api_test.go b/internal/api/api_test.go index f7fd129..d8a269d 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -75,10 +75,12 @@ type fakeRepo struct { // failSessionUser / failGetSetting force those reads to fail with a generic // (non-ErrNotFound) error, simulating a store outage for the 503 auth path. failSessionUser error - // failTouchSession / failRevokeOthers force those session writes to fail. + // failTouchSession / failRevokeOthers / failMarkReauth force those session + // writes to fail. failTouchSession error failRevokeOthers error - failGetSetting error + failMarkReauth error + failGetSetting error // player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // newest live (user, purpose) just as the PG query does. otps map[string]*fakeEmailOTP @@ -220,6 +222,8 @@ type fakeSession struct { userAgent string clientIP string touches int + // reauthAt is reauth_at: when the session last proved a factor; zero = never. + reauthAt time.Time } // fakeBackup mirrors a world_backups row: the client-facing view plus the @@ -904,7 +908,16 @@ func (f *fakeRepo) CreateSession(_ context.Context, ns NewSession) error { now := ns.ExpiresAt.Add(-sessionTTL) f.sessions[ns.TokenHash] = &fakeSession{ userID: ns.UserID, expiresAt: ns.ExpiresAt, createdAt: now, lastSeen: now, - userAgent: ns.UserAgent, clientIP: ns.ClientIP, + userAgent: ns.UserAgent, clientIP: ns.ClientIP, reauthAt: ns.ReauthAt, + } + return nil +} +func (f *fakeRepo) MarkSessionReauth(_ context.Context, tokenHash string, at time.Time) error { + if f.failMarkReauth != nil { + return f.failMarkReauth + } + if s, ok := f.sessions[tokenHash]; ok && !s.revoked { + s.reauthAt = at } return nil } @@ -951,7 +964,7 @@ func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Tim } return &SessionedUser{ ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role, - EmailVerified: u.EmailVerified, LastSeenAt: s.lastSeenAt(now), + EmailVerified: u.EmailVerified, LastSeenAt: s.lastSeenAt(now), ReauthAt: s.reauthAt, }, nil } func (f *fakeRepo) TouchSession(_ context.Context, tokenHash string, now time.Time) error { diff --git a/internal/api/audit_test.go b/internal/api/audit_test.go index 2552d1b..14bcb0d 100644 --- a/internal/api/audit_test.go +++ b/internal/api/audit_test.go @@ -43,7 +43,7 @@ func TestAuditCannotBeSignedWithAnotherPersonsEmail(t *testing.T) { repo.settings[LocalAuthEnabledKey] = []byte("true") repo.staff["owner"] = &StaffUser{ID: "u1", Username: "owner", Email: "owner@example.net", Role: "owner", EmailVerified: true} repo.staff["mallory"] = &StaffUser{ID: "u2", Username: "mallory", Email: "mallory@example.net", Role: "user", EmailVerified: true} - repo.sessions[hashCookie("tok")] = &fakeSession{userID: "u2", expiresAt: time.Unix(1_700_000_000, 0).Add(time.Hour)} + repo.sessions[hashCookie("tok")] = &fakeSession{userID: "u2", expiresAt: frozenNow.Add(time.Hour), reauthAt: frozenNow} api := newTestAPI(repo, newFakeCluster()) api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now} api.ClientIPHeader = "CF-Connecting-IP" diff --git a/internal/api/auth.go b/internal/api/auth.go index 270c351..2748f6e 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/golang-jwt/jwt/v5" ) @@ -41,6 +42,9 @@ type Principal struct { // passed Zero Trust at the edge, so the local-email-verification gate is not // the right boundary for them. ViaSession bool + // ReauthAt is when the holder of the session last proved a factor of the + // account; zero for a session that never did and for a JWT caller. + ReauthAt time.Time } // staffRole reports whether a stored user role carries staff standing: admin, diff --git a/internal/api/handlers_account_migrate.go b/internal/api/handlers_account_migrate.go index ba7f7b8..d62ab91 100644 --- a/internal/api/handlers_account_migrate.go +++ b/internal/api/handlers_account_migrate.go @@ -1,11 +1,9 @@ package api import ( - "bytes" "context" "crypto/rand" "encoding/hex" - "encoding/json" "errors" "net/http" "strings" @@ -25,7 +23,9 @@ import ( // email-OTP — advancing to 'confirmed'. Mere // session possession is never enough; a stolen // session cannot read the mailbox nor present the -// authenticator. +// authenticator, and cannot enroll one of its own +// without a recent proof of an existing factor +// (reauth.go). // 3. web issue code + name target → handleMigrateIssueCode: the source names the // target account by id and mints a one-time code // ('code_issued'). @@ -205,54 +205,7 @@ func (a *API) handleMigrateConfirmOTPStart(w http.ResponseWriter, r *http.Reques } // Per-recipient cooldown, namespaced apart from the other OTP doors so they never // perturb each other's throttle. - if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, otpPurposeMigrate, a.now()); err != nil { - writeError(w, r, err) - return - } else if !until.IsZero() { - writeOTPAccountLocked(w, r, until, a.now()) - return - } - emailKey := "migrate:confirm:" + strings.ToLower(p.Email) - lim := a.otpLimiter() - emailAt, ok := lim.reserve(emailKey, otpResendCooldown) - if !ok { - writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown", - "a code was sent recently; wait a moment before requesting another")) - return - } - committed := false - defer func() { - if !committed { - lim.release(emailKey, emailAt) - } - }() - code, err := newEmailOTP() - if err != nil { - writeError(w, r, err) - return - } - id, err := newOTPID() - if err != nil { - writeError(w, r, err) - return - } - expiresAt := a.now().Add(otpTTL) - if err := a.Repo.CreateEmailOTP(r.Context(), id, p.UserID, p.Email, otpCodeHash(code), otpPurposeMigrate, expiresAt); err != nil { - writeError(w, r, err) - return - } - if err := a.deliverOTP(r.Context(), p.Email, code); err != nil { - writeError(w, r, err) - return - } - committed = true - a.audit(r, "account.migrate.confirm_otp_sent", "") - writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()}) -} - -// migrateConfirmOTPVerifyRequest is the OTP step-up verify body: the code from the email. -type migrateConfirmOTPVerifyRequest struct { - Code string `json:"code"` + a.startStepUpOTP(w, r, p, otpPurposeMigrate, "migrate:confirm:", "account.migrate.confirm_otp_sent") } // handleMigrateConfirmOTPVerify redeems the migration step-up code and, on a match, @@ -260,7 +213,7 @@ type migrateConfirmOTPVerifyRequest struct { // is the login-door one (no identity side-effect): the address is already proven. func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) - var req migrateConfirmOTPVerifyRequest + var req stepUpOTPVerifyRequest if err := decodeJSON(w, r, &req); err != nil { writeError(w, r, err) return @@ -273,25 +226,7 @@ func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Reque if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok { return } - var lock *OTPAccountLockedError - err := a.Repo.ConsumeLoginEmailOTP(r.Context(), p.UserID, otpPurposeMigrate, otpCodeHash(code), a.now()) - if isOTPRefusal(err) { - a.authFailure(r, "migrate_confirm", otpFailureReason(err), nil) - } - switch { - case errors.As(err, &lock): - a.noteOTPLock(r, err, p.UserID, otpPurposeMigrate) - writeOTPAccountLocked(w, r, lock.Until, a.now()) - return - case errors.Is(err, ErrOTPLocked): - writeError(w, r, newError(http.StatusTooManyRequests, "otp_locked", - "too many incorrect attempts; request a new code")) - return - case errors.Is(err, ErrOTPInvalid): - writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired")) - return - case err != nil: - writeError(w, r, err) + if !a.verifyStepUpOTP(w, r, p, otpPurposeMigrate, "migrate_confirm", code) { return } if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "email_otp", a.now()); err != nil { @@ -307,17 +242,6 @@ func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusOK, map[string]any{"confirmed": true}) } -// migratePasskeyUser builds the PasskeyUser the assertion ceremony needs for the -// already-logged-in source (contrast the login door, which resolves it from a typed -// email). The credential set must be identical between begin and finish. -func migratePasskeyUser(p *Principal, creds []PasskeyCredential) PasskeyUser { - name := p.Email - if name == "" { - name = p.UserID - } - return PasskeyUser{ID: p.UserID, Name: name, DisplayName: name, Credentials: creds} -} - // handleMigrateConfirmPasskeyBegin starts a fresh passkey assertion bound to the // migration step-up (spec §B3, external app face). Unlike the login door it needs no // email — the caller is already authenticated — so it scopes the challenge to the @@ -331,39 +255,8 @@ func (a *API) handleMigrateConfirmPasskeyBegin(w http.ResponseWriter, r *http.Re if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok { return } - creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID) - if err != nil { - writeError(w, r, err) - return - } - if len(creds) == 0 { - writeError(w, r, newError(http.StatusBadRequest, "no_passkey", - "no passkey enrolled; confirm the migration with an email code")) - return - } - options, sessionData, err := a.Passkey.BeginLogin(migratePasskeyUser(p, creds)) - if err != nil { - writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed", - "could not start passkey confirmation")) - return - } - id, err := newPasskeyID() - if err != nil { - writeError(w, r, err) - return - } - expiresAt := a.now().Add(passkeyChallengeTTL) - if err := a.Repo.CreatePasskeyChallenge(r.Context(), id, p.UserID, passkeyPurposeMigrate, sessionData, expiresAt); err != nil { - writeError(w, r, err) - return - } - writeJSON(w, http.StatusOK, options) -} - -// migrateConfirmPasskeyFinishRequest is the assertion the browser produced, captured -// as raw bytes so the exact response reaches the verifier without re-encoding. -type migrateConfirmPasskeyFinishRequest struct { - Assertion json.RawMessage `json:"assertion"` + a.beginStepUpPasskey(w, r, p, passkeyPurposeMigrate, + "no passkey enrolled; confirm the migration with an email code") } // handleMigrateConfirmPasskeyFinish verifies the migration step-up assertion and, on @@ -375,7 +268,7 @@ func (a *API) handleMigrateConfirmPasskeyFinish(w http.ResponseWriter, r *http.R writeError(w, r, errPasskeyUnavailable) return } - var req migrateConfirmPasskeyFinishRequest + var req stepUpPasskeyFinishRequest if err := decodeJSON(w, r, &req); err != nil { writeError(w, r, err) return @@ -387,42 +280,7 @@ func (a *API) handleMigrateConfirmPasskeyFinish(w http.ResponseWriter, r *http.R if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok { return } - sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), p.UserID, passkeyPurposeMigrate, a.now()) - if err != nil { - if errors.Is(err, ErrPasskeyChallengeInvalid) { - a.authFailure(r, "migrate_passkey", "challenge_invalid", nil) - writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", - "passkey confirmation could not be completed; begin again")) - return - } - writeError(w, r, err) - return - } - creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID) - if err != nil { - writeError(w, r, err) - return - } - va, err := a.Passkey.FinishLogin(migratePasskeyUser(p, creds), sessionData, bytes.NewReader(req.Assertion)) - if err != nil { - a.authFailure(r, "migrate_passkey", "bad_assertion", nil) - writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", - "passkey confirmation could not be completed; begin again")) - return - } - // Same clone policy as the login door (applyAssertionCounter): a rolled-back counter - // fails closed with the opaque envelope and advances nothing, so the migrate step-up is - // never a weaker sibling that would accept an authenticator login refuses. A clean - // assertion advances the stored sign-count, keeping the clone signal meaningful for the - // next login. - if err := a.applyAssertionCounter(r.Context(), va); err != nil { - if errors.Is(err, errPasskeyClonedAuthenticator) { - a.passkeyCloneRejected(r, "migrate_passkey", nil, va.CredentialID) - writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", - "passkey confirmation could not be completed; begin again")) - return - } - writeError(w, r, err) + if !a.finishStepUpPasskey(w, r, p, passkeyPurposeMigrate, "migrate_passkey", req.Assertion) { return } if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "passkey", a.now()); err != nil { diff --git a/internal/api/handlers_account_sessions_test.go b/internal/api/handlers_account_sessions_test.go index 7dc9dc3..813ee46 100644 --- a/internal/api/handlers_account_sessions_test.go +++ b/internal/api/handlers_account_sessions_test.go @@ -35,7 +35,9 @@ func newSessionsFixture(t *testing.T) *sessionsFixture { api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now} now := api.now() for tok, s := range map[string]*fakeSession{ - laptopTok: {userID: "u1", lastSeen: now.Add(-10 * time.Minute), userAgent: "Firefox on Linux", clientIP: "203.0.113.5"}, + // The laptop signed in by a proving door a minute ago, so the guarded + // changes below run without a reauth (reauth_test.go covers the gate). + laptopTok: {userID: "u1", lastSeen: now.Add(-10 * time.Minute), userAgent: "Firefox on Linux", clientIP: "203.0.113.5", reauthAt: now.Add(-time.Minute)}, phoneTok: {userID: "u1", lastSeen: now.Add(-2 * time.Hour), userAgent: "Safari on iPhone", clientIP: "198.51.100.7"}, alexTok: {userID: "u2", lastSeen: now.Add(-time.Minute)}, } { diff --git a/internal/api/handlers_auth_email.go b/internal/api/handlers_auth_email.go index 8b53022..8c19b08 100644 --- a/internal/api/handlers_auth_email.go +++ b/internal/api/handlers_auth_email.go @@ -271,7 +271,7 @@ func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) { return } - if err := a.startSession(w, r, u.ID); err != nil { + if err := a.startSession(w, r, u.ID, provenSignIn); err != nil { writeError(w, r, err) return } diff --git a/internal/api/handlers_email_otp.go b/internal/api/handlers_email_otp.go index 8b0133d..9a600a3 100644 --- a/internal/api/handlers_email_otp.go +++ b/internal/api/handlers_email_otp.go @@ -131,6 +131,10 @@ func (a *API) handleEmailOTPStart(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required")) return } + // Gate the start: the verify only redeems a code minted here. + if !a.requireReauth(w, r, p) { + return + } // Atomically reserve the cooldown on both the caller and the recipient BEFORE // minting, so a burst of truly concurrent starts yields exactly one winner. Here // the throttle is the sole defense and each admitted send is a real, non-idempotent @@ -249,10 +253,14 @@ func (a *API) handleEmailOTPVerify(w http.ResponseWriter, r *http.Request) { return } a.audit(r, "account.email.verified", "") + // Proving the address is an email reauth. + a.markReauthQuietly(r) // Replacing a verified address moves where sign-in codes go, so a session - // opened through the old one ends. A first verification retires nothing. + // opened through the old one ends, and the old mailbox hears about it. A + // first verification retires nothing. if p.EmailVerified && !strings.EqualFold(p.Email, email) { a.revokeOtherSessionsAfter(r, "email change") + a.notifyEmailChanged(r, p.Email, email) } writeJSON(w, http.StatusOK, map[string]any{"verified": true, "email": email}) } @@ -320,6 +328,11 @@ func (a *API) handleSetEmail(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required")) return } + // Recording an address unverifies the current one, which would strip the + // account's email factor and with it the reauth that guards adding a passkey. + if !a.requireReauth(w, r, p) { + return + } if err := a.Repo.SetUserEmail(r.Context(), p.UserID, email); err != nil { writeError(w, r, err) return diff --git a/internal/api/handlers_onboard.go b/internal/api/handlers_onboard.go index 38978ef..f9d95fb 100644 --- a/internal/api/handlers_onboard.go +++ b/internal/api/handlers_onboard.go @@ -125,7 +125,7 @@ func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) { return } - if err := a.startSession(w, r, userID); err != nil { + if err := a.startSession(w, r, userID, bindCodeSignIn); err != nil { writeError(w, r, err) return } diff --git a/internal/api/handlers_op_login.go b/internal/api/handlers_op_login.go index c413276..72ccfae 100644 --- a/internal/api/handlers_op_login.go +++ b/internal/api/handlers_op_login.go @@ -326,7 +326,7 @@ func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(http.StatusForbidden, "staff_account", "that account is not an operator")) return } - if err := a.startSession(w, r, u.ID); err != nil { + if err := a.startSession(w, r, u.ID, provenSignIn); err != nil { writeError(w, r, err) return } diff --git a/internal/api/handlers_passkey.go b/internal/api/handlers_passkey.go index b0690eb..dbaa9cb 100644 --- a/internal/api/handlers_passkey.go +++ b/internal/api/handlers_passkey.go @@ -253,6 +253,10 @@ func (a *API) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) return } p := principalFromContext(r.Context()) + // Gate the begin: the finish only consumes the challenge minted here. + if !a.requireReauth(w, r, p) { + return + } creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID) if err != nil { writeError(w, r, err) @@ -356,6 +360,11 @@ func (a *API) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request return } a.audit(r, "account.passkey.registered", cred.ID) + // The session just showed an authenticator now bound to the account, the + // same strength as a passkey reauth, so the next guarded step of a first-time + // setup (verifying an email) runs without asking again. + a.markReauthQuietly(r) + a.notifyPasskeyAdded(r, p) writeJSON(w, http.StatusCreated, passkeyView(cred)) } @@ -409,6 +418,9 @@ func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "credential id is required")) return } + if !a.requireReauth(w, r, p) { + return + } if err := a.Repo.DeletePasskeyCredential(r.Context(), p.UserID, id); err != nil { if errors.Is(err, ErrNotFound) { writeError(w, r, newError(http.StatusNotFound, "not_found", "no such passkey")) @@ -424,6 +436,7 @@ func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) { } a.audit(r, "account.passkey.removed", id) a.revokeOtherSessionsAfter(r, "passkey removal") + a.notifyPasskeyRemoved(r, p) w.WriteHeader(http.StatusNoContent) } @@ -654,7 +667,7 @@ func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) { return } - if err := a.startSession(w, r, u.ID); err != nil { + if err := a.startSession(w, r, u.ID, provenSignIn); err != nil { writeError(w, r, err) return } diff --git a/internal/api/handlers_passkey_discoverable.go b/internal/api/handlers_passkey_discoverable.go index 15e3fc9..8b19cae 100644 --- a/internal/api/handlers_passkey_discoverable.go +++ b/internal/api/handlers_passkey_discoverable.go @@ -197,7 +197,7 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt return } - if err := a.startSession(w, r, resolved.ID); err != nil { + if err := a.startSession(w, r, resolved.ID, provenSignIn); err != nil { writeError(w, r, err) return } diff --git a/internal/api/handlers_setup.go b/internal/api/handlers_setup.go index 5646758..5820dae 100644 --- a/internal/api/handlers_setup.go +++ b/internal/api/handlers_setup.go @@ -81,7 +81,7 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) { // Mint the session — a regular felis_session; the lockdown is a product-level // restriction the frontend enforces until email is verified / a passkey is bound. - if err := a.startSession(w, r, u.ID); err != nil { + if err := a.startSession(w, r, u.ID, provenSignIn); err != nil { writeError(w, r, err) return } diff --git a/internal/api/handlers_setup_test.go b/internal/api/handlers_setup_test.go index e058088..793583a 100644 --- a/internal/api/handlers_setup_test.go +++ b/internal/api/handlers_setup_test.go @@ -83,7 +83,7 @@ func TestSetEmailClearsVerified(t *testing.T) { repo.staff["u"] = &StaffUser{ID: "u1", Username: "u", Role: "admin", Email: "old@x.test", EmailVerified: true} api := newTestAPI(repo, newFakeCluster()) - api.External = staticExternal{p: &Principal{UserID: "u1", Role: "admin", ViaSession: true, EmailVerified: true}} + api.External = staticExternal{p: &Principal{UserID: "u1", Role: "admin", ViaSession: true, EmailVerified: true, ReauthAt: frozenNow}} h := api.ExternalHandler() w := do(h, "POST", "/api/v1/account/email", `{"email":"new@x.test"}`, jsonHeader) diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 484fb29..dd6c64b 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -1114,10 +1114,11 @@ func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email string) // CreateSession records a minted session by the sha-256 of its cookie value // (spec §B). Only the hash is stored, mirroring tokens. func (p *PGRepo) CreateSession(ctx context.Context, s NewSession) error { + reauth := sql.NullTime{Time: s.ReauthAt, Valid: !s.ReauthAt.IsZero()} _, err := p.db.ExecContext(ctx, - `INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip) - VALUES ($1, $2, $3, $4, $5)`, - s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP) + `INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip, reauth_at) + VALUES ($1, $2, $3, $4, $5, $6)`, + s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP, reauth) return err } @@ -1136,17 +1137,21 @@ const sessionLive = `s.revoked_at IS NULL AND s.expires_at > $2 // SessionUser resolves a live session hash to its user, or ErrNotFound. func (p *PGRepo) SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error) { const q = `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text, COALESCE(u.email_verified, false), - s.last_seen_at + s.last_seen_at, s.reauth_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND ` + sessionLive var u SessionedUser + var reauth sql.NullTime switch err := p.db.QueryRowContext(ctx, q, tokenHash, now, now.Add(-staffSessionIdle)).Scan( - &u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified, &u.LastSeenAt); { + &u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified, &u.LastSeenAt, &reauth); { case errors.Is(err, sql.ErrNoRows): return nil, ErrNotFound case err != nil: return nil, err } + if reauth.Valid { + u.ReauthAt = reauth.Time + } return &u, nil } @@ -1158,6 +1163,14 @@ func (p *PGRepo) TouchSession(ctx context.Context, tokenHash string, now time.Ti return err } +// MarkSessionReauth records a proven factor on a live session. +func (p *PGRepo) MarkSessionReauth(ctx context.Context, tokenHash string, at time.Time) error { + _, err := p.db.ExecContext(ctx, + `UPDATE sessions SET reauth_at = $2 WHERE token_hash = $1 AND revoked_at IS NULL`, + tokenHash, at) + return err +} + // RevokeSession marks a session revoked (logout). Idempotent: a missing or // already-revoked session is not an error. func (p *PGRepo) RevokeSession(ctx context.Context, tokenHash string) error { diff --git a/internal/api/reauth.go b/internal/api/reauth.go new file mode 100644 index 0000000..f4153ec --- /dev/null +++ b/internal/api/reauth.go @@ -0,0 +1,416 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "log" + "net/http" + "strings" + "time" +) + +// Reauth (step-up) guards the changes that plant or remove a lasting way into an +// account: adding or removing a passkey and changing the email. Holding the +// session is not enough for them once the account has a factor of its own; the +// holder must have proven one within reauthWindow. Otherwise a stolen cookie +// (XSS, a shared machine) could register the thief's passkey and keep the +// account long after the session ends, and for staff that passkey would skip +// op-login's in-game approval for good. +// +// What proves a factor, and so marks the session (sessions.reauth_at): +// +// - signing in by passkey, by email code, through op-login or with the setup +// token (startSession with provenSignIn); +// - a passkey assertion or an email code on the reauth endpoints below; +// - verifying an email address by code (the address is proven that moment, +// and reaching that step already passed this gate when the account had a +// factor to protect). +// +// A bind-code sign-in proves only the in-game identity and marks nothing: whoever +// controls the Minecraft account must still show the account's passkey or mailbox +// before touching them. +// +// Staff reauth with a passkey or by signing in again through op-login. An email +// code alone is not a staff factor, because signing in as staff by email also +// takes in-game approval. + +const ( + // reauthWindow is how long a proven factor lets the session make guarded + // changes. Long enough to finish a passkey ceremony or an email change. + reauthWindow = 5 * time.Minute + + otpPurposeReauth = "reauth" + passkeyPurposeReauth = "passkey_reauth" + + reauthFactorPasskey = "passkey" + reauthFactorEmail = "email" + // reauthFactorSignIn: sign out and back in through a proving door. + reauthFactorSignIn = "sign_in" +) + +// reauthState is where the caller stands with the guarded changes. +type reauthState struct { + // Needed: a guarded change would be refused until the caller reauths. + Needed bool `json:"needed"` + // Until is when the current proof stops counting; absent when there is none + // or the account has nothing to guard. + Until *time.Time `json:"until,omitempty"` + // Factors are the ways this caller can reauth, best first. + Factors []string `json:"factors"` +} + +func (a *API) reauthState(r *http.Request, p *Principal) (reauthState, error) { + st := reauthState{Factors: []string{}} + if !p.ViaSession { + // A Cloudflare Access caller is authenticated by the proxy on every + // request and has no session here to mark. + return st, nil + } + hasPasskey, err := a.userHasPasskey(r.Context(), p.UserID) + if err != nil { + return st, err + } + if hasPasskey { + st.Factors = append(st.Factors, reauthFactorPasskey) + } + if staffRole(p.Role) { + st.Factors = append(st.Factors, reauthFactorSignIn) + } else if p.EmailVerified { + st.Factors = append(st.Factors, reauthFactorEmail) + } + if !hasPasskey && !p.EmailVerified { + // Nothing to protect yet: the session is the account's only way in. + return st, nil + } + if until := p.ReauthAt.Add(reauthWindow); !p.ReauthAt.IsZero() && a.now().Before(until) { + until = until.UTC() + st.Until = &until + return st, nil + } + st.Needed = true + return st, nil +} + +// requireReauth lets a guarded change through, or answers 403 reauth_required +// and returns false. +func (a *API) requireReauth(w http.ResponseWriter, r *http.Request, p *Principal) bool { + st, err := a.reauthState(r, p) + if err != nil { + writeError(w, r, err) + return false + } + if st.Needed { + writeError(w, r, newError(http.StatusForbidden, "reauth_required", + "confirm it's you first: this change needs your passkey or email code from the last few minutes")) + return false + } + return true +} + +// markReauth records the proof on the caller's session and answers with the new +// window. +func (a *API) markReauth(w http.ResponseWriter, r *http.Request, p *Principal, factor string) { + now := a.now() + if err := a.Repo.MarkSessionReauth(r.Context(), currentSessionHash(r), now); err != nil { + writeError(w, r, err) + return + } + a.audit(r, "account.reauth", factor) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "until": now.Add(reauthWindow).UTC()}) +} + +// markReauthQuietly records a proof that happened as part of another change +// (a passkey registration, an email verification). The change already went +// through, so a failure here is logged and the next guarded change just asks. +func (a *API) markReauthQuietly(r *http.Request) { + hash := currentSessionHash(r) + if hash == "" { + return + } + if err := a.Repo.MarkSessionReauth(r.Context(), hash, a.now()); err != nil { + log.Printf("auth: could not record reauth on the session (request_id=%s): %v", + requestIDFromContext(r.Context()), err) + } +} + +// handleReauthStatus reports whether a guarded change needs a reauth first and +// which factors can provide it, so the panel can ask before starting one. +func (a *API) handleReauthStatus(w http.ResponseWriter, r *http.Request) { + st, err := a.reauthState(r, principalFromContext(r.Context())) + if err != nil { + writeError(w, r, err) + return + } + writeJSON(w, http.StatusOK, st) +} + +// requireReauthSession refuses the reauth endpoints to a caller with no session +// to mark. +func requireReauthSession(w http.ResponseWriter, r *http.Request, p *Principal) bool { + if !p.ViaSession || currentSessionHash(r) == "" { + writeError(w, r, newError(http.StatusBadRequest, "no_session", + "only a signed-in browser session can confirm it's you")) + return false + } + return true +} + +func (a *API) handleReauthPasskeyBegin(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + if a.Passkey == nil { + writeError(w, r, errPasskeyUnavailable) + return + } + if !requireReauthSession(w, r, p) { + return + } + a.beginStepUpPasskey(w, r, p, passkeyPurposeReauth, + "no passkey enrolled; confirm with an email code instead") +} + +func (a *API) handleReauthPasskeyFinish(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + if a.Passkey == nil { + writeError(w, r, errPasskeyUnavailable) + return + } + var req stepUpPasskeyFinishRequest + if err := decodeJSON(w, r, &req); err != nil { + writeError(w, r, err) + return + } + if len(req.Assertion) == 0 { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "assertion is required")) + return + } + if !requireReauthSession(w, r, p) { + return + } + if !a.finishStepUpPasskey(w, r, p, passkeyPurposeReauth, "reauth_passkey", req.Assertion) { + return + } + a.markReauth(w, r, p, reauthFactorPasskey) +} + +// requireEmailReauth admits a player with a verified address to the email-code +// reauth; staff confirm with a passkey or by signing in again. +func requireEmailReauth(w http.ResponseWriter, r *http.Request, p *Principal) bool { + if staffRole(p.Role) { + writeError(w, r, newError(http.StatusForbidden, "staff_reauth", + "operators confirm with a passkey or by signing in again")) + return false + } + if !p.EmailVerified || p.Email == "" { + writeError(w, r, newError(http.StatusConflict, "no_step_up_factor", + "there is no verified email on this account to send a code to")) + return false + } + return true +} + +func (a *API) handleReauthEmailStart(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + if !requireReauthSession(w, r, p) || !requireEmailReauth(w, r, p) { + return + } + a.startStepUpOTP(w, r, p, otpPurposeReauth, "reauth:", "account.reauth.otp_sent") +} + +func (a *API) handleReauthEmailVerify(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + var req stepUpOTPVerifyRequest + if err := decodeJSON(w, r, &req); err != nil { + writeError(w, r, err) + return + } + code := strings.TrimSpace(req.Code) + if code == "" { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required")) + return + } + if !requireReauthSession(w, r, p) || !requireEmailReauth(w, r, p) { + return + } + if !a.verifyStepUpOTP(w, r, p, otpPurposeReauth, "reauth_email", code) { + return + } + a.markReauth(w, r, p, reauthFactorEmail) +} + +// ---- step-up ceremonies shared by reauth and the migration confirm ---- + +// stepUpPasskeyFinishRequest is the assertion the browser produced, captured as +// raw bytes so the exact response reaches the verifier without re-encoding. +type stepUpPasskeyFinishRequest struct { + Assertion json.RawMessage `json:"assertion"` +} + +// stepUpOTPVerifyRequest is the code from the step-up email. +type stepUpOTPVerifyRequest struct { + Code string `json:"code"` +} + +// stepUpPasskeyUser builds the PasskeyUser the assertion ceremony needs for the +// already signed-in caller (contrast the login door, which resolves it from a +// typed email). The credential set must be identical between begin and finish. +func stepUpPasskeyUser(p *Principal, creds []PasskeyCredential) PasskeyUser { + name := p.Email + if name == "" { + name = p.UserID + } + return PasskeyUser{ID: p.UserID, Name: name, DisplayName: name, Credentials: creds} +} + +// beginStepUpPasskey starts an assertion over the caller's own passkeys, its +// challenge stashed under purpose, and writes the options (go-webauthn's +// {"publicKey": {...}} document). The caller has checked a.Passkey. +func (a *API) beginStepUpPasskey(w http.ResponseWriter, r *http.Request, p *Principal, purpose, noPasskey string) { + creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID) + if err != nil { + writeError(w, r, err) + return + } + if len(creds) == 0 { + writeError(w, r, newError(http.StatusBadRequest, "no_passkey", "%s", noPasskey)) + return + } + options, sessionData, err := a.Passkey.BeginLogin(stepUpPasskeyUser(p, creds)) + if err != nil { + writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed", + "could not start passkey confirmation")) + return + } + id, err := newPasskeyID() + if err != nil { + writeError(w, r, err) + return + } + expiresAt := a.now().Add(passkeyChallengeTTL) + if err := a.Repo.CreatePasskeyChallenge(r.Context(), id, p.UserID, purpose, sessionData, expiresAt); err != nil { + writeError(w, r, err) + return + } + writeJSON(w, http.StatusOK, options) +} + +// finishStepUpPasskey consumes the purpose's stashed challenge and verifies the +// assertion against the caller's passkeys. It reports whether the caller passed; +// on false the error is written. door names the failure in metrics and audit. +// The caller has checked a.Passkey and that the assertion is present. +func (a *API) finishStepUpPasskey(w http.ResponseWriter, r *http.Request, p *Principal, purpose, door string, assertion json.RawMessage) bool { + invalid := func() bool { + writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", + "passkey confirmation could not be completed; begin again")) + return false + } + sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), p.UserID, purpose, a.now()) + if err != nil { + if errors.Is(err, ErrPasskeyChallengeInvalid) { + a.authFailure(r, door, "challenge_invalid", nil) + return invalid() + } + writeError(w, r, err) + return false + } + creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID) + if err != nil { + writeError(w, r, err) + return false + } + va, err := a.Passkey.FinishLogin(stepUpPasskeyUser(p, creds), sessionData, bytes.NewReader(assertion)) + if err != nil { + a.authFailure(r, door, "bad_assertion", nil) + return invalid() + } + // Same clone policy as the login door (applyAssertionCounter): a rolled-back + // counter fails closed with the opaque envelope, so a step-up never accepts an + // authenticator that login refuses. A clean assertion advances the stored + // sign-count, keeping the clone signal meaningful for the next login. + if err := a.applyAssertionCounter(r.Context(), va); err != nil { + if errors.Is(err, errPasskeyClonedAuthenticator) { + a.passkeyCloneRejected(r, door, nil, va.CredentialID) + return invalid() + } + writeError(w, r, err) + return false + } + return true +} + +// startStepUpOTP mails a fresh code under purpose to the caller's (verified) +// address and answers 202. keyPrefix namespaces the per-mailbox resend cooldown +// so the step-up doors never perturb each other's throttle. +func (a *API) startStepUpOTP(w http.ResponseWriter, r *http.Request, p *Principal, purpose, keyPrefix, auditAction string) { + if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, purpose, a.now()); err != nil { + writeError(w, r, err) + return + } else if !until.IsZero() { + writeOTPAccountLocked(w, r, until, a.now()) + return + } + emailKey := keyPrefix + strings.ToLower(p.Email) + lim := a.otpLimiter() + emailAt, ok := lim.reserve(emailKey, otpResendCooldown) + if !ok { + writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown", + "a code was sent recently; wait a moment before requesting another")) + return + } + committed := false + defer func() { + if !committed { + lim.release(emailKey, emailAt) + } + }() + code, err := newEmailOTP() + if err != nil { + writeError(w, r, err) + return + } + id, err := newOTPID() + if err != nil { + writeError(w, r, err) + return + } + expiresAt := a.now().Add(otpTTL) + if err := a.Repo.CreateEmailOTP(r.Context(), id, p.UserID, p.Email, otpCodeHash(code), purpose, expiresAt); err != nil { + writeError(w, r, err) + return + } + if err := a.deliverOTP(r.Context(), p.Email, code); err != nil { + writeError(w, r, err) + return + } + committed = true + a.audit(r, auditAction, "") + writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()}) +} + +// verifyStepUpOTP redeems a step-up code. The lifecycle is the login door's (no +// identity side effect): the address is already proven. It reports whether the +// code matched; on false the error is written. +func (a *API) verifyStepUpOTP(w http.ResponseWriter, r *http.Request, p *Principal, purpose, door, code string) bool { + var lock *OTPAccountLockedError + err := a.Repo.ConsumeLoginEmailOTP(r.Context(), p.UserID, purpose, otpCodeHash(code), a.now()) + if isOTPRefusal(err) { + a.authFailure(r, door, otpFailureReason(err), nil) + } + switch { + case errors.As(err, &lock): + a.noteOTPLock(r, err, p.UserID, purpose) + writeOTPAccountLocked(w, r, lock.Until, a.now()) + return false + case errors.Is(err, ErrOTPLocked): + writeError(w, r, newError(http.StatusTooManyRequests, "otp_locked", + "too many incorrect attempts; request a new code")) + return false + case errors.Is(err, ErrOTPInvalid): + writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired")) + return false + case err != nil: + writeError(w, r, err) + return false + } + return true +} diff --git a/internal/api/reauth_test.go b/internal/api/reauth_test.go new file mode 100644 index 0000000..9446248 --- /dev/null +++ b/internal/api/reauth_test.go @@ -0,0 +1,557 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + "time" +) + +// Reauth: once an account has a passkey or a verified email, adding or removing a +// passkey and changing the email need a factor proven within reauthWindow. These +// tests drive the real SessionAuth, so the proof is read from the session row the +// cookie names, exactly as in production. + +const opTok = "tok-op" + +// reauthFixture is the sessions fixture (steve: a player with a verified email, +// signed in on the laptop and the phone; alex: a player with no factor) plus a +// passkey verifier and an operator, pam, with a verified email. Every session +// starts with no proof on it. +func reauthFixture(t *testing.T) *sessionsFixture { + t.Helper() + f := newSessionsFixture(t) + f.api.Passkey = &fakePasskeyVerifier{} + f.repo.staff["pam"] = &StaffUser{ID: "u3", Username: "pam", Email: "pam@example.net", Role: "admin", EmailVerified: true} + now := f.api.now() + f.repo.sessions[hashCookie(opTok)] = &fakeSession{userID: "u3", lastSeen: now, expiresAt: now.Add(time.Hour)} + for _, s := range f.repo.sessions { + s.reauthAt = time.Time{} + } + f.eh = f.api.ExternalHandler() + return f +} + +func (f *sessionsFixture) reauthAt(tok string) time.Time { + return f.repo.sessions[hashCookie(tok)].reauthAt +} + +func (f *sessionsFixture) setReauth(tok string, at time.Time) { + f.repo.sessions[hashCookie(tok)].reauthAt = at +} + +func jsonCookie(tok string) map[string]string { + h := asCookie(tok) + h["Content-Type"] = "application/json" + return h +} + +func TestSigningInByEmailCodeCountsAsReauth(t *testing.T) { + api, repo, mailer := seedLoginEmailAPI(t) + eh := api.ExternalHandler() + if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"player@example.net"}`, jsonHeader); w.Code != http.StatusAccepted { + t.Fatalf("start = %d (%s)", w.Code, w.Body.String()) + } + w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"player@example.net","code":"`+mailer.code+`"}`, jsonHeader) + if w.Code != http.StatusOK { + t.Fatalf("verify = %d (%s)", w.Code, w.Body.String()) + } + s := repo.sessions[hashCookie(sessionCookieValue(t, w))] + if !s.reauthAt.Equal(api.now()) { + t.Fatalf("reauth_at = %v, want the sign-in time %v", s.reauthAt, api.now()) + } +} + +// A bind code proves the Minecraft account, and nothing about the account's own +// passkey or mailbox. +func TestSigningInByBindCodeIsNoReauth(t *testing.T) { + api, repo := seedBindAPI(t) + mintBindCode(t, api, repo, "ABCD2345", bindTestUUID, authSourceMojang) + w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"ABCD2345"}`, jsonHeader) + if w.Code != http.StatusOK { + t.Fatalf("bind = %d (%s)", w.Code, w.Body.String()) + } + if s := repo.sessions[hashCookie(sessionCookieValue(t, w))]; !s.reauthAt.IsZero() { + t.Fatalf("reauth_at = %v, want none after a bind-code sign-in", s.reauthAt) + } +} + +// guardedChange is a request the gate covers, the status it gets once the gate +// lets it through, and a check that it changed nothing when refused. +type guardedChange struct { + name, method, path, body string + ok int + untouched func(f *sessionsFixture) bool +} + +var guardedChanges = []guardedChange{ + {"add a passkey", "POST", "/api/v1/account/passkey/register/begin", "", http.StatusOK, + func(f *sessionsFixture) bool { return len(f.repo.passkeyChallenges) == 0 }}, + {"remove a passkey", "DELETE", "/api/v1/account/passkey/credentials/a", "", http.StatusNoContent, + func(f *sessionsFixture) bool { _, ok := f.repo.passkeyCreds["a"]; return ok }}, + {"change the email", "POST", "/api/v1/account/email/start", `{"email":"steve@new.example"}`, http.StatusAccepted, + func(f *sessionsFixture) bool { return len(f.repo.otps) == 0 }}, + {"record an unverified email", "POST", "/api/v1/account/email", `{"email":"steve@new.example"}`, http.StatusOK, + func(f *sessionsFixture) bool { return f.repo.staff["steve"].EmailVerified }}, +} + +func TestGuardedChangesNeedARecentReauth(t *testing.T) { + for _, tc := range []struct { + name string + proof time.Duration // how long ago the session proved a factor; -1 = never + wantOK bool + }{ + {"never proved", -1, false}, + {"proved 6 minutes ago", 6 * time.Minute, false}, + {"proved exactly 5 minutes ago", 5 * time.Minute, false}, + {"proved 4m59s ago", 5*time.Minute - time.Second, true}, + {"proved just now", 0, true}, + } { + for _, g := range guardedChanges { + t.Run(tc.name+"/"+g.name, func(t *testing.T) { + f := reauthFixture(t) + f.api.Mailer = &captureMailer{} + f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow} + if tc.proof >= 0 { + f.setReauth(laptopTok, f.api.now().Add(-tc.proof)) + } + w := do(f.eh, g.method, g.path, g.body, jsonCookie(laptopTok)) + if tc.wantOK { + if w.Code != g.ok { + t.Fatalf("%s = %d (%s), want %d", g.name, w.Code, w.Body.String(), g.ok) + } + return + } + if w.Code != http.StatusForbidden || decodeErr(t, w) != "reauth_required" { + t.Fatalf("%s = %d (%s), want 403 reauth_required", g.name, w.Code, w.Body.String()) + } + if !g.untouched(f) { + t.Fatalf("%s went through despite the refusal", g.name) + } + }) + } + } +} + +// With no passkey and no verified email the session is the account's only way +// in, so there is nothing a reauth could protect and nothing to give one with. +func TestAccountWithoutAFactorNeedsNoReauth(t *testing.T) { + f := reauthFixture(t) + f.api.Mailer = &captureMailer{} + if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok)); w.Code != http.StatusOK { + t.Fatalf("register begin = %d (%s)", w.Code, w.Body.String()) + } + if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"alex@example.net"}`, jsonCookie(alexTok)); w.Code != http.StatusAccepted { + t.Fatalf("email start = %d (%s)", w.Code, w.Body.String()) + } +} + +// An unverified address is no factor: it never received a code. +func TestUnverifiedEmailIsNoFactor(t *testing.T) { + f := reauthFixture(t) + f.repo.staff["alex"].Email = "alex@example.net" + if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok)); w.Code != http.StatusOK { + t.Fatalf("register begin = %d (%s)", w.Code, w.Body.String()) + } +} + +// A passkey alone is a factor worth guarding. +func TestPasskeyAloneNeedsReauth(t *testing.T) { + f := reauthFixture(t) + f.repo.passkeyCreds["x"] = PasskeyCredential{ID: "x", UserID: "u2", CredentialID: "c-x", CreatedAt: frozenNow} + w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok)) + if w.Code != http.StatusForbidden || decodeErr(t, w) != "reauth_required" { + t.Fatalf("register begin = %d (%s), want 403 reauth_required", w.Code, w.Body.String()) + } +} + +// A Cloudflare Access caller is authenticated by the proxy on every request and +// has no session to mark. +func TestAccessCallerNeedsNoReauth(t *testing.T) { + repo := newFakeRepo() + repo.staff["op"] = &StaffUser{ID: "u1", Username: "op", Role: "admin", Email: "op@example.net", EmailVerified: true} + repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow} + api := newTestAPI(repo, newFakeCluster()) + api.Passkey = &fakePasskeyVerifier{} + api.External = staticExternal{p: &Principal{UserID: "u1", Email: "op@example.net", Role: "admin", EmailVerified: true}} + if w := do(api.ExternalHandler(), "POST", "/api/v1/account/passkey/register/begin", "", nil); w.Code != http.StatusOK { + t.Fatalf("register begin = %d (%s)", w.Code, w.Body.String()) + } +} + +type reauthStatusBody struct { + Needed bool `json:"needed"` + Until *time.Time `json:"until"` + Factors []string `json:"factors"` +} + +func getReauthStatus(t *testing.T, f *sessionsFixture, tok string) reauthStatusBody { + t.Helper() + w := do(f.eh, "GET", "/api/v1/account/reauth", "", asCookie(tok)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d (%s)", w.Code, w.Body.String()) + } + var b reauthStatusBody + if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil { + t.Fatal(err) + } + return b +} + +func TestReauthStatusNamesTheFactors(t *testing.T) { + f := reauthFixture(t) + f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow} + f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow} + + steve := getReauthStatus(t, f, laptopTok) + if !steve.Needed || steve.Until != nil || strings.Join(steve.Factors, ",") != "passkey,email" { + t.Fatalf("player status = %+v, want needed with passkey,email", steve) + } + // An operator's verified email is no factor: signing in as staff by email + // also takes in-game approval. + pam := getReauthStatus(t, f, opTok) + if !pam.Needed || strings.Join(pam.Factors, ",") != "passkey,sign_in" { + t.Fatalf("operator status = %+v, want needed with passkey,sign_in", pam) + } + alex := getReauthStatus(t, f, alexTok) + if alex.Needed || len(alex.Factors) != 0 { + t.Fatalf("no-factor status = %+v, want not needed and no factors", alex) + } + + proved := f.api.now().Add(-time.Minute) + f.setReauth(laptopTok, proved) + steve = getReauthStatus(t, f, laptopTok) + if steve.Needed || steve.Until == nil || !steve.Until.Equal(proved.Add(reauthWindow)) { + t.Fatalf("after a proof status = %+v, want not needed until %v", steve, proved.Add(reauthWindow)) + } +} + +func TestReauthByEmailCode(t *testing.T) { + f := reauthFixture(t) + mailer := &captureMailer{} + f.api.Mailer = mailer + + if w := do(f.eh, "POST", "/api/v1/account/reauth/email/start", "", asCookie(laptopTok)); w.Code != http.StatusAccepted { + t.Fatalf("start = %d (%s)", w.Code, w.Body.String()) + } + if mailer.email != "steve@example.net" || mailer.code == "" { + t.Fatalf("code went to %q (%q), want steve@example.net", mailer.email, mailer.code) + } + wrong := "000000" + if mailer.code == wrong { + wrong = "111111" + } + w := do(f.eh, "POST", "/api/v1/account/reauth/email/verify", `{"code":"`+wrong+`"}`, jsonCookie(laptopTok)) + if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" { + t.Fatalf("wrong code = %d (%s), want 400 invalid_code", w.Code, w.Body.String()) + } + if !f.reauthAt(laptopTok).IsZero() { + t.Fatal("a wrong code marked the session") + } + + w = do(f.eh, "POST", "/api/v1/account/reauth/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(laptopTok)) + if w.Code != http.StatusOK { + t.Fatalf("verify = %d (%s)", w.Code, w.Body.String()) + } + var body struct { + OK bool `json:"ok"` + Until time.Time `json:"until"` + } + _ = json.Unmarshal(w.Body.Bytes(), &body) + if !body.OK || !body.Until.Equal(f.api.now().Add(reauthWindow)) { + t.Fatalf("verify body = %s, want ok until now+5m", w.Body.String()) + } + if !f.reauthAt(laptopTok).Equal(f.api.now()) { + t.Fatalf("laptop reauth_at = %v, want now", f.reauthAt(laptopTok)) + } + // The proof belongs to the session that gave it. + if !f.reauthAt(phoneTok).IsZero() { + t.Fatal("the phone was marked by the laptop's code") + } + if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK { + t.Fatalf("register begin after reauth = %d (%s)", w.Code, w.Body.String()) + } + var audited bool + for _, e := range f.repo.audits { + audited = audited || (e.Action == "account.reauth" && e.ServerName == "email") + } + if !audited { + t.Fatalf("no account.reauth audit naming the email factor: %+v", f.repo.audits) + } +} + +// A code from another step-up (the email change, a migration) does not reauth. +func TestReauthCodeIsItsOwnPurpose(t *testing.T) { + f := reauthFixture(t) + mailer := &captureMailer{} + f.api.Mailer = mailer + f.setReauth(laptopTok, f.api.now()) + if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"steve@new.example"}`, jsonCookie(laptopTok)); w.Code != http.StatusAccepted { + t.Fatalf("email start = %d (%s)", w.Code, w.Body.String()) + } + w := do(f.eh, "POST", "/api/v1/account/reauth/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(phoneTok)) + if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" { + t.Fatalf("onboarding code on the reauth door = %d (%s), want 400 invalid_code", w.Code, w.Body.String()) + } +} + +func TestOperatorsCannotReauthByEmail(t *testing.T) { + f := reauthFixture(t) + mailer := &captureMailer{} + f.api.Mailer = mailer + for _, path := range []string{"/api/v1/account/reauth/email/start", "/api/v1/account/reauth/email/verify"} { + w := do(f.eh, "POST", path, `{"code":"123456"}`, jsonCookie(opTok)) + if w.Code != http.StatusForbidden || decodeErr(t, w) != "staff_reauth" { + t.Fatalf("%s = %d (%s), want 403 staff_reauth", path, w.Code, w.Body.String()) + } + } + if mailer.calls != 0 { + t.Fatal("a code was mailed to an operator") + } +} + +func TestReauthByEmailNeedsAVerifiedAddress(t *testing.T) { + f := reauthFixture(t) + f.api.Mailer = &captureMailer{} + f.repo.staff["alex"].Email = "alex@example.net" + w := do(f.eh, "POST", "/api/v1/account/reauth/email/start", "", asCookie(alexTok)) + if w.Code != http.StatusConflict || decodeErr(t, w) != "no_step_up_factor" { + t.Fatalf("start = %d (%s), want 409 no_step_up_factor", w.Code, w.Body.String()) + } +} + +func TestReauthByPasskey(t *testing.T) { + f := reauthFixture(t) + pv := f.api.Passkey.(*fakePasskeyVerifier) + f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", SignCount: 4, CreatedAt: frozenNow} + finish := func() int { + t.Helper() + if w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK { + t.Fatalf("begin = %d (%s)", w.Code, w.Body.String()) + } + if len(pv.lastUser.Credentials) != 1 || pv.lastUser.Credentials[0].CredentialID != "c-a" { + t.Fatalf("assertion offered %+v, want the caller's own passkey", pv.lastUser.Credentials) + } + return do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"c-a"}}`, jsonCookie(laptopTok)).Code + } + + pv.failErr = errors.New("assertion rejected") + if code := finish(); code != http.StatusBadRequest { + t.Fatalf("bad assertion = %d, want 400", code) + } + pv.failErr = nil + pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 2, CloneWarning: true} + if code := finish(); code != http.StatusBadRequest { + t.Fatalf("cloned authenticator = %d, want 400", code) + } + if !f.reauthAt(laptopTok).IsZero() { + t.Fatal("a failed assertion marked the session") + } + + pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 5} + if code := finish(); code != http.StatusOK { + t.Fatalf("finish = %d, want 200", code) + } + if !f.reauthAt(laptopTok).Equal(f.api.now()) { + t.Fatalf("reauth_at = %v, want now", f.reauthAt(laptopTok)) + } + if got := f.repo.passkeyCreds["a"].SignCount; got != 5 { + t.Fatalf("sign count = %d, want it advanced to 5", got) + } + // The challenge was spent: a replayed finish has nothing to consume. + w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"c-a"}}`, jsonCookie(laptopTok)) + if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" { + t.Fatalf("replayed finish = %d (%s), want 400 passkey_login_invalid", w.Code, w.Body.String()) + } +} + +// A migration's passkey challenge cannot be spent on a reauth, or the reverse. +func TestReauthPasskeyChallengeIsItsOwnPurpose(t *testing.T) { + f := reauthFixture(t) + pv := f.api.Passkey.(*fakePasskeyVerifier) + pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 5} + f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow} + if err := f.repo.CreatePasskeyChallenge(t.Context(), "m1", "u1", passkeyPurposeMigrate, []byte("s"), f.api.now().Add(time.Minute)); err != nil { + t.Fatal(err) + } + w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"c-a"}}`, jsonCookie(laptopTok)) + if w.Code != http.StatusBadRequest { + t.Fatalf("finish on a migration challenge = %d (%s), want 400", w.Code, w.Body.String()) + } +} + +// Proving an address by code is an email reauth, so a first-time setup can go on +// to its next guarded step. +func TestVerifyingAnEmailCountsAsReauth(t *testing.T) { + f := reauthFixture(t) + mailer := &captureMailer{} + f.api.Mailer = mailer + if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"alex@example.net"}`, jsonCookie(alexTok)); w.Code != http.StatusAccepted { + t.Fatalf("start = %d (%s)", w.Code, w.Body.String()) + } + if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(alexTok)); w.Code != http.StatusOK { + t.Fatalf("verify = %d (%s)", w.Code, w.Body.String()) + } + if !f.reauthAt(alexTok).Equal(f.api.now()) { + t.Fatalf("reauth_at = %v, want now", f.reauthAt(alexTok)) + } +} + +func TestRegisteringAPasskeyCountsAsReauth(t *testing.T) { + f := reauthFixture(t) + f.api.Passkey.(*fakePasskeyVerifier).credential = VerifiedCredential{CredentialID: "c-new", PublicKey: "pk"} + if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok)); w.Code != http.StatusOK { + t.Fatalf("begin = %d (%s)", w.Code, w.Body.String()) + } + if w := do(f.eh, "POST", "/api/v1/account/passkey/register/finish", `{"attestation":{"id":"x"}}`, jsonCookie(alexTok)); w.Code != http.StatusCreated { + t.Fatalf("finish = %d (%s)", w.Code, w.Body.String()) + } + if !f.reauthAt(alexTok).Equal(f.api.now()) { + t.Fatalf("reauth_at = %v, want now", f.reauthAt(alexTok)) + } +} + +// ---- change notices ---- + +func noticeFixture(t *testing.T) (*sessionsFixture, *noticeMailer) { + t.Helper() + f := reauthFixture(t) + mailer := ¬iceMailer{} + f.api.Mailer = mailer + f.api.ClientIPHeader = "CF-Connecting-IP" + f.setReauth(laptopTok, f.api.now()) + return f, mailer +} + +func fromIP(h map[string]string) map[string]string { + h["CF-Connecting-IP"] = "203.0.113.9" + return h +} + +func onlyNotice(t *testing.T, m *noticeMailer) (to, subject, body string) { + t.Helper() + if len(m.notices) != 1 { + t.Fatalf("notices = %q, want exactly one", m.notices) + } + parts := strings.SplitN(m.notices[0], "|", 3) + return parts[0], parts[1], parts[2] +} + +func TestRemovingAPasskeyMailsTheAccount(t *testing.T) { + f, mailer := noticeFixture(t) + f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow} + if w := do(f.eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", fromIP(asCookie(laptopTok))); w.Code != http.StatusNoContent { + t.Fatalf("delete = %d (%s)", w.Code, w.Body.String()) + } + to, subject, body := onlyNotice(t, mailer) + if to != "steve@example.net" || subject != "Felis 已删除 Passkey · passkey removed" { + t.Fatalf("notice to %q subject %q", to, subject) + } + for _, want := range []string{ + "A passkey was just removed from your Felis account, and every other device was signed out.", + "Time: 2023-11-14 22:13 UTC", + "From IP: 203.0.113.9", + "check the passkeys that remain", + "来源 IP:203.0.113.9", + } { + if !strings.Contains(body, want) { + t.Errorf("notice body lacks %q:\n%s", want, body) + } + } +} + +func TestAddingAPasskeyMailsTheAccount(t *testing.T) { + f, mailer := noticeFixture(t) + f.api.Passkey.(*fakePasskeyVerifier).credential = VerifiedCredential{CredentialID: "c-new", PublicKey: "pk"} + if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK { + t.Fatalf("begin = %d (%s)", w.Code, w.Body.String()) + } + if w := do(f.eh, "POST", "/api/v1/account/passkey/register/finish", `{"attestation":{"id":"x"}}`, fromIP(jsonCookie(laptopTok))); w.Code != http.StatusCreated { + t.Fatalf("finish = %d (%s)", w.Code, w.Body.String()) + } + to, subject, body := onlyNotice(t, mailer) + if to != "steve@example.net" || subject != "Felis 已添加 Passkey · passkey added" || + !strings.Contains(body, "A passkey was just added to your Felis account.") || + !strings.Contains(body, "remove that passkey") { + t.Fatalf("notice to %q subject %q body:\n%s", to, subject, body) + } +} + +// Replacing the address mails the OLD one, which is the mailbox the owner still +// reads if someone else made the change. The new address is masked. +func TestChangingTheEmailMailsTheOldAddress(t *testing.T) { + f, mailer := noticeFixture(t) + if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"steve@new.example"}`, jsonCookie(laptopTok)); w.Code != http.StatusAccepted { + t.Fatalf("start = %d (%s)", w.Code, w.Body.String()) + } + if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, fromIP(jsonCookie(laptopTok))); w.Code != http.StatusOK { + t.Fatalf("verify = %d (%s)", w.Code, w.Body.String()) + } + to, subject, body := onlyNotice(t, mailer) + if to != "steve@example.net" || subject != "Felis 邮箱已更换 · email changed" { + t.Fatalf("notice to %q subject %q", to, subject) + } + if !strings.Contains(body, "The email on your Felis account was just changed to s***@new.example.") || + !strings.Contains(body, "From IP: 203.0.113.9") { + t.Fatalf("notice body:\n%s", body) + } + if strings.Contains(body, "steve@new.example") { + t.Fatalf("notice hands the new address to the old mailbox:\n%s", body) + } +} + +// A first verification and a re-verification of the same address move nothing. +func TestVerifyingAFirstOrSameEmailMailsNoNotice(t *testing.T) { + for _, tc := range []struct { + name, tok, address string + }{ + {"first address", alexTok, "alex@example.net"}, + {"same address", laptopTok, "Steve@Example.NET"}, + } { + t.Run(tc.name, func(t *testing.T) { + f, mailer := noticeFixture(t) + if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"`+tc.address+`"}`, jsonCookie(tc.tok)); w.Code != http.StatusAccepted { + t.Fatalf("start = %d (%s)", w.Code, w.Body.String()) + } + if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(tc.tok)); w.Code != http.StatusOK { + t.Fatalf("verify = %d (%s)", w.Code, w.Body.String()) + } + if len(mailer.notices) != 0 { + t.Fatalf("notices = %q, want none", mailer.notices) + } + }) + } +} + +// Nothing proves an unverified address belongs to the owner, so nothing is sent +// there. +func TestPasskeyNoticeSkipsAnUnverifiedAddress(t *testing.T) { + f, mailer := noticeFixture(t) + f.repo.staff["alex"].Email = "alex@example.net" + // Two passkeys, so removing one is allowed without a verified email. + f.repo.passkeyCreds["x"] = PasskeyCredential{ID: "x", UserID: "u2", CredentialID: "c-x", CreatedAt: frozenNow} + f.repo.passkeyCreds["y"] = PasskeyCredential{ID: "y", UserID: "u2", CredentialID: "c-y", CreatedAt: frozenNow} + f.setReauth(alexTok, f.api.now()) + if w := do(f.eh, "DELETE", "/api/v1/account/passkey/credentials/x", "", asCookie(alexTok)); w.Code != http.StatusNoContent { + t.Fatalf("delete = %d (%s)", w.Code, w.Body.String()) + } + if len(mailer.notices) != 0 { + t.Fatalf("notices = %q, want none", mailer.notices) + } +} + +func TestMaskEmail(t *testing.T) { + for in, want := range map[string]string{ + "alice@example.com": "a***@example.com", + "李雷@example.cn": "李***@example.cn", + "a@b.c": "a***@b.c", + "broken": "***", + "@nolocal.example": "***", + } { + if got := maskEmail(in); got != want { + t.Errorf("maskEmail(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/api/repo.go b/internal/api/repo.go index 891daae..0cb3ade 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -165,6 +165,9 @@ type SessionedUser struct { // LastSeenAt is when the session last authenticated a request, as last // recorded by TouchSession (so up to sessionTouchEvery stale). LastSeenAt time.Time + // ReauthAt is when the holder last proved a factor of the account (see + // requireReauth); zero when the session never did. + ReauthAt time.Time } // NewSession is one session to record at sign-in: the sha-256 of the opaque @@ -176,6 +179,9 @@ type NewSession struct { ExpiresAt time.Time UserAgent string ClientIP string + // ReauthAt is set when the sign-in itself proved a factor (passkey, email + // code, op-login, setup token); zero for a bind-code sign-in. + ReauthAt time.Time } // OpLoginRequest is one op.console staff-login attempt (spec §B op-login): the @@ -596,6 +602,9 @@ type Repo interface { // never moves last_seen_at backwards, and touching an absent session is not // an error. TouchSession(ctx context.Context, tokenHash string, now time.Time) error + // MarkSessionReauth records that the holder of a live session proved a factor + // at the given time. Marking an absent or revoked session is not an error. + MarkSessionReauth(ctx context.Context, tokenHash string, at time.Time) error // RevokeSession marks a session revoked (logout). It is idempotent: revoking an // absent or already-revoked session is not an error. RevokeSession(ctx context.Context, tokenHash string) error diff --git a/internal/api/session.go b/internal/api/session.go index 7aab6a9..181c1be 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -66,24 +66,43 @@ func hashCookie(value string) string { return hex.EncodeToString(sum[:]) } +// signInProof says whether the sign-in minting a session proved a factor of the +// account. A proven sign-in counts as a fresh reauth, so the new session may add +// a passkey or change the email straight away (requireReauth). +type signInProof bool + +const ( + // provenSignIn: a passkey, an email code, op-login or the setup token. + provenSignIn signInProof = true + // bindCodeSignIn: the in-game identity alone, which never unlocks the + // account's other factors. + bindCodeSignIn signInProof = false +) + // startSession mints a session for userID and sets its cookie. Every sign-in door // ends here, so every session records the device it was minted for. -func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string) error { +func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string, proof signInProof) error { token, err := newSessionToken() if err != nil { return err } - expires := a.now().Add(sessionTTL) + now := a.now() + expires := now.Add(sessionTTL) ip := "" if addr := a.clientIP(r); addr.IsValid() { ip = addr.String() } + var reauth time.Time + if proof == provenSignIn { + reauth = now + } if err := a.Repo.CreateSession(r.Context(), NewSession{ TokenHash: hashCookie(token), UserID: userID, ExpiresAt: expires, UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent), ClientIP: ip, + ReauthAt: reauth, }); err != nil { return err } @@ -227,6 +246,7 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) { ViaAdminAccess: staffRole(u.Role) && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname), EmailVerified: u.EmailVerified, ViaSession: true, + ReauthAt: u.ReauthAt, }, nil } diff --git a/internal/pgint/sessions_test.go b/internal/pgint/sessions_test.go index cf1b6fb..24ceefc 100644 --- a/internal/pgint/sessions_test.go +++ b/internal/pgint/sessions_test.go @@ -187,3 +187,59 @@ func TestRevokeOtherUserSessionsKeepsOne(t *testing.T) { t.Fatalf("revoke-others keeping nothing = %d, %v; want 1", n, err) } } + +// reauth_at: a proven sign-in stores the proof, a bind-code sign-in stores +// none, SessionUser reads it back, and a reauth marks only a live session. +func TestSessionReauthProof(t *testing.T) { + ctx := context.Background() + now := mustNow() + u := newUser(t, "user", "reauth") + + unproven := newSession(t, u.ID, "unproven", now.Add(time.Hour)) + su, err := repo.SessionUser(ctx, unproven, now) + if err != nil { + t.Fatalf("SessionUser: %v", err) + } + if !su.ReauthAt.IsZero() { + t.Fatalf("ReauthAt = %v on a session with no proof, want zero", su.ReauthAt) + } + + proven := "proven-" + suffix(t) + signedIn := now.Add(-time.Minute) + if err := repo.CreateSession(ctx, api.NewSession{ + TokenHash: proven, UserID: u.ID, ExpiresAt: now.Add(time.Hour), ReauthAt: signedIn, + }); err != nil { + t.Fatalf("CreateSession: %v", err) + } + if su, err = repo.SessionUser(ctx, proven, now); err != nil || !sameMicro(su.ReauthAt, signedIn) { + t.Fatalf("SessionUser = %+v, %v; want ReauthAt %v", su, err, signedIn) + } + + if err := repo.MarkSessionReauth(ctx, unproven, now); err != nil { + t.Fatalf("MarkSessionReauth: %v", err) + } + if su, err = repo.SessionUser(ctx, unproven, now); err != nil || !sameMicro(su.ReauthAt, now) { + t.Fatalf("after a mark SessionUser = %+v, %v; want ReauthAt %v", su, err, now) + } + // The other session keeps its own proof. + if su, _ = repo.SessionUser(ctx, proven, now); !sameMicro(su.ReauthAt, signedIn) { + t.Fatalf("marking one session moved another's proof to %v", su.ReauthAt) + } + + if err := repo.RevokeSession(ctx, proven); err != nil { + t.Fatal(err) + } + if err := repo.MarkSessionReauth(ctx, proven, now.Add(time.Minute)); err != nil { + t.Fatalf("marking a revoked session: %v", err) + } + var stored time.Time + if err := db.QueryRow(`SELECT reauth_at FROM sessions WHERE token_hash = $1`, proven).Scan(&stored); err != nil { + t.Fatal(err) + } + if !sameMicro(stored, signedIn) { + t.Fatalf("a revoked session's reauth_at moved to %v", stored) + } + if err := repo.MarkSessionReauth(ctx, "no-such-"+suffix(t), now); err != nil { + t.Fatalf("marking an absent session: %v", err) + } +} diff --git a/internal/store/migrations/0028_session_reauth.sql b/internal/store/migrations/0028_session_reauth.sql new file mode 100644 index 0000000..a74d9e4 --- /dev/null +++ b/internal/store/migrations/0028_session_reauth.sql @@ -0,0 +1,7 @@ +-- When the holder of a session last proved a factor the account already had: a +-- passkey assertion, a code mailed to the verified address, or a sign-in through +-- one of those (op-login and the setup token count too). Adding or removing a +-- passkey and changing the email need that proof within the last few minutes, +-- so a stolen cookie alone cannot plant a lasting way in. NULL means the session +-- never proved one (a bind-code sign-in), which is what every existing row gets. +ALTER TABLE sessions ADD COLUMN reauth_at timestamptz; diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 946909f..6210bfc 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -52,6 +52,10 @@ interface MockAccount { updated_at?: string; quota?: QuotaView; sessions?: SessionView[]; + // Until when this browser's session counts as re-authenticated (ms epoch), + // and the address an email-change code was last sent to. + reauthUntil?: number; + pendingEmail?: string; } interface MockServer extends ServerStatus { @@ -568,6 +572,41 @@ function setSessionCookie(res: ServerResponse, accountID: string): void { res.setHeader("Set-Cookie", `${SESSION_COOKIE}=${accountID}; Path=/; SameSite=Lax`); } +// signInProven opens a session through a door that proved a factor (email code, +// passkey, operator login), which like the real API counts as a re-auth. +function signInProven(ctx: RequestContext, accountID: AccountID): void { + setSessionCookie(ctx.res, accountID); + const acc = ctx.state.accounts[accountID]; + if (acc) acc.reauthUntil = Date.now() + REAUTH_MS; +} + +const REAUTH_MS = 5 * 60_000; + +/** reauthFactors mirrors reauthState: a passkey, an email code to a verified + * address (users) or a fresh sign-in (operators). None → nothing to re-prove. */ +function reauthFactors(state: MockState, acc: MockAccount): string[] { + const hasPasskey = (state.passkeys[acc.id] ?? []).length > 0; + if (!hasPasskey && !acc.emailVerified) return []; + const factors = hasPasskey ? ["passkey"] : []; + if (acc.role !== "user") factors.push("sign_in"); + else if (acc.emailVerified) factors.push("email"); + return factors; +} + +/** refusedForReauth answers 403 reauth_required, as the real API does, for a + * change to how the account signs in without a recent proof. */ +function refusedForReauth(ctx: SessionContext): boolean { + const acc = ctx.account; + if (reauthFactors(ctx.state, acc).length === 0 || (acc.reauthUntil ?? 0) > Date.now()) return false; + sendError(ctx.res, 403, "reauth_required", "confirm it's you first: this change needs your passkey or email code from the last few minutes"); + return true; +} + +function markReauth(ctx: SessionContext): void { + ctx.account.reauthUntil = Date.now() + REAUTH_MS; + sendJSON(ctx.res, 200, { ok: true, until: new Date(ctx.account.reauthUntil).toISOString() }); +} + function clearSessionCookie(res: ServerResponse): void { res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`); } @@ -784,7 +823,7 @@ async function handlePublic(ctx: RequestContext): Promise { return true; } opLogins.delete(body.request_id!); - setSessionCookie(ctx.res, "owner"); + signInProven(ctx, "owner"); sendJSON(ctx.res, 200, { user_id: "mock-owner", role: "owner" }); return true; } @@ -828,7 +867,7 @@ async function handlePublic(ctx: RequestContext): Promise { sendError(ctx.res, 400, "bad_request", "login_id and assertion are required"); return true; } - setSessionCookie(ctx.res, "owner"); + signInProven(ctx, "owner"); sendJSON(ctx.res, 200, { user_id: "mock-owner", role: "owner" @@ -857,7 +896,7 @@ async function handlePublic(ctx: RequestContext): Promise { sendError(ctx.res, 400, "bad_request", "email and assertion are required"); return true; } - setSessionCookie(ctx.res, "owner"); + signInProven(ctx, "owner"); sendJSON(ctx.res, 200, { user_id: "mock-owner", role: "owner" @@ -879,7 +918,7 @@ async function handlePublic(ctx: RequestContext): Promise { sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired"); return true; } - setSessionCookie(ctx.res, "owner"); + signInProven(ctx, "owner"); sendJSON(ctx.res, 200, { user_id: "mock-owner", role: "owner" @@ -993,6 +1032,8 @@ async function handleSession(ctx: SessionContext): Promise { sendError(ctx.res, 400, "bad_request", "invalid email"); return true; } + if (refusedForReauth(ctx)) return true; + ctx.account.pendingEmail = body.email.trim(); sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() }); return true; } @@ -1002,11 +1043,22 @@ async function handleSession(ctx: SessionContext): Promise { sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired"); return true; } + const next = ctx.account.pendingEmail ?? ctx.account.email; + // Like the real API: replacing a verified address signs the other devices + // out, and proving a code counts as a re-auth. + if (ctx.account.emailVerified && next.toLowerCase() !== ctx.account.email.toLowerCase()) { + const here = thisSessionHash(ctx.account); + ctx.account.sessions = accountSessions(ctx.account).filter((s) => s.token_hash === here); + } + ctx.account.email = next; + ctx.account.pendingEmail = undefined; ctx.account.emailVerified = true; + ctx.account.reauthUntil = Date.now() + REAUTH_MS; sendJSON(ctx.res, 200, { verified: true, email: ctx.account.email }); return true; } case "POST account/passkey/register/begin": { + if (refusedForReauth(ctx)) return true; sendJSON(ctx.res, 200, { challenge: "c29tZV9jaGFsbGVuZ2VfZGF0YQ", rp: { name: "Felis Dev" }, @@ -1034,6 +1086,7 @@ async function handleSession(ctx: SessionContext): Promise { ctx.state.passkeys[ctx.account.id] = []; } ctx.state.passkeys[ctx.account.id].unshift(newCred); + ctx.account.reauthUntil = Date.now() + REAUTH_MS; sendJSON(ctx.res, 201, newCred); return true; } @@ -1042,6 +1095,61 @@ async function handleSession(ctx: SessionContext): Promise { sendJSON(ctx.res, 200, { credentials: list }); return true; } + case "GET account/reauth": { + const factors = reauthFactors(ctx.state, ctx.account); + const until = ctx.account.reauthUntil ?? 0; + if (factors.length === 0) sendJSON(ctx.res, 200, { needed: false, factors }); + else if (until > Date.now()) sendJSON(ctx.res, 200, { needed: false, until: new Date(until).toISOString(), factors }); + else sendJSON(ctx.res, 200, { needed: true, factors }); + return true; + } + case "POST account/reauth/passkey/begin": { + const list = ctx.state.passkeys[ctx.account.id] ?? []; + if (list.length === 0) { + sendError(ctx.res, 409, "no_passkey", "no passkey enrolled; confirm with an email code instead"); + return true; + } + sendJSON(ctx.res, 200, { + publicKey: { + challenge: "c29tZV9yZWF1dGhfY2hhbGxlbmdl", + rpId: "dev.felis.localhost", + allowCredentials: list.map(() => ({ type: "public-key", id: "cGstMQ" })), + userVerification: "preferred", + timeout: 60000, + }, + }); + return true; + } + case "POST account/reauth/passkey/finish": { + const body = await readJSON<{ assertion?: unknown }>(ctx.req); + if (!body.assertion) { + sendError(ctx.res, 400, "bad_request", "assertion is required"); + return true; + } + markReauth(ctx); + return true; + } + case "POST account/reauth/email/start": { + if (ctx.account.role !== "user") { + sendError(ctx.res, 403, "staff_reauth", "operators confirm with a passkey or by signing in again"); + return true; + } + if (!ctx.account.emailVerified) { + sendError(ctx.res, 409, "no_step_up_factor", "no verified email to send a code to"); + return true; + } + sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() }); + return true; + } + case "POST account/reauth/email/verify": { + const body = await readJSON<{ code?: string }>(ctx.req); + if (body.code?.trim() !== MOCK_OTP_CODE) { + sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired"); + return true; + } + markReauth(ctx); + return true; + } case "GET account/migrate": // No migration pending: the real API's answer until one is started in-game. sendJSON(ctx.res, 200, { active: false }); @@ -1076,6 +1184,8 @@ async function handleSession(ctx: SessionContext): Promise { return true; } if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) { + // The real API asks for the re-auth before it looks the passkey up. + if (refusedForReauth(ctx)) return true; const id = ctx.parts[5]; const list = ctx.state.passkeys[ctx.account.id] ?? []; const idx = list.findIndex((k) => k.id === id); diff --git a/panel/src/components/ReauthDialog.tsx b/panel/src/components/ReauthDialog.tsx new file mode 100644 index 0000000..7323c07 --- /dev/null +++ b/panel/src/components/ReauthDialog.tsx @@ -0,0 +1,286 @@ +import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; +import { Fingerprint, Loader2, LogIn, Mail, ShieldCheck } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { InlineError } from "@/components/MessageLine"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { api, clientError, humanizeError } from "@/lib/api"; +import { useAsync } from "@/lib/hooks"; +import { requestAssertion } from "@/lib/passkey"; +import { useTier } from "@/lib/tier"; + +// A change to how the account signs in (a passkey added or removed, the email +// changed) needs this session to have proven a factor in the last few minutes, +// so a browser left signed in cannot quietly swap the account's ways in. The API +// refuses such a change with 403 reauth_required; useReauth catches that, asks +// for the proof in this dialog and runs the change again once it is given. + +export function isReauthRequired(e: unknown): boolean { + return (e as { code?: string } | null)?.code === "reauth_required"; +} + +/** isReauthCancelled: the confirmation was closed without a proof. Nothing + * failed, so callers show no error for it. */ +export function isReauthCancelled(e: unknown): boolean { + return (e as { code?: string } | null)?.code === "reauth_cancelled"; +} + +export function useReauth() { + const [open, setOpen] = useState(false); + const settle = useRef<((ok: boolean) => void) | null>(null); + + const finish = useCallback((ok: boolean) => { + setOpen(false); + const resolve = settle.current; + settle.current = null; + resolve?.(ok); + }, []); + + // confirm opens the dialog and resolves true once a factor is proven, false + // when it is closed without one. + const confirm = useCallback(() => { + settle.current?.(false); + setOpen(true); + return new Promise((resolve) => { + settle.current = resolve; + }); + }, []); + + // guard runs a change. Refused for want of a fresh proof, it asks for one and + // runs the change a second time; closing the dialog rejects with + // reauth_cancelled. A change that needs a user gesture of its own (a WebAuthn + // create) uses confirm and lets the user press its button again instead. + const guard = useCallback( + async (change: () => Promise): Promise => { + try { + return await change(); + } catch (e) { + if (!isReauthRequired(e)) throw e; + if (!(await confirm())) throw clientError("reauth_cancelled"); + return change(); + } + }, + [confirm], + ); + + const dialog = finish(true)} onCancel={() => finish(false)} />; + return { guard, confirm, dialog }; +} + +function ReauthDialog({ open, onDone, onCancel }: { open: boolean; onDone: () => void; onCancel: () => void }) { + const { t } = useTranslation("account"); + return ( + !next && onCancel()}> + + + + + {t("reauth_title")} + + {t("reauth_desc")} + + {/* Mounted per opening, so every confirmation starts from a fresh status. */} + {open && } + + + ); +} + +type Pending = "passkey" | "send" | "verify" | "sign_in"; + +function ReauthBody({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) { + const { t } = useTranslation("account"); + const { identity, refresh } = useTier(); + const status = useAsync(() => api.reauthStatus(), []); + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + const [codeSent, setCodeSent] = useState(false); + const [code, setCode] = useState(""); + + // Proven meanwhile (in another tab, say): there is nothing to ask. + const proven = status.data?.needed === false; + useEffect(() => { + if (proven) onDone(); + }, [proven, onDone]); + + async function act(kind: Pending, fn: () => Promise) { + if (pending) return; + setPending(kind); + setError(null); + try { + await fn(); + } catch (e) { + setError(humanizeError(e)); + } finally { + setPending(null); + } + } + + const withPasskey = () => + act("passkey", async () => { + const options = await api.reauthPasskeyBegin(); + await api.reauthPasskeyFinish(await requestAssertion(options.publicKey)); + onDone(); + }); + + const sendCode = () => + act("send", async () => { + await api.reauthEmailStart(); + setCodeSent(true); + setCode(""); + }); + + const verifyCode = (e: FormEvent) => { + e.preventDefault(); + const trimmed = code.trim(); + if (!trimmed) return; + void act("verify", async () => { + await api.reauthEmailVerify(trimmed); + onDone(); + }); + }; + + // A fresh sign-in counts as the proof. Signing out flips the session to + // signed-out and the route guard takes the browser to the sign-in page. + const signInAgain = () => + act("sign_in", async () => { + await api.logout(); + await refresh(); + }); + + if (status.loading && !status.data) { + return ( +
+ + {t("reauth_checking")} +
+ ); + } + if (status.error || !status.data || proven) { + return ( + <> + + + + {status.error ? ( + + ) : null} + + + ); + } + + const factors = status.data.factors; + const email = identity?.email ?? ""; + const options: React.ReactNode[] = []; + + if (factors.includes("passkey")) { + options.push( + , + ); + } + if (factors.includes("email")) { + options.push( + codeSent ? ( +
+ +
+ setCode(e.target.value)} + disabled={pending !== null} + maxLength={6} + autoFocus + className="font-mono text-center tracking-[0.2em]" + /> + +
+ +
+ ) : ( + + ), + ); + } + if (factors.includes("sign_in")) { + options.push( +
+

{t("reauth_sign_in_desc")}

+ +
, + ); + } + + return ( + <> +
+ {options.length === 0 ? ( + + ) : ( + options.flatMap((option, i) => + i === 0 + ? [option] + : [ +
+ + {t("reauth_or")} + +
, + option, + ], + ) + )} + +
+ + + + + ); +} diff --git a/panel/src/i18n/resources/en-US/account.json b/panel/src/i18n/resources/en-US/account.json index 989ba58..88a0d86 100644 --- a/panel/src/i18n/resources/en-US/account.json +++ b/panel/src/i18n/resources/en-US/account.json @@ -89,5 +89,20 @@ "migration_redeem_placeholder": "Transfer code", "migration_redeeming": "Redeeming…", "migration_redeem_btn": "Redeem", - "migration_redeemed": "Migration complete — {{count}} server(s) moved to this account." + "migration_redeemed": "Migration complete — {{count}} server(s) moved to this account.", + "reauth_title": "Confirm it's you", + "reauth_desc": "Adding or removing a passkey and changing the email need a fresh check with a way in you already have. The check lasts 5 minutes.", + "reauth_checking": "Checking how you can confirm…", + "reauth_passkey_btn": "Use a passkey", + "reauth_passkey_waiting": "Waiting for your passkey…", + "reauth_or": "or", + "reauth_email_btn": "Email a code to {{email}}", + "reauth_code_label": "Code sent to {{email}}", + "reauth_confirm_btn": "Confirm", + "reauth_confirming": "Confirming…", + "reauth_resend": "Send another code", + "reauth_sign_in_desc": "Operator accounts can also sign out and sign in again. A fresh sign-in counts for 5 minutes.", + "reauth_sign_in_btn": "Sign out and sign in again", + "reauth_done_continue": "Confirmed. Press Continue to add the passkey.", + "email_change_desc": "Enter the new address and we'll send it a code. Once it's verified, sign-in codes go there, the old address gets a notice, and your other devices are signed out." } diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 9a497c9..166ec11 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -78,6 +78,9 @@ "passkey_login_failed": "Passkey verification failed — try again.", "passkey_login_invalid": "That Passkey sign-in request is invalid or expired — start it again.", "too_many_challenges": "Too many verification attempts right now — try again shortly.", + "reauth_required": "Confirm it's you first: this change needs your Passkey or an email code from the last few minutes.", + "staff_reauth": "Operator accounts confirm with a Passkey or by signing in again.", + "no_session": "This only works in a browser signed in to Felis.", "op_login_invalid": "This operator sign-in couldn't be completed — restart the sign-in.", "op_login_not_found": "No pending operator sign-in with that id.", "too_many_streams": "Too many live streams are open — close some pages and try again.", diff --git a/panel/src/i18n/resources/zh-CN/account.json b/panel/src/i18n/resources/zh-CN/account.json index 38c5bb9..644c10e 100644 --- a/panel/src/i18n/resources/zh-CN/account.json +++ b/panel/src/i18n/resources/zh-CN/account.json @@ -88,5 +88,20 @@ "migration_redeem_placeholder": "转移码", "migration_redeeming": "兑换中…", "migration_redeem_btn": "兑换", - "migration_redeemed": "迁移完成——已有 {{count}} 台服务器转移至本账户。" + "migration_redeemed": "迁移完成——已有 {{count}} 台服务器转移至本账户。", + "reauth_title": "确认是你本人", + "reauth_desc": "添加或删除 Passkey、修改邮箱前,需要用你已有的登录方式再验证一次,验证后 5 分钟内有效。", + "reauth_checking": "正在查看可用的验证方式…", + "reauth_passkey_btn": "用 Passkey 验证", + "reauth_passkey_waiting": "等待 Passkey…", + "reauth_or": "或", + "reauth_email_btn": "发送验证码到 {{email}}", + "reauth_code_label": "验证码已发送到 {{email}}", + "reauth_confirm_btn": "确认", + "reauth_confirming": "确认中…", + "reauth_resend": "重新发送验证码", + "reauth_sign_in_desc": "管理员账户也可以退出后重新登录,重新登录后 5 分钟内视为已验证。", + "reauth_sign_in_btn": "退出并重新登录", + "reauth_done_continue": "已确认。点“继续”添加 Passkey。", + "email_change_desc": "输入新邮箱,我们会向新地址发送验证码。验证通过后登录验证码改发到新邮箱,旧邮箱会收到通知,其它设备会退出登录。" } diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index c147760..17284ec 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -78,6 +78,9 @@ "passkey_login_failed": "Passkey 验证失败——请重试。", "passkey_login_invalid": "Passkey 登录请求无效或已过期——请重新发起。", "too_many_challenges": "验证请求过于频繁——请稍后再试。", + "reauth_required": "请先确认是你本人:这项更改需要你在几分钟内用 Passkey 或邮箱验证码验证过。", + "staff_reauth": "管理员账户须用 Passkey 或重新登录来确认身份。", + "no_session": "只能在已登录 Felis 的浏览器中进行此操作。", "op_login_invalid": "本次管理员登录未能完成——请重新发起登录。", "op_login_not_found": "找不到该管理员登录请求。", "too_many_streams": "同时打开的实时连接过多——请关闭一些页面后再试。", diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 41ad006..cb09d9b 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -1015,4 +1015,16 @@ describe("copy for the generic server codes", () => { expect(err.status).toBe(0); expect(humanizeError(err)).toBe("The browser returned no passkey. Try again."); }); + + it("words the re-authentication refusals", () => { + expect(humanizeError({ status: 403, code: "reauth_required", message: "raw" })).toBe( + "Confirm it's you first: this change needs your Passkey or an email code from the last few minutes.", + ); + expect(humanizeError({ status: 403, code: "staff_reauth", message: "raw" })).toBe( + "Operator accounts confirm with a Passkey or by signing in again.", + ); + expect(humanizeError({ status: 400, code: "no_session", message: "raw" })).toBe( + "This only works in a browser signed in to Felis.", + ); + }); }); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 7e7607b..f16e67f 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -590,6 +590,26 @@ export const api = rejectingSync({ revokeMyOtherSessions: () => request<{ revoked: number }>("POST", "/account/sessions/revoke-others"), + // Re-authentication. Adding or removing a passkey and changing the email are + // refused with 403 reauth_required unless this session proved a factor in the + // last few minutes. Status names the factors that can prove it: "passkey", + // "email" (a code to the verified address) or "sign_in" (operators sign in + // again). Like the migrate begin, the passkey begin returns the raw + // {"publicKey": {...}} document. + reauthStatus: () => + request<{ needed: boolean; until?: string; factors: string[] }>("GET", "/account/reauth"), + + reauthPasskeyBegin: () => request("POST", "/account/reauth/passkey/begin"), + + reauthPasskeyFinish: (assertion: any) => + request<{ ok: boolean; until: string }>("POST", "/account/reauth/passkey/finish", { assertion }), + + reauthEmailStart: () => + request<{ sent: boolean; expires_at: string }>("POST", "/account/reauth/email/start"), + + reauthEmailVerify: (code: string) => + request<{ ok: boolean; until: string }>("POST", "/account/reauth/email/verify", { code }), + // Account migration (spec §B3 inherit). Started in-game with /felis migrate; the // web side then drives: status → step-up confirm (passkey when enrolled, email-OTP // otherwise) → issue-code (source names the target account and reads the one-time @@ -982,6 +1002,13 @@ export function humanizeError(e: unknown): string { return t("passkey_login_invalid"); case "too_many_challenges": return t("too_many_challenges"); + // Re-authentication before a change to how the account signs in. + case "reauth_required": + return t("reauth_required"); + case "staff_reauth": + return t("staff_reauth"); + case "no_session": + return t("no_session"); // Operator-login approvals, live streams, and the remaining auth doors. case "op_login_invalid": return t("op_login_invalid"); diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index ce0762d..58c3a22 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -1363,7 +1363,7 @@ export interface paths { put?: never; /** * Mint and deliver an email one-time code for the caller (web onboarding, spec §B2). - * @description Generates a one-time code bound to the authenticated principal and the supplied address, persists only its hash, and delivers it out of band. The code is never returned in the response. A re-request supersedes the prior unconsumed code. + * @description Generates a one-time code bound to the authenticated principal and the supplied address, persists only its hash, and delivers it out of band. The code is never returned in the response. A re-request supersedes the prior unconsumed code. Once the account has a passkey or a verified email, the session must have reauthed within 5 minutes (403 reauth_required). */ post: operations["emailOtpStart"]; delete?: never; @@ -1383,7 +1383,7 @@ export interface paths { put?: never; /** * Redeem an email one-time code and mark the caller's email verified (spec §B2). - * @description Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. When the new address replaces a different verified one, every other session of the caller is signed out: sign-in codes now go to the new address, so a session opened through the old one ends. Too many incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, consumed, or mismatched code is a 400. + * @description Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. When the new address replaces a different verified one, every other session of the caller is signed out: sign-in codes now go to the new address, so a session opened through the old one ends; the old address is mailed a notice with the new one masked. A verified code also counts as a reauth for this session. Too many incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, consumed, or mismatched code is a 400. */ post: operations["emailOtpVerify"]; delete?: never; @@ -1403,7 +1403,7 @@ export interface paths { put?: never; /** * Record the caller's email WITHOUT verifying it (setup bootstrap, spec §B2). - * @description Writes the supplied address to the authenticated principal's user row and clears email_verified (already false for a fresh Owner). The setup bootstrap has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP flow proves control of the address via /account/email/verify. + * @description Writes the supplied address to the authenticated principal's user row and clears email_verified (already false for a fresh Owner). The setup bootstrap has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP flow proves control of the address via /account/email/verify. Clearing a verified address strips a factor, so once the account has one the session must have reauthed within 5 minutes (403 reauth_required). */ post: operations["setEmail"]; delete?: never; @@ -1423,7 +1423,7 @@ export interface paths { put?: never; /** * Begin a passkey (WebAuthn) registration ceremony for the caller (spec §14, Phase 6 bind). - * @description Mints a credential-creation challenge bound to the authenticated principal, stashes the server-side ceremony state under a short TTL, and returns the WebAuthn publicKey creation options for navigator.credentials.create(). The challenge is never echoed by the client. Enrollment only — passkey login is a deferred slice. 503 when the WebAuthn verifier is not configured on this instance. + * @description Mints a credential-creation challenge bound to the authenticated principal, stashes the server-side ceremony state under a short TTL, and returns the WebAuthn publicKey creation options for navigator.credentials.create(). The challenge is never echoed by the client. Once the account has a passkey or a verified email, the session must have reauthed within 5 minutes (403 reauth_required). 503 when the WebAuthn verifier is not configured on this instance. */ post: operations["passkeyRegisterBegin"]; delete?: never; @@ -1443,7 +1443,7 @@ export interface paths { put?: never; /** * Finish a passkey registration ceremony and bind the credential (spec §14, Phase 6 bind). - * @description Consumes the caller's live registration challenge (single-use), verifies the authenticator's attestation against the server-stashed ceremony state, and persists the public credential. A missing or expired ceremony is a 400; an attestation that fails verification is a 400; a credential already bound to any account is a 409. 503 when the WebAuthn verifier is not configured. + * @description Consumes the caller's live registration challenge (single-use), verifies the authenticator's attestation against the server-stashed ceremony state, and persists the public credential. A missing or expired ceremony is a 400; an attestation that fails verification is a 400; a credential already bound to any account is a 409. The verified email is mailed a notice, and the ceremony counts as a reauth for this session. 503 when the WebAuthn verifier is not configured. */ post: operations["passkeyRegisterFinish"]; delete?: never; @@ -1484,7 +1484,7 @@ export interface paths { post?: never; /** * Unbind one of the caller's passkeys (spec §14, Phase 6 bind). - * @description Removes a passkey scoped to the authenticated principal, so a caller can only unbind their OWN credential. An unknown or cross-user id is a 404; it never silently no-ops as success. The account's only passkey cannot be removed while its email is unverified (409 last_passkey): it is then the account's only durable way in. Removing a passkey signs out every other session of the caller, so a session opened with that passkey ends with it. + * @description Removes a passkey scoped to the authenticated principal, so a caller can only unbind their OWN credential. An unknown or cross-user id is a 404; it never silently no-ops as success. The account's only passkey cannot be removed while its email is unverified (409 last_passkey): it is then the account's only durable way in. Removing a passkey signs out every other session of the caller, so a session opened with that passkey ends with it, and mails the verified email a notice. The session must have reauthed within 5 minutes (403 reauth_required). */ delete: operations["passkeyDelete"]; options?: never; @@ -1492,6 +1492,103 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/account/reauth": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Say whether a passkey or email change needs a reauth first, and how to give one. + * @description needed is true when the account has a passkey or a verified email and this session has not proven one within the last 5 minutes. until is when the current proof stops counting. factors lists the ways this caller can reauth, best first: passkey (an enrolled passkey), email (a player's verified address), sign_in (an operator signs out and back in through op-login or a passkey). + */ + get: operations["reauthStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/reauth/passkey/begin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Begin a passkey assertion that reauths this session. + * @description Returns WebAuthn assertion request options over the caller's own passkeys, bound to a fresh reauth-purpose challenge. + */ + post: operations["reauthPasskeyBegin"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/reauth/passkey/finish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Finish the passkey assertion and mark this session reauthed for 5 minutes. + * @description Verifies the assertion against the reauth challenge with the login door's clone check (a cloned authenticator is 400 passkey_login_invalid). + */ + post: operations["reauthPasskeyFinish"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/reauth/email/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Mail a reauth code to the caller's verified address. + * @description For players with a verified email. Operators reauth with a passkey or by signing in again (403 staff_reauth). + */ + post: operations["reauthEmailStart"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/account/reauth/email/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Redeem the reauth code and mark this session reauthed for 5 minutes. */ + post: operations["reauthEmailVerify"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/account/sessions": { parameters: { query?: never; @@ -2339,6 +2436,29 @@ export interface components { "application/json": components["schemas"]["Error"]; }; }; + /** @description This session is reauthed until the returned time. */ + Reauthed: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + ok: true; + /** Format: date-time */ + until: string; + }; + }; + }; + /** @description reauth_required: this change adds, removes or moves a way into the account, and the account has a passkey or a verified email, so the session must have proven one of them within the last 5 minutes. Signing in by passkey, email code, op-login or the setup token counts; a bind-code sign-in does not. GET /api/v1/account/reauth lists the factors that can give the proof, then retry the change. */ + ReauthRequired: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description The configured SMTP relay refused the message (code mail_undeliverable), so no code was delivered. Distinct from 500 because the fault is in the install's [smtp] settings, not in the request or the platform — most often a From address the relay will not let this account send as. The relay's own text is deliberately withheld (it names the SMTP account) and written to the felis-api log instead, keyed by the same request_id this response carries. Retrying the same address changes nothing until an operator fixes the relay. */ MailUndeliverable: { headers: { @@ -5765,6 +5885,7 @@ export interface operations { }; }; 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["ReauthRequired"]; /** @description Resend requested before the cooldown elapsed (otp_resend_cooldown); or the account spent its daily wrong-code budget (otp_account_locked, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */ 429: { headers: { @@ -5865,6 +5986,7 @@ export interface operations { }; }; 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["ReauthRequired"]; }; }; passkeyRegisterBegin: { @@ -5886,6 +6008,7 @@ export interface operations { }; }; 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["ReauthRequired"]; /** @description Passkey subsystem is not configured. */ 503: { headers: { @@ -5997,6 +6120,7 @@ export interface operations { content?: never; }; 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["ReauthRequired"]; /** @description No such passkey for this caller. */ 404: { headers: { @@ -6017,6 +6141,211 @@ export interface operations { }; }; }; + reauthStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Where the caller stands. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + needed: boolean; + /** Format: date-time */ + until?: string; + factors: ("passkey" | "email" | "sign_in")[]; + }; + }; + }; + 401: components["responses"]["Unauthorized"]; + }; + }; + reauthPasskeyBegin: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + /** @description The caller has no enrolled passkey (no_passkey), or no browser session to mark (no_session). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + reauthPasskeyFinish: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description The navigator.credentials.get() PublicKeyCredential assertion. */ + assertion: Record; + }; + }; + }; + responses: { + 200: components["responses"]["Reauthed"]; + /** @description Assertion invalid, challenge stale, or a cloned authenticator (passkey_login_invalid); no browser session (no_session). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + reauthEmailStart: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Code minted and dispatched. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @constant */ + sent: true; + /** Format: date-time */ + expires_at: string; + }; + }; + }; + /** @description No browser session to mark (no_session). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description Operators cannot reauth by email (staff_reauth). */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The account has no verified email (no_step_up_factor). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Resend requested before the cooldown elapsed (otp_resend_cooldown); or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 502: components["responses"]["MailUndeliverable"]; + }; + }; + reauthEmailVerify: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + code: string; + }; + }; + }; + responses: { + 200: components["responses"]["Reauthed"]; + /** @description Invalid or expired code (invalid_code), or no browser session (no_session). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + /** @description Operators cannot reauth by email (staff_reauth). */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The account has no verified email (no_step_up_factor). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Too many incorrect attempts on this code (otp_locked), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; listMySessions: { parameters: { query?: never; diff --git a/panel/src/lib/passkey.ts b/panel/src/lib/passkey.ts new file mode 100644 index 0000000..7c66e82 --- /dev/null +++ b/panel/src/lib/passkey.ts @@ -0,0 +1,31 @@ +import { clientError } from "@/lib/api"; +import { base64urlToBytes, bytesToBase64url } from "@/lib/utils"; + +/** requestAssertion runs the WebAuthn get() ceremony for the server's request + * options (their JSON form: challenge and credential ids base64url) and returns + * the assertion in the JSON form the API's finish endpoints decode. Begins that + * answer with go-webauthn's {"publicKey": {...}} envelope pass the inner object. */ +export async function requestAssertion(options: any) { + const publicKey: PublicKeyCredentialRequestOptions = { + ...options, + challenge: base64urlToBytes(options.challenge), + allowCredentials: options.allowCredentials?.map((cred: any) => ({ + ...cred, + id: base64urlToBytes(cred.id), + })), + }; + const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null; + if (!credential) throw clientError("passkey_no_credential"); + const response = credential.response as AuthenticatorAssertionResponse; + return { + id: credential.id, + rawId: bytesToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bytesToBase64url(response.clientDataJSON), + authenticatorData: bytesToBase64url(response.authenticatorData), + signature: bytesToBase64url(response.signature), + userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null, + }, + }; +} diff --git a/panel/src/pages/Account.tsx b/panel/src/pages/Account.tsx index da75221..7c07d4c 100644 --- a/panel/src/pages/Account.tsx +++ b/panel/src/pages/Account.tsx @@ -14,8 +14,10 @@ import { formatAbsolute } from "@/lib/format"; import type { PasskeyCredential } from "@/lib/types"; import { useAsync } from "@/lib/hooks"; import { AccountSessionsCard } from "@/pages/AccountSessions"; +import { isReauthCancelled, isReauthRequired, useReauth } from "@/components/ReauthDialog"; import { useTier } from "@/lib/tier"; import { base64urlToBytes, bytesToBase64url } from "@/lib/utils"; +import { requestAssertion } from "@/lib/passkey"; import { Dialog, DialogContent, @@ -36,8 +38,12 @@ export function Account() { const status = useAsync(() => api.linkStatus(), []); const { identity, refresh } = useTier(); const { t, i18n } = useTranslation("account"); + // Adding or removing a passkey and changing the email ask for a fresh proof + // of a factor first (ReauthDialog). + const reauth = useReauth(); - // Email verification state + // Email verification state. A verified address is changed through the same + // two steps, opened with changingEmail. const [emailInput, setEmailInput] = useState(""); const [otpCodeInput, setOtpCodeInput] = useState(""); const [emailSending, setEmailSending] = useState(false); @@ -46,6 +52,7 @@ export function Account() { const [emailSent, setEmailSent] = useState(false); const [sentEmailAddress, setSentEmailAddress] = useState(""); const [initializedEmail, setInitializedEmail] = useState(false); + const [changingEmail, setChangingEmail] = useState(false); useEffect(() => { if (identity?.email && !initializedEmail) { @@ -61,16 +68,30 @@ export function Account() { setEmailSending(true); setEmailError(null); try { - await api.emailStart(trimmed); + await reauth.guard(() => api.emailStart(trimmed)); setEmailSent(true); setSentEmailAddress(trimmed); } catch (err) { - setEmailError(humanizeError(err)); + if (!isReauthCancelled(err)) setEmailError(humanizeError(err)); } finally { setEmailSending(false); } } + function startChangeEmail() { + setChangingEmail(true); + setEmailInput(""); + setEmailSent(false); + setOtpCodeInput(""); + setEmailError(null); + } + + function cancelChangeEmail() { + setChangingEmail(false); + setEmailSent(false); + setEmailError(null); + } + async function verifyEmailOtp(e: FormEvent) { e.preventDefault(); const trimmedCode = otpCodeInput.trim(); @@ -80,6 +101,11 @@ export function Account() { try { await api.emailVerify(trimmedCode); await refresh(); + if (changingEmail) { + // Replacing a verified address signs the other devices out. + setChangingEmail(false); + setSessionsVersion((v) => v + 1); + } setEmailSent(false); setEmailInput(""); setOtpCodeInput(""); @@ -97,6 +123,7 @@ export function Account() { const [passkeyNickname, setPasskeyNickname] = useState(""); const [registeringPasskey, setRegisteringPasskey] = useState(false); const [passkeyError, setPasskeyError] = useState(null); + const [passkeyNotice, setPasskeyNotice] = useState(null); const [registerDialogOpen, setRegisterDialogOpen] = useState(false); // Deleting a passkey goes through a confirm dialog that names it. The API // refuses to remove the only passkey of an account whose email is unverified @@ -118,6 +145,7 @@ export function Account() { setRegisterDialogOpen(false); setPasskeyNickname(""); setPasskeyError(null); + setPasskeyNotice(null); setRegisteringPasskey(false); } @@ -127,12 +155,22 @@ export function Account() { if (!name || registeringPasskey) return; setRegisteringPasskey(true); setPasskeyError(null); + setPasskeyNotice(null); const controller = new AbortController(); abortControllerRef.current = controller; try { - const options = await api.passkeyRegisterBegin(); + let options; + try { + options = await api.passkeyRegisterBegin(); + } catch (err) { + if (!isReauthRequired(err)) throw err; + // The browser lets navigator.credentials.create run only right after a + // click, which the confirmation used up: the user presses Continue again. + if (await reauth.confirm()) setPasskeyNotice(t("reauth_done_continue")); + return; + } const publicKey: PublicKeyCredentialCreationOptions = { ...options, challenge: base64urlToBytes(options.challenge), @@ -189,12 +227,13 @@ export function Account() { setDeletingPasskey(true); setDeleteError(null); try { - await api.passkeyDelete(pendingDelete.id); + await reauth.guard(() => api.passkeyDelete(pendingDelete.id)); setPendingDelete(null); // The server signed the other devices out along with the passkey. setSessionsVersion((v) => v + 1); await passkeys.reload(); } catch (err) { + if (isReauthCancelled(err)) return; // Another device may have changed the list meanwhile: refresh it. A 404 // means the passkey is already gone, which is what was asked for. void passkeys.reload(); @@ -290,17 +329,20 @@ export function Account() { - {identity?.email_verified ? ( + {identity?.email_verified && !changingEmail ? (
{t("email_verified")}

{t("email_desc")}

-
+
{identity.email} +
) : ( @@ -309,7 +351,7 @@ export function Account() {

{t("email_step1")}

-

{t("email_step1_desc")}

+

{t(changingEmail ? "email_change_desc" : "email_step1_desc")}

{!emailSent ? (
{emailSending ? t("sending_code") : t("send_code")} + {changingEmail && ( + + )}
) : (
@@ -409,6 +456,7 @@ export function Account() { required />
+ {passkeyNotice && }
@@ -518,6 +566,8 @@ export function Account() { onSignOut={() => void signOut()} signingOut={signingOut} /> + + {reauth.dialog} ); } @@ -654,29 +704,7 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo function confirmWithPasskey() { void run(async () => { const options = await api.migrateConfirmPasskeyBegin(); - const pk = options.publicKey; - const publicKey: PublicKeyCredentialRequestOptions = { - ...pk, - challenge: base64urlToBytes(pk.challenge), - allowCredentials: pk.allowCredentials?.map((cred: any) => ({ - ...cred, - id: base64urlToBytes(cred.id), - })), - }; - const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential; - if (!credential) throw clientError("passkey_no_credential"); - const response = credential.response as AuthenticatorAssertionResponse; - await api.migrateConfirmPasskeyFinish({ - id: credential.id, - rawId: bytesToBase64url(credential.rawId), - type: credential.type, - response: { - clientDataJSON: bytesToBase64url(response.clientDataJSON), - authenticatorData: bytesToBase64url(response.authenticatorData), - signature: bytesToBase64url(response.signature), - userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null, - }, - }); + await api.migrateConfirmPasskeyFinish(await requestAssertion(options.publicKey)); await mig.reload(); }); } diff --git a/panel/src/pages/AccountReauth.test.tsx b/panel/src/pages/AccountReauth.test.tsx new file mode 100644 index 0000000..89f2e78 --- /dev/null +++ b/panel/src/pages/AccountReauth.test.tsx @@ -0,0 +1,303 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import type { Identity, PasskeyCredential } from "@/lib/types"; +import { Account } from "./Account"; + +// The Account page's changes to how the account signs in (delete or add a +// passkey, change the email) meet 403 reauth_required when the session has not +// proven a factor lately. These drive the page through that refusal: the check +// dialog, each factor, and the change running again afterwards. + +const mocks = vi.hoisted(() => ({ + passkeyList: vi.fn(), + passkeyDelete: vi.fn(), + passkeyRegisterBegin: vi.fn(), + listMySessions: vi.fn(), + emailStart: vi.fn(), + emailVerify: vi.fn(), + reauthStatus: vi.fn(), + reauthPasskeyBegin: vi.fn(), + reauthPasskeyFinish: vi.fn(), + reauthEmailStart: vi.fn(), + reauthEmailVerify: vi.fn(), + logout: vi.fn(), + refresh: vi.fn(), + credentialsGet: vi.fn(), + credentialsCreate: vi.fn(), + identity: null as Identity | null, +})); + +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + api: { + ...actual.api, + linkStatus: () => Promise.resolve({ linked: true }), + migrateStatus: () => Promise.resolve({ active: false }), + passkeyList: mocks.passkeyList, + passkeyDelete: mocks.passkeyDelete, + passkeyRegisterBegin: mocks.passkeyRegisterBegin, + listMySessions: mocks.listMySessions, + emailStart: mocks.emailStart, + emailVerify: mocks.emailVerify, + reauthStatus: mocks.reauthStatus, + reauthPasskeyBegin: mocks.reauthPasskeyBegin, + reauthPasskeyFinish: mocks.reauthPasskeyFinish, + reauthEmailStart: mocks.reauthEmailStart, + reauthEmailVerify: mocks.reauthEmailVerify, + logout: mocks.logout, + }, + }; +}); +vi.mock("@/lib/tier", () => ({ + useTier: () => ({ identity: mocks.identity, refresh: mocks.refresh }), +})); + +const refused = { status: 403, code: "reauth_required", message: "confirm it's you first" }; +const laptop: PasskeyCredential = { id: "pk-1", name: "Laptop", created_at: "2026-03-01T10:00:00Z" }; +const phone: PasskeyCredential = { id: "pk-2", name: "Phone", created_at: "2026-04-01T10:00:00Z" }; + +function identity(role: Identity["role"] = "user"): Identity { + return { + user_id: "u-1", + email: "a@example.com", + role, + is_admin: role !== "user", + is_owner: role === "owner", + email_verified: true, + }; +} + +const bytes = (s: string) => new TextEncoder().encode(s).buffer; + +function renderAccount() { + return render( + + + , + ); +} + +const checkDialog = () => screen.findByRole("dialog", { name: "Confirm it's you" }); + +async function askToDeleteLaptop() { + await userEvent.click(await screen.findByRole("button", { name: "Delete passkey “Laptop”" })); + const confirmDelete = screen.getByRole("dialog", { name: "Delete this passkey?" }); + await userEvent.click(within(confirmDelete).getByRole("button", { name: "Delete" })); +} + +async function proveByEmail(dialog: HTMLElement) { + await userEvent.click(await within(dialog).findByRole("button", { name: "Email a code to a@example.com" })); + await userEvent.type(await within(dialog).findByLabelText("Code sent to a@example.com"), "123456"); + await userEvent.click(within(dialog).getByRole("button", { name: "Confirm" })); +} + +beforeEach(() => { + for (const fn of Object.values(mocks)) if (typeof fn === "function") fn.mockReset(); + mocks.identity = identity(); + mocks.listMySessions.mockResolvedValue([]); + mocks.reauthEmailStart.mockResolvedValue({ sent: true, expires_at: "2026-09-25T10:10:00Z" }); + mocks.reauthEmailVerify.mockResolvedValue({ ok: true, until: "2026-09-25T10:05:00Z" }); + mocks.reauthPasskeyFinish.mockResolvedValue({ ok: true, until: "2026-09-25T10:05:00Z" }); + Object.defineProperty(navigator, "credentials", { + value: { get: mocks.credentialsGet, create: mocks.credentialsCreate }, + configurable: true, + }); +}); + +describe("Account re-authentication", () => { + it("asks for an email code before deleting a passkey, then deletes it", async () => { + mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] }); + mocks.passkeyDelete.mockRejectedValueOnce(refused).mockResolvedValue(undefined); + mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] }); + renderAccount(); + + await askToDeleteLaptop(); + const dialog = await checkDialog(); + expect(within(dialog).queryByRole("button", { name: "Use a passkey" })).toBeNull(); + await proveByEmail(dialog); + + expect(mocks.reauthEmailStart).toHaveBeenCalledTimes(1); + expect(mocks.reauthEmailVerify).toHaveBeenCalledWith("123456"); + await waitFor(() => expect(mocks.passkeyDelete).toHaveBeenCalledTimes(2)); + expect(mocks.passkeyDelete).toHaveBeenLastCalledWith("pk-1"); + await waitFor(() => expect(screen.queryByRole("button", { name: "Delete passkey “Laptop”" })).toBeNull()); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("closing the check keeps the passkey and the delete dialog, with no error", async () => { + mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] }); + mocks.passkeyDelete.mockRejectedValue(refused); + mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] }); + renderAccount(); + + await askToDeleteLaptop(); + const dialog = await checkDialog(); + await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Confirm it's you" })).toBeNull()); + const confirmDelete = screen.getByRole("dialog", { name: "Delete this passkey?" }); + expect(within(confirmDelete).queryByRole("alert")).toBeNull(); + expect(mocks.passkeyDelete).toHaveBeenCalledTimes(1); + expect(mocks.passkeyList).toHaveBeenCalledTimes(1); + }); + + it("a wrong code keeps the check open with the reason and changes nothing", async () => { + mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] }); + mocks.passkeyDelete.mockRejectedValue(refused); + mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] }); + mocks.reauthEmailVerify.mockRejectedValue({ status: 400, code: "invalid_code", message: "raw" }); + renderAccount(); + + await askToDeleteLaptop(); + const dialog = await checkDialog(); + await proveByEmail(dialog); + + const alert = await within(dialog).findByRole("alert"); + expect(alert.textContent).toBe("That code is invalid or expired — request a fresh one and try again."); + expect(mocks.passkeyDelete).toHaveBeenCalledTimes(1); + }); + + it("proves with a passkey from the envelope the begin returns", async () => { + mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] }); + mocks.passkeyDelete.mockRejectedValueOnce(refused).mockResolvedValue(undefined); + mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["passkey", "email"] }); + mocks.reauthPasskeyBegin.mockResolvedValue({ + publicKey: { challenge: "Y2hhbGxlbmdl", allowCredentials: [{ type: "public-key", id: "cGstMQ" }] }, + }); + mocks.credentialsGet.mockResolvedValue({ + id: "cred-1", + rawId: bytes("raw"), + type: "public-key", + response: { + clientDataJSON: bytes("cd"), + authenticatorData: bytes("ad"), + signature: bytes("sig"), + userHandle: null, + }, + }); + renderAccount(); + + await askToDeleteLaptop(); + const dialog = await checkDialog(); + // Both factors are offered, the passkey first. + const buttons = within(dialog).getAllByRole("button").map((b) => b.textContent); + expect(buttons.indexOf("Use a passkey")).toBeLessThan(buttons.indexOf("Email a code to a@example.com")); + await userEvent.click(within(dialog).getByRole("button", { name: "Use a passkey" })); + + await waitFor(() => expect(mocks.passkeyDelete).toHaveBeenCalledTimes(2)); + const publicKey = mocks.credentialsGet.mock.calls[0][0].publicKey; + expect(new TextDecoder().decode(publicKey.challenge)).toBe("challenge"); + expect(new TextDecoder().decode(publicKey.allowCredentials[0].id)).toBe("pk-1"); + expect(mocks.reauthPasskeyFinish).toHaveBeenCalledWith({ + id: "cred-1", + rawId: "cmF3", + type: "public-key", + response: { clientDataJSON: "Y2Q", authenticatorData: "YWQ", signature: "c2ln", userHandle: null }, + }); + expect(mocks.reauthEmailStart).not.toHaveBeenCalled(); + }); + + it("offers operators a fresh sign-in instead of an email code", async () => { + mocks.identity = identity("admin"); + mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] }); + mocks.passkeyDelete.mockRejectedValue(refused); + mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["sign_in"] }); + mocks.logout.mockResolvedValue(undefined); + renderAccount(); + + await askToDeleteLaptop(); + const dialog = await checkDialog(); + expect( + within(dialog).getByText("Operator accounts can also sign out and sign in again. A fresh sign-in counts for 5 minutes."), + ).toBeTruthy(); + expect(within(dialog).queryByRole("button", { name: /Email a code/ })).toBeNull(); + await userEvent.click(within(dialog).getByRole("button", { name: "Sign out and sign in again" })); + + expect(mocks.logout).toHaveBeenCalledTimes(1); + await waitFor(() => expect(mocks.refresh).toHaveBeenCalled()); + }); + + it("goes straight on when the session was proven meanwhile", async () => { + mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] }); + mocks.passkeyDelete.mockRejectedValueOnce(refused).mockResolvedValue(undefined); + mocks.reauthStatus.mockResolvedValue({ needed: false, until: "2026-09-25T10:05:00Z", factors: ["email"] }); + renderAccount(); + + await askToDeleteLaptop(); + + await waitFor(() => expect(mocks.passkeyDelete).toHaveBeenCalledTimes(2)); + expect(mocks.reauthEmailStart).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("after the check, adding a passkey waits for Continue instead of starting the browser prompt", async () => { + mocks.passkeyList.mockResolvedValue({ credentials: [laptop] }); + mocks.passkeyRegisterBegin.mockRejectedValue(refused); + mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] }); + renderAccount(); + + await userEvent.click(await screen.findByRole("button", { name: "Add Passkey" })); + const register = screen.getByRole("dialog", { name: "Add Passkey" }); + await userEvent.type(within(register).getByLabelText("Device Nickname"), "Phone"); + await userEvent.click(within(register).getByRole("button", { name: "Continue" })); + await proveByEmail(await checkDialog()); + + const status = await within(screen.getByRole("dialog", { name: "Add Passkey" })).findByRole("status"); + expect(status.textContent).toBe("Confirmed. Press Continue to add the passkey."); + expect(mocks.passkeyRegisterBegin).toHaveBeenCalledTimes(1); + expect(mocks.credentialsCreate).not.toHaveBeenCalled(); + expect(within(screen.getByRole("dialog", { name: "Add Passkey" })).getByRole("button", { name: "Continue" })).toHaveProperty( + "disabled", + false, + ); + }); + + it("changes a verified email after the check and refreshes the devices it signs out", async () => { + mocks.passkeyList.mockResolvedValue({ credentials: [] }); + mocks.emailStart.mockRejectedValueOnce(refused).mockResolvedValue({ sent: true, expires_at: "2026-09-25T10:10:00Z" }); + mocks.emailVerify.mockResolvedValue({ verified: true, email: "new@example.com" }); + mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] }); + renderAccount(); + + await userEvent.click(await screen.findByRole("button", { name: "Change email" })); + expect( + screen.getByText( + "Enter the new address and we'll send it a code. Once it's verified, sign-in codes go there, the old address gets a notice, and your other devices are signed out.", + ), + ).toBeTruthy(); + const address = screen.getByPlaceholderText("user@example.com"); + expect((address as HTMLInputElement).value).toBe(""); + await userEvent.type(address, "new@example.com"); + await userEvent.click(screen.getByRole("button", { name: "Send Code" })); + await proveByEmail(await checkDialog()); + + await waitFor(() => expect(mocks.emailStart).toHaveBeenCalledTimes(2)); + expect(mocks.emailStart).toHaveBeenLastCalledWith("new@example.com"); + expect(await screen.findByText(/Verification code sent\./)).toBeTruthy(); + + await userEvent.type(screen.getByPlaceholderText("6-digit code"), "654321"); + await userEvent.click(screen.getByRole("button", { name: "Verify" })); + + expect(mocks.emailVerify).toHaveBeenCalledWith("654321"); + await waitFor(() => expect(mocks.listMySessions).toHaveBeenCalledTimes(2)); + expect(mocks.refresh).toHaveBeenCalled(); + }); + + it("cancelling a change of email returns to the verified address", async () => { + mocks.passkeyList.mockResolvedValue({ credentials: [] }); + renderAccount(); + + await userEvent.click(await screen.findByRole("button", { name: "Change email" })); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.getByText("a@example.com")).toBeTruthy(); + expect(screen.queryByPlaceholderText("user@example.com")).toBeNull(); + expect(mocks.emailStart).not.toHaveBeenCalled(); + }); +}); diff --git a/panel/src/pages/Login.tsx b/panel/src/pages/Login.tsx index 03f3ea3..093f09f 100644 --- a/panel/src/pages/Login.tsx +++ b/panel/src/pages/Login.tsx @@ -9,9 +9,9 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTier } from "@/lib/tier"; import { loginReturnPath } from "@/lib/auth"; -import { api, clientError, humanizeError } from "@/lib/api"; +import { api, humanizeError } from "@/lib/api"; import { loadConfig } from "@/lib/config"; -import { base64urlToBytes, bytesToBase64url } from "@/lib/utils"; +import { requestAssertion } from "@/lib/passkey"; import { InlineError } from "@/components/MessageLine"; import { formatCountdown, opLoginDeadline, useOpLoginPoll } from "@/lib/opLoginPoll"; @@ -143,41 +143,10 @@ export function Login() { const identifier = email.trim(); try { - let assertion: any; if (!identifier) { // Discoverable (Usernameless) passkey login const options = await api.authPasskeyDiscoverableBegin(); - const publicKey: PublicKeyCredentialRequestOptions = { - ...options.publicKey, - challenge: base64urlToBytes(options.publicKey.challenge), - allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({ - ...cred, - id: base64urlToBytes(cred.id), - })), - }; - - const credential = (await navigator.credentials.get({ - publicKey, - })) as PublicKeyCredential; - - if (!credential) { - throw clientError("passkey_no_credential"); - } - - const response = credential.response as AuthenticatorAssertionResponse; - assertion = { - id: credential.id, - rawId: bytesToBase64url(credential.rawId), - type: credential.type, - response: { - clientDataJSON: bytesToBase64url(response.clientDataJSON), - authenticatorData: bytesToBase64url(response.authenticatorData), - signature: bytesToBase64url(response.signature), - userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null, - }, - }; - - await api.authPasskeyDiscoverableFinish(options.login_id, assertion); + await api.authPasskeyDiscoverableFinish(options.login_id, await requestAssertion(options.publicKey)); } else { // Email-first passkey login if (!identifier.includes("@")) { @@ -185,37 +154,7 @@ export function Login() { } const options = await api.authPasskeyLoginBegin(identifier); - const publicKey: PublicKeyCredentialRequestOptions = { - ...options, - challenge: base64urlToBytes(options.challenge), - allowCredentials: options.allowCredentials?.map((cred: any) => ({ - ...cred, - id: base64urlToBytes(cred.id), - })), - }; - - const credential = (await navigator.credentials.get({ - publicKey, - })) as PublicKeyCredential; - - if (!credential) { - throw clientError("passkey_no_credential"); - } - - const response = credential.response as AuthenticatorAssertionResponse; - assertion = { - id: credential.id, - rawId: bytesToBase64url(credential.rawId), - type: credential.type, - response: { - clientDataJSON: bytesToBase64url(response.clientDataJSON), - authenticatorData: bytesToBase64url(response.authenticatorData), - signature: bytesToBase64url(response.signature), - userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null, - }, - }; - - await api.authPasskeyLoginFinish(identifier, assertion); + await api.authPasskeyLoginFinish(identifier, await requestAssertion(options)); } await refresh();