feat(api): 添加或删除 passkey、修改邮箱前须 5 分钟内用已有因子重新验证,变更后邮件通知账户,面板加确认对话框与修改邮箱入口
This commit is contained in:
36 files changed
+2735
-288
No files matched your search
+237
-7
@@ -157,6 +157,27 @@ components:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
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:
|
MailUndeliverable:
|
||||||
description: >
|
description: >
|
||||||
The configured SMTP relay refused the message (code mail_undeliverable), so no
|
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
|
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
|
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
|
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-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ accessJWT: [] }]
|
||||||
@@ -4008,6 +4030,8 @@ paths:
|
|||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/ReauthRequired'
|
||||||
'429':
|
'429':
|
||||||
description: >-
|
description: >-
|
||||||
Resend requested before the cooldown elapsed (otp_resend_cooldown); or the
|
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
|
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
|
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
|
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,
|
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
|
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,
|
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
|
Writes the supplied address to the authenticated principal's user row and
|
||||||
clears email_verified (already false for a fresh Owner). The setup bootstrap
|
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
|
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-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ accessJWT: [] }]
|
||||||
@@ -4112,6 +4140,8 @@ paths:
|
|||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/ReauthRequired'
|
||||||
|
|
||||||
/api/v1/account/passkey/register/begin:
|
/api/v1/account/passkey/register/begin:
|
||||||
post:
|
post:
|
||||||
@@ -4122,8 +4152,10 @@ paths:
|
|||||||
Mints a credential-creation challenge bound to the authenticated principal,
|
Mints a credential-creation challenge bound to the authenticated principal,
|
||||||
stashes the server-side ceremony state under a short TTL, and returns the
|
stashes the server-side ceremony state under a short TTL, and returns the
|
||||||
WebAuthn publicKey creation options for navigator.credentials.create(). The
|
WebAuthn publicKey creation options for navigator.credentials.create(). The
|
||||||
challenge is never echoed by the client. Enrollment only — passkey login is a
|
challenge is never echoed by the client. Once the account has a passkey or a
|
||||||
deferred slice. 503 when the WebAuthn verifier is not configured on this instance.
|
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-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ accessJWT: [] }]
|
||||||
@@ -4137,6 +4169,8 @@ paths:
|
|||||||
description: Opaque WebAuthn PublicKeyCredentialCreationOptions, passed verbatim to the browser.
|
description: Opaque WebAuthn PublicKeyCredentialCreationOptions, passed verbatim to the browser.
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/ReauthRequired'
|
||||||
'503':
|
'503':
|
||||||
description: Passkey subsystem is not configured.
|
description: Passkey subsystem is not configured.
|
||||||
content:
|
content:
|
||||||
@@ -4153,7 +4187,9 @@ paths:
|
|||||||
authenticator's attestation against the server-stashed ceremony state, and
|
authenticator's attestation against the server-stashed ceremony state, and
|
||||||
persists the public credential. A missing or expired ceremony is a 400; an
|
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
|
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-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ accessJWT: [] }]
|
||||||
@@ -4231,7 +4267,9 @@ paths:
|
|||||||
silently no-ops as success. The account's only passkey cannot be removed while
|
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
|
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
|
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-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ accessJWT: [] }]
|
||||||
@@ -4246,6 +4284,8 @@ paths:
|
|||||||
description: Passkey unbound.
|
description: Passkey unbound.
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/ReauthRequired'
|
||||||
'404':
|
'404':
|
||||||
description: No such passkey for this caller.
|
description: No such passkey for this caller.
|
||||||
content:
|
content:
|
||||||
@@ -4257,6 +4297,196 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
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:
|
/api/v1/account/sessions:
|
||||||
get:
|
get:
|
||||||
tags: [account]
|
tags: [account]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
// [email protected] → 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:]
|
||||||
|
}
|
||||||
@@ -560,6 +560,16 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
|||||||
{Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish},
|
{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: "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},
|
{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
|
// 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
|
// device and sign out one or all the others. App-tier and scoped to the caller
|
||||||
// inside the handler, like the passkey routes above.
|
// inside the handler, like the passkey routes above.
|
||||||
|
|||||||
@@ -75,10 +75,12 @@ type fakeRepo struct {
|
|||||||
// failSessionUser / failGetSetting force those reads to fail with a generic
|
// failSessionUser / failGetSetting force those reads to fail with a generic
|
||||||
// (non-ErrNotFound) error, simulating a store outage for the 503 auth path.
|
// (non-ErrNotFound) error, simulating a store outage for the 503 auth path.
|
||||||
failSessionUser error
|
failSessionUser error
|
||||||
// failTouchSession / failRevokeOthers force those session writes to fail.
|
// failTouchSession / failRevokeOthers / failMarkReauth force those session
|
||||||
|
// writes to fail.
|
||||||
failTouchSession error
|
failTouchSession error
|
||||||
failRevokeOthers error
|
failRevokeOthers error
|
||||||
failGetSetting error
|
failMarkReauth error
|
||||||
|
failGetSetting error
|
||||||
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
|
// 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.
|
// newest live (user, purpose) just as the PG query does.
|
||||||
otps map[string]*fakeEmailOTP
|
otps map[string]*fakeEmailOTP
|
||||||
@@ -220,6 +222,8 @@ type fakeSession struct {
|
|||||||
userAgent string
|
userAgent string
|
||||||
clientIP string
|
clientIP string
|
||||||
touches int
|
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
|
// 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)
|
now := ns.ExpiresAt.Add(-sessionTTL)
|
||||||
f.sessions[ns.TokenHash] = &fakeSession{
|
f.sessions[ns.TokenHash] = &fakeSession{
|
||||||
userID: ns.UserID, expiresAt: ns.ExpiresAt, createdAt: now, lastSeen: now,
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -951,7 +964,7 @@ func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Tim
|
|||||||
}
|
}
|
||||||
return &SessionedUser{
|
return &SessionedUser{
|
||||||
ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role,
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
func (f *fakeRepo) TouchSession(_ context.Context, tokenHash string, now time.Time) error {
|
func (f *fakeRepo) TouchSession(_ context.Context, tokenHash string, now time.Time) error {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func TestAuditCannotBeSignedWithAnotherPersonsEmail(t *testing.T) {
|
|||||||
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
repo.staff["owner"] = &StaffUser{ID: "u1", Username: "owner", Email: "[email protected]", Role: "owner", EmailVerified: true}
|
repo.staff["owner"] = &StaffUser{ID: "u1", Username: "owner", Email: "[email protected]", Role: "owner", EmailVerified: true}
|
||||||
repo.staff["mallory"] = &StaffUser{ID: "u2", Username: "mallory", Email: "[email protected]", Role: "user", EmailVerified: true}
|
repo.staff["mallory"] = &StaffUser{ID: "u2", Username: "mallory", Email: "[email protected]", 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 := newTestAPI(repo, newFakeCluster())
|
||||||
api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
|
api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
|
||||||
api.ClientIPHeader = "CF-Connecting-IP"
|
api.ClientIPHeader = "CF-Connecting-IP"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"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
|
// passed Zero Trust at the edge, so the local-email-verification gate is not
|
||||||
// the right boundary for them.
|
// the right boundary for them.
|
||||||
ViaSession bool
|
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,
|
// staffRole reports whether a stored user role carries staff standing: admin,
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -25,7 +23,9 @@ import (
|
|||||||
// email-OTP — advancing to 'confirmed'. Mere
|
// email-OTP — advancing to 'confirmed'. Mere
|
||||||
// session possession is never enough; a stolen
|
// session possession is never enough; a stolen
|
||||||
// session cannot read the mailbox nor present the
|
// 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
|
// 3. web issue code + name target → handleMigrateIssueCode: the source names the
|
||||||
// target account by id and mints a one-time code
|
// target account by id and mints a one-time code
|
||||||
// ('code_issued').
|
// ('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
|
// Per-recipient cooldown, namespaced apart from the other OTP doors so they never
|
||||||
// perturb each other's throttle.
|
// perturb each other's throttle.
|
||||||
if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, otpPurposeMigrate, a.now()); err != nil {
|
a.startStepUpOTP(w, r, p, otpPurposeMigrate, "migrate:confirm:", "account.migrate.confirm_otp_sent")
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMigrateConfirmOTPVerify redeems the migration step-up code and, on a match,
|
// 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.
|
// is the login-door one (no identity side-effect): the address is already proven.
|
||||||
func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||||
p := principalFromContext(r.Context())
|
p := principalFromContext(r.Context())
|
||||||
var req migrateConfirmOTPVerifyRequest
|
var req stepUpOTPVerifyRequest
|
||||||
if err := decodeJSON(w, r, &req); err != nil {
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -273,25 +226,7 @@ func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Reque
|
|||||||
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
|
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var lock *OTPAccountLockedError
|
if !a.verifyStepUpOTP(w, r, p, otpPurposeMigrate, "migrate_confirm", code) {
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "email_otp", a.now()); err != nil {
|
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})
|
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
|
// handleMigrateConfirmPasskeyBegin starts a fresh passkey assertion bound to the
|
||||||
// migration step-up (spec §B3, external app face). Unlike the login door it needs no
|
// 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
|
// 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 {
|
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
|
a.beginStepUpPasskey(w, r, p, passkeyPurposeMigrate,
|
||||||
if err != nil {
|
"no passkey enrolled; confirm the migration with an email code")
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMigrateConfirmPasskeyFinish verifies the migration step-up assertion and, on
|
// 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)
|
writeError(w, r, errPasskeyUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req migrateConfirmPasskeyFinishRequest
|
var req stepUpPasskeyFinishRequest
|
||||||
if err := decodeJSON(w, r, &req); err != nil {
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -387,42 +280,7 @@ func (a *API) handleMigrateConfirmPasskeyFinish(w http.ResponseWriter, r *http.R
|
|||||||
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
|
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), p.UserID, passkeyPurposeMigrate, a.now())
|
if !a.finishStepUpPasskey(w, r, p, passkeyPurposeMigrate, "migrate_passkey", req.Assertion) {
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "passkey", a.now()); err != nil {
|
if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "passkey", a.now()); err != nil {
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ func newSessionsFixture(t *testing.T) *sessionsFixture {
|
|||||||
api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
|
api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
|
||||||
now := api.now()
|
now := api.now()
|
||||||
for tok, s := range map[string]*fakeSession{
|
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"},
|
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)},
|
alexTok: {userID: "u2", lastSeen: now.Add(-time.Minute)},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
|
||||||
return
|
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
|
// 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
|
// 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
|
// 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
|
return
|
||||||
}
|
}
|
||||||
a.audit(r, "account.email.verified", "")
|
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
|
// 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) {
|
if p.EmailVerified && !strings.EqualFold(p.Email, email) {
|
||||||
a.revokeOtherSessionsAfter(r, "email change")
|
a.revokeOtherSessionsAfter(r, "email change")
|
||||||
|
a.notifyEmailChanged(r, p.Email, email)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"verified": true, "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"))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
|
||||||
return
|
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 {
|
if err := a.Repo.SetUserEmail(r.Context(), p.UserID, email); err != nil {
|
||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.startSession(w, r, userID); err != nil {
|
if err := a.startSession(w, r, userID, bindCodeSignIn); err != nil {
|
||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
writeError(w, r, newError(http.StatusForbidden, "staff_account", "that account is not an operator"))
|
||||||
return
|
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)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,6 +253,10 @@ func (a *API) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := principalFromContext(r.Context())
|
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)
|
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
@@ -356,6 +360,11 @@ func (a *API) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.audit(r, "account.passkey.registered", cred.ID)
|
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))
|
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"))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "credential id is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !a.requireReauth(w, r, p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := a.Repo.DeletePasskeyCredential(r.Context(), p.UserID, id); err != nil {
|
if err := a.Repo.DeletePasskeyCredential(r.Context(), p.UserID, id); err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
writeError(w, r, newError(http.StatusNotFound, "not_found", "no such passkey"))
|
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.audit(r, "account.passkey.removed", id)
|
||||||
a.revokeOtherSessionsAfter(r, "passkey removal")
|
a.revokeOtherSessionsAfter(r, "passkey removal")
|
||||||
|
a.notifyPasskeyRemoved(r, p)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,7 +667,7 @@ func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt
|
|||||||
return
|
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)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func TestSetEmailClearsVerified(t *testing.T) {
|
|||||||
repo.staff["u"] = &StaffUser{ID: "u1", Username: "u", Role: "admin", Email: "[email protected]", EmailVerified: true}
|
repo.staff["u"] = &StaffUser{ID: "u1", Username: "u", Role: "admin", Email: "[email protected]", EmailVerified: true}
|
||||||
|
|
||||||
api := newTestAPI(repo, newFakeCluster())
|
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()
|
h := api.ExternalHandler()
|
||||||
|
|
||||||
w := do(h, "POST", "/api/v1/account/email", `{"email":"[email protected]"}`, jsonHeader)
|
w := do(h, "POST", "/api/v1/account/email", `{"email":"[email protected]"}`, jsonHeader)
|
||||||
|
|||||||
+18
-5
@@ -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
|
// CreateSession records a minted session by the sha-256 of its cookie value
|
||||||
// (spec §B). Only the hash is stored, mirroring tokens.
|
// (spec §B). Only the hash is stored, mirroring tokens.
|
||||||
func (p *PGRepo) CreateSession(ctx context.Context, s NewSession) error {
|
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,
|
_, err := p.db.ExecContext(ctx,
|
||||||
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip)
|
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip, reauth_at)
|
||||||
VALUES ($1, $2, $3, $4, $5)`,
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP)
|
s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP, reauth)
|
||||||
return err
|
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.
|
// 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) {
|
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),
|
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
|
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.token_hash = $1 AND ` + sessionLive
|
WHERE s.token_hash = $1 AND ` + sessionLive
|
||||||
var u SessionedUser
|
var u SessionedUser
|
||||||
|
var reauth sql.NullTime
|
||||||
switch err := p.db.QueryRowContext(ctx, q, tokenHash, now, now.Add(-staffSessionIdle)).Scan(
|
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):
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if reauth.Valid {
|
||||||
|
u.ReauthAt = reauth.Time
|
||||||
|
}
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1158,6 +1163,14 @@ func (p *PGRepo) TouchSession(ctx context.Context, tokenHash string, now time.Ti
|
|||||||
return err
|
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
|
// RevokeSession marks a session revoked (logout). Idempotent: a missing or
|
||||||
// already-revoked session is not an error.
|
// already-revoked session is not an error.
|
||||||
func (p *PGRepo) RevokeSession(ctx context.Context, tokenHash string) error {
|
func (p *PGRepo) RevokeSession(ctx context.Context, tokenHash string) error {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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: "[email protected]", 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":"[email protected]"}`, 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":"[email protected]","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":"[email protected]"}`, http.StatusAccepted,
|
||||||
|
func(f *sessionsFixture) bool { return len(f.repo.otps) == 0 }},
|
||||||
|
{"record an unverified email", "POST", "/api/v1/account/email", `{"email":"[email protected]"}`, 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":"[email protected]"}`, 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 = "[email protected]"
|
||||||
|
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: "[email protected]", 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: "[email protected]", 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 != "[email protected]" || mailer.code == "" {
|
||||||
|
t.Fatalf("code went to %q (%q), want [email protected]", 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":"[email protected]"}`, 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 = "[email protected]"
|
||||||
|
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":"[email protected]"}`, 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 != "[email protected]" || 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 != "[email protected]" || 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":"[email protected]"}`, 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 != "[email protected]" || 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, "[email protected]") {
|
||||||
|
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, "[email protected]"},
|
||||||
|
{"same address", laptopTok, "[email protected]"},
|
||||||
|
} {
|
||||||
|
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 = "[email protected]"
|
||||||
|
// 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{
|
||||||
|
"[email protected]": "a***@example.com",
|
||||||
|
"李雷@example.cn": "李***@example.cn",
|
||||||
|
"[email protected]": "a***@b.c",
|
||||||
|
"broken": "***",
|
||||||
|
"@nolocal.example": "***",
|
||||||
|
} {
|
||||||
|
if got := maskEmail(in); got != want {
|
||||||
|
t.Errorf("maskEmail(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,6 +165,9 @@ type SessionedUser struct {
|
|||||||
// LastSeenAt is when the session last authenticated a request, as last
|
// LastSeenAt is when the session last authenticated a request, as last
|
||||||
// recorded by TouchSession (so up to sessionTouchEvery stale).
|
// recorded by TouchSession (so up to sessionTouchEvery stale).
|
||||||
LastSeenAt time.Time
|
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
|
// 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
|
ExpiresAt time.Time
|
||||||
UserAgent string
|
UserAgent string
|
||||||
ClientIP 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
|
// 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
|
// never moves last_seen_at backwards, and touching an absent session is not
|
||||||
// an error.
|
// an error.
|
||||||
TouchSession(ctx context.Context, tokenHash string, now time.Time) 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
|
// RevokeSession marks a session revoked (logout). It is idempotent: revoking an
|
||||||
// absent or already-revoked session is not an error.
|
// absent or already-revoked session is not an error.
|
||||||
RevokeSession(ctx context.Context, tokenHash string) error
|
RevokeSession(ctx context.Context, tokenHash string) error
|
||||||
|
|||||||
+22
-2
@@ -66,24 +66,43 @@ func hashCookie(value string) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
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
|
// 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.
|
// 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()
|
token, err := newSessionToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
expires := a.now().Add(sessionTTL)
|
now := a.now()
|
||||||
|
expires := now.Add(sessionTTL)
|
||||||
ip := ""
|
ip := ""
|
||||||
if addr := a.clientIP(r); addr.IsValid() {
|
if addr := a.clientIP(r); addr.IsValid() {
|
||||||
ip = addr.String()
|
ip = addr.String()
|
||||||
}
|
}
|
||||||
|
var reauth time.Time
|
||||||
|
if proof == provenSignIn {
|
||||||
|
reauth = now
|
||||||
|
}
|
||||||
if err := a.Repo.CreateSession(r.Context(), NewSession{
|
if err := a.Repo.CreateSession(r.Context(), NewSession{
|
||||||
TokenHash: hashCookie(token),
|
TokenHash: hashCookie(token),
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ExpiresAt: expires,
|
ExpiresAt: expires,
|
||||||
UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent),
|
UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent),
|
||||||
ClientIP: ip,
|
ClientIP: ip,
|
||||||
|
ReauthAt: reauth,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
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),
|
ViaAdminAccess: staffRole(u.Role) && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname),
|
||||||
EmailVerified: u.EmailVerified,
|
EmailVerified: u.EmailVerified,
|
||||||
ViaSession: true,
|
ViaSession: true,
|
||||||
|
ReauthAt: u.ReauthAt,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,3 +187,59 @@ func TestRevokeOtherUserSessionsKeepsOne(t *testing.T) {
|
|||||||
t.Fatalf("revoke-others keeping nothing = %d, %v; want 1", n, err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
+114
-4
@@ -52,6 +52,10 @@ interface MockAccount {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
quota?: QuotaView;
|
quota?: QuotaView;
|
||||||
sessions?: SessionView[];
|
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 {
|
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`);
|
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 {
|
function clearSessionCookie(res: ServerResponse): void {
|
||||||
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
|
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
|
||||||
}
|
}
|
||||||
@@ -784,7 +823,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
opLogins.delete(body.request_id!);
|
opLogins.delete(body.request_id!);
|
||||||
setSessionCookie(ctx.res, "owner");
|
signInProven(ctx, "owner");
|
||||||
sendJSON(ctx.res, 200, { user_id: "mock-owner", role: "owner" });
|
sendJSON(ctx.res, 200, { user_id: "mock-owner", role: "owner" });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -828,7 +867,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
|||||||
sendError(ctx.res, 400, "bad_request", "login_id and assertion are required");
|
sendError(ctx.res, 400, "bad_request", "login_id and assertion are required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
setSessionCookie(ctx.res, "owner");
|
signInProven(ctx, "owner");
|
||||||
sendJSON(ctx.res, 200, {
|
sendJSON(ctx.res, 200, {
|
||||||
user_id: "mock-owner",
|
user_id: "mock-owner",
|
||||||
role: "owner"
|
role: "owner"
|
||||||
@@ -857,7 +896,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
|||||||
sendError(ctx.res, 400, "bad_request", "email and assertion are required");
|
sendError(ctx.res, 400, "bad_request", "email and assertion are required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
setSessionCookie(ctx.res, "owner");
|
signInProven(ctx, "owner");
|
||||||
sendJSON(ctx.res, 200, {
|
sendJSON(ctx.res, 200, {
|
||||||
user_id: "mock-owner",
|
user_id: "mock-owner",
|
||||||
role: "owner"
|
role: "owner"
|
||||||
@@ -879,7 +918,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
|||||||
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
|
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
setSessionCookie(ctx.res, "owner");
|
signInProven(ctx, "owner");
|
||||||
sendJSON(ctx.res, 200, {
|
sendJSON(ctx.res, 200, {
|
||||||
user_id: "mock-owner",
|
user_id: "mock-owner",
|
||||||
role: "owner"
|
role: "owner"
|
||||||
@@ -993,6 +1032,8 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
sendError(ctx.res, 400, "bad_request", "invalid email");
|
sendError(ctx.res, 400, "bad_request", "invalid email");
|
||||||
return true;
|
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() });
|
sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1002,11 +1043,22 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
|
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
|
||||||
return true;
|
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.emailVerified = true;
|
||||||
|
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
|
||||||
sendJSON(ctx.res, 200, { verified: true, email: ctx.account.email });
|
sendJSON(ctx.res, 200, { verified: true, email: ctx.account.email });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "POST account/passkey/register/begin": {
|
case "POST account/passkey/register/begin": {
|
||||||
|
if (refusedForReauth(ctx)) return true;
|
||||||
sendJSON(ctx.res, 200, {
|
sendJSON(ctx.res, 200, {
|
||||||
challenge: "c29tZV9jaGFsbGVuZ2VfZGF0YQ",
|
challenge: "c29tZV9jaGFsbGVuZ2VfZGF0YQ",
|
||||||
rp: { name: "Felis Dev" },
|
rp: { name: "Felis Dev" },
|
||||||
@@ -1034,6 +1086,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
ctx.state.passkeys[ctx.account.id] = [];
|
ctx.state.passkeys[ctx.account.id] = [];
|
||||||
}
|
}
|
||||||
ctx.state.passkeys[ctx.account.id].unshift(newCred);
|
ctx.state.passkeys[ctx.account.id].unshift(newCred);
|
||||||
|
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
|
||||||
sendJSON(ctx.res, 201, newCred);
|
sendJSON(ctx.res, 201, newCred);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1042,6 +1095,61 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
sendJSON(ctx.res, 200, { credentials: list });
|
sendJSON(ctx.res, 200, { credentials: list });
|
||||||
return true;
|
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":
|
case "GET account/migrate":
|
||||||
// No migration pending: the real API's answer until one is started in-game.
|
// No migration pending: the real API's answer until one is started in-game.
|
||||||
sendJSON(ctx.res, 200, { active: false });
|
sendJSON(ctx.res, 200, { active: false });
|
||||||
@@ -1076,6 +1184,8 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) {
|
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 id = ctx.parts[5];
|
||||||
const list = ctx.state.passkeys[ctx.account.id] ?? [];
|
const list = ctx.state.passkeys[ctx.account.id] ?? [];
|
||||||
const idx = list.findIndex((k) => k.id === id);
|
const idx = list.findIndex((k) => k.id === id);
|
||||||
|
|||||||
@@ -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<boolean>((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 <T,>(change: () => Promise<T>): Promise<T> => {
|
||||||
|
try {
|
||||||
|
return await change();
|
||||||
|
} catch (e) {
|
||||||
|
if (!isReauthRequired(e)) throw e;
|
||||||
|
if (!(await confirm())) throw clientError("reauth_cancelled");
|
||||||
|
return change();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[confirm],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = <ReauthDialog open={open} onDone={() => 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 (
|
||||||
|
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||||
|
{t("reauth_title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{t("reauth_desc")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{/* Mounted per opening, so every confirmation starts from a fresh status. */}
|
||||||
|
{open && <ReauthBody onDone={onDone} onCancel={onCancel} />}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Pending | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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<void>) {
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
{t("reauth_checking")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.error || !status.data || proven) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InlineError message={status.error ? humanizeError(status.error) : null} />
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
{status.error ? (
|
||||||
|
<Button size="sm" onClick={status.reload}>
|
||||||
|
{t("common:try_again")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const factors = status.data.factors;
|
||||||
|
const email = identity?.email ?? "";
|
||||||
|
const options: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
if (factors.includes("passkey")) {
|
||||||
|
options.push(
|
||||||
|
<Button key="passkey" className="w-full" onClick={() => void withPasskey()} disabled={pending !== null}>
|
||||||
|
{pending === "passkey" ? <Loader2 className="animate-spin" /> : <Fingerprint />}
|
||||||
|
{pending === "passkey" ? t("reauth_passkey_waiting") : t("reauth_passkey_btn")}
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (factors.includes("email")) {
|
||||||
|
options.push(
|
||||||
|
codeSent ? (
|
||||||
|
<form key="email" onSubmit={verifyCode} className="space-y-2">
|
||||||
|
<Label htmlFor="reauth-code">{t("reauth_code_label", { email })}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="reauth-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder={t("otp_code_placeholder")}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
disabled={pending !== null}
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
className="font-mono text-center tracking-[0.2em]"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={pending !== null || code.trim().length !== 6} className="shrink-0">
|
||||||
|
{pending === "verify" && <Loader2 className="animate-spin" />}
|
||||||
|
{pending === "verify" ? t("reauth_confirming") : t("reauth_confirm_btn")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void sendCode()}
|
||||||
|
disabled={pending !== null}
|
||||||
|
className="h-auto p-0 font-normal"
|
||||||
|
>
|
||||||
|
{pending === "send" ? t("sending_code") : t("reauth_resend")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key="email"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => void sendCode()}
|
||||||
|
disabled={pending !== null}
|
||||||
|
>
|
||||||
|
{pending === "send" ? <Loader2 className="animate-spin" /> : <Mail />}
|
||||||
|
<span className="truncate">{pending === "send" ? t("sending_code") : t("reauth_email_btn", { email })}</span>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (factors.includes("sign_in")) {
|
||||||
|
options.push(
|
||||||
|
<div key="sign_in" className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("reauth_sign_in_desc")}</p>
|
||||||
|
<Button variant="outline" className="w-full" onClick={() => void signInAgain()} disabled={pending !== null}>
|
||||||
|
{pending === "sign_in" ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||||
|
{t("reauth_sign_in_btn")}
|
||||||
|
</Button>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<InlineError message={t("errors:no_step_up_factor")} />
|
||||||
|
) : (
|
||||||
|
options.flatMap((option, i) =>
|
||||||
|
i === 0
|
||||||
|
? [option]
|
||||||
|
: [
|
||||||
|
<div key={`or-${i}`} className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
{t("reauth_or")}
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
</div>,
|
||||||
|
option,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<InlineError message={error} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -89,5 +89,20 @@
|
|||||||
"migration_redeem_placeholder": "Transfer code",
|
"migration_redeem_placeholder": "Transfer code",
|
||||||
"migration_redeeming": "Redeeming…",
|
"migration_redeeming": "Redeeming…",
|
||||||
"migration_redeem_btn": "Redeem",
|
"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."
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,9 @@
|
|||||||
"passkey_login_failed": "Passkey verification failed — try again.",
|
"passkey_login_failed": "Passkey verification failed — try again.",
|
||||||
"passkey_login_invalid": "That Passkey sign-in request is invalid or expired — start it 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.",
|
"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_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.",
|
"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.",
|
"too_many_streams": "Too many live streams are open — close some pages and try again.",
|
||||||
|
|||||||
@@ -88,5 +88,20 @@
|
|||||||
"migration_redeem_placeholder": "转移码",
|
"migration_redeem_placeholder": "转移码",
|
||||||
"migration_redeeming": "兑换中…",
|
"migration_redeeming": "兑换中…",
|
||||||
"migration_redeem_btn": "兑换",
|
"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": "输入新邮箱,我们会向新地址发送验证码。验证通过后登录验证码改发到新邮箱,旧邮箱会收到通知,其它设备会退出登录。"
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,9 @@
|
|||||||
"passkey_login_failed": "Passkey 验证失败——请重试。",
|
"passkey_login_failed": "Passkey 验证失败——请重试。",
|
||||||
"passkey_login_invalid": "Passkey 登录请求无效或已过期——请重新发起。",
|
"passkey_login_invalid": "Passkey 登录请求无效或已过期——请重新发起。",
|
||||||
"too_many_challenges": "验证请求过于频繁——请稍后再试。",
|
"too_many_challenges": "验证请求过于频繁——请稍后再试。",
|
||||||
|
"reauth_required": "请先确认是你本人:这项更改需要你在几分钟内用 Passkey 或邮箱验证码验证过。",
|
||||||
|
"staff_reauth": "管理员账户须用 Passkey 或重新登录来确认身份。",
|
||||||
|
"no_session": "只能在已登录 Felis 的浏览器中进行此操作。",
|
||||||
"op_login_invalid": "本次管理员登录未能完成——请重新发起登录。",
|
"op_login_invalid": "本次管理员登录未能完成——请重新发起登录。",
|
||||||
"op_login_not_found": "找不到该管理员登录请求。",
|
"op_login_not_found": "找不到该管理员登录请求。",
|
||||||
"too_many_streams": "同时打开的实时连接过多——请关闭一些页面后再试。",
|
"too_many_streams": "同时打开的实时连接过多——请关闭一些页面后再试。",
|
||||||
|
|||||||
@@ -1015,4 +1015,16 @@ describe("copy for the generic server codes", () => {
|
|||||||
expect(err.status).toBe(0);
|
expect(err.status).toBe(0);
|
||||||
expect(humanizeError(err)).toBe("The browser returned no passkey. Try again.");
|
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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -590,6 +590,26 @@ export const api = rejectingSync({
|
|||||||
revokeMyOtherSessions: () =>
|
revokeMyOtherSessions: () =>
|
||||||
request<{ revoked: number }>("POST", "/account/sessions/revoke-others"),
|
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<any>("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
|
// 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
|
// 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
|
// 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");
|
return t("passkey_login_invalid");
|
||||||
case "too_many_challenges":
|
case "too_many_challenges":
|
||||||
return t("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.
|
// Operator-login approvals, live streams, and the remaining auth doors.
|
||||||
case "op_login_invalid":
|
case "op_login_invalid":
|
||||||
return t("op_login_invalid");
|
return t("op_login_invalid");
|
||||||
|
|||||||
@@ -1363,7 +1363,7 @@ export interface paths {
|
|||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* Mint and deliver an email one-time code for the caller (web onboarding, spec §B2).
|
* 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"];
|
post: operations["emailOtpStart"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -1383,7 +1383,7 @@ export interface paths {
|
|||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* Redeem an email one-time code and mark the caller's email verified (spec §B2).
|
* 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"];
|
post: operations["emailOtpVerify"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -1403,7 +1403,7 @@ export interface paths {
|
|||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* Record the caller's email WITHOUT verifying it (setup bootstrap, spec §B2).
|
* 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"];
|
post: operations["setEmail"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -1423,7 +1423,7 @@ export interface paths {
|
|||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* Begin a passkey (WebAuthn) registration ceremony for the caller (spec §14, Phase 6 bind).
|
* 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"];
|
post: operations["passkeyRegisterBegin"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -1443,7 +1443,7 @@ export interface paths {
|
|||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* Finish a passkey registration ceremony and bind the credential (spec §14, Phase 6 bind).
|
* 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"];
|
post: operations["passkeyRegisterFinish"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -1484,7 +1484,7 @@ export interface paths {
|
|||||||
post?: never;
|
post?: never;
|
||||||
/**
|
/**
|
||||||
* Unbind one of the caller's passkeys (spec §14, Phase 6 bind).
|
* 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"];
|
delete: operations["passkeyDelete"];
|
||||||
options?: never;
|
options?: never;
|
||||||
@@ -1492,6 +1492,103 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/account/sessions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2339,6 +2436,29 @@ export interface components {
|
|||||||
"application/json": components["schemas"]["Error"];
|
"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. */
|
/** @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: {
|
MailUndeliverable: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -5765,6 +5885,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
401: components["responses"]["Unauthorized"];
|
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). */
|
/** @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: {
|
429: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -5865,6 +5986,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["ReauthRequired"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
passkeyRegisterBegin: {
|
passkeyRegisterBegin: {
|
||||||
@@ -5886,6 +6008,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["ReauthRequired"];
|
||||||
/** @description Passkey subsystem is not configured. */
|
/** @description Passkey subsystem is not configured. */
|
||||||
503: {
|
503: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -5997,6 +6120,7 @@ export interface operations {
|
|||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["ReauthRequired"];
|
||||||
/** @description No such passkey for this caller. */
|
/** @description No such passkey for this caller. */
|
||||||
404: {
|
404: {
|
||||||
headers: {
|
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<string, never>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @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<string, never>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
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: {
|
listMySessions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+59
-31
@@ -14,8 +14,10 @@ import { formatAbsolute } from "@/lib/format";
|
|||||||
import type { PasskeyCredential } from "@/lib/types";
|
import type { PasskeyCredential } from "@/lib/types";
|
||||||
import { useAsync } from "@/lib/hooks";
|
import { useAsync } from "@/lib/hooks";
|
||||||
import { AccountSessionsCard } from "@/pages/AccountSessions";
|
import { AccountSessionsCard } from "@/pages/AccountSessions";
|
||||||
|
import { isReauthCancelled, isReauthRequired, useReauth } from "@/components/ReauthDialog";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
||||||
|
import { requestAssertion } from "@/lib/passkey";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -36,8 +38,12 @@ export function Account() {
|
|||||||
const status = useAsync(() => api.linkStatus(), []);
|
const status = useAsync(() => api.linkStatus(), []);
|
||||||
const { identity, refresh } = useTier();
|
const { identity, refresh } = useTier();
|
||||||
const { t, i18n } = useTranslation("account");
|
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 [emailInput, setEmailInput] = useState("");
|
||||||
const [otpCodeInput, setOtpCodeInput] = useState("");
|
const [otpCodeInput, setOtpCodeInput] = useState("");
|
||||||
const [emailSending, setEmailSending] = useState(false);
|
const [emailSending, setEmailSending] = useState(false);
|
||||||
@@ -46,6 +52,7 @@ export function Account() {
|
|||||||
const [emailSent, setEmailSent] = useState(false);
|
const [emailSent, setEmailSent] = useState(false);
|
||||||
const [sentEmailAddress, setSentEmailAddress] = useState("");
|
const [sentEmailAddress, setSentEmailAddress] = useState("");
|
||||||
const [initializedEmail, setInitializedEmail] = useState(false);
|
const [initializedEmail, setInitializedEmail] = useState(false);
|
||||||
|
const [changingEmail, setChangingEmail] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (identity?.email && !initializedEmail) {
|
if (identity?.email && !initializedEmail) {
|
||||||
@@ -61,16 +68,30 @@ export function Account() {
|
|||||||
setEmailSending(true);
|
setEmailSending(true);
|
||||||
setEmailError(null);
|
setEmailError(null);
|
||||||
try {
|
try {
|
||||||
await api.emailStart(trimmed);
|
await reauth.guard(() => api.emailStart(trimmed));
|
||||||
setEmailSent(true);
|
setEmailSent(true);
|
||||||
setSentEmailAddress(trimmed);
|
setSentEmailAddress(trimmed);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setEmailError(humanizeError(err));
|
if (!isReauthCancelled(err)) setEmailError(humanizeError(err));
|
||||||
} finally {
|
} finally {
|
||||||
setEmailSending(false);
|
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) {
|
async function verifyEmailOtp(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmedCode = otpCodeInput.trim();
|
const trimmedCode = otpCodeInput.trim();
|
||||||
@@ -80,6 +101,11 @@ export function Account() {
|
|||||||
try {
|
try {
|
||||||
await api.emailVerify(trimmedCode);
|
await api.emailVerify(trimmedCode);
|
||||||
await refresh();
|
await refresh();
|
||||||
|
if (changingEmail) {
|
||||||
|
// Replacing a verified address signs the other devices out.
|
||||||
|
setChangingEmail(false);
|
||||||
|
setSessionsVersion((v) => v + 1);
|
||||||
|
}
|
||||||
setEmailSent(false);
|
setEmailSent(false);
|
||||||
setEmailInput("");
|
setEmailInput("");
|
||||||
setOtpCodeInput("");
|
setOtpCodeInput("");
|
||||||
@@ -97,6 +123,7 @@ export function Account() {
|
|||||||
const [passkeyNickname, setPasskeyNickname] = useState("");
|
const [passkeyNickname, setPasskeyNickname] = useState("");
|
||||||
const [registeringPasskey, setRegisteringPasskey] = useState(false);
|
const [registeringPasskey, setRegisteringPasskey] = useState(false);
|
||||||
const [passkeyError, setPasskeyError] = useState<string | null>(null);
|
const [passkeyError, setPasskeyError] = useState<string | null>(null);
|
||||||
|
const [passkeyNotice, setPasskeyNotice] = useState<string | null>(null);
|
||||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||||
// Deleting a passkey goes through a confirm dialog that names it. The API
|
// 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
|
// refuses to remove the only passkey of an account whose email is unverified
|
||||||
@@ -118,6 +145,7 @@ export function Account() {
|
|||||||
setRegisterDialogOpen(false);
|
setRegisterDialogOpen(false);
|
||||||
setPasskeyNickname("");
|
setPasskeyNickname("");
|
||||||
setPasskeyError(null);
|
setPasskeyError(null);
|
||||||
|
setPasskeyNotice(null);
|
||||||
setRegisteringPasskey(false);
|
setRegisteringPasskey(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,12 +155,22 @@ export function Account() {
|
|||||||
if (!name || registeringPasskey) return;
|
if (!name || registeringPasskey) return;
|
||||||
setRegisteringPasskey(true);
|
setRegisteringPasskey(true);
|
||||||
setPasskeyError(null);
|
setPasskeyError(null);
|
||||||
|
setPasskeyNotice(null);
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortControllerRef.current = controller;
|
abortControllerRef.current = controller;
|
||||||
|
|
||||||
try {
|
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 = {
|
const publicKey: PublicKeyCredentialCreationOptions = {
|
||||||
...options,
|
...options,
|
||||||
challenge: base64urlToBytes(options.challenge),
|
challenge: base64urlToBytes(options.challenge),
|
||||||
@@ -189,12 +227,13 @@ export function Account() {
|
|||||||
setDeletingPasskey(true);
|
setDeletingPasskey(true);
|
||||||
setDeleteError(null);
|
setDeleteError(null);
|
||||||
try {
|
try {
|
||||||
await api.passkeyDelete(pendingDelete.id);
|
await reauth.guard(() => api.passkeyDelete(pendingDelete.id));
|
||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
// The server signed the other devices out along with the passkey.
|
// The server signed the other devices out along with the passkey.
|
||||||
setSessionsVersion((v) => v + 1);
|
setSessionsVersion((v) => v + 1);
|
||||||
await passkeys.reload();
|
await passkeys.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isReauthCancelled(err)) return;
|
||||||
// Another device may have changed the list meanwhile: refresh it. A 404
|
// Another device may have changed the list meanwhile: refresh it. A 404
|
||||||
// means the passkey is already gone, which is what was asked for.
|
// means the passkey is already gone, which is what was asked for.
|
||||||
void passkeys.reload();
|
void passkeys.reload();
|
||||||
@@ -290,17 +329,20 @@ export function Account() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-sm">
|
<CardContent className="text-sm">
|
||||||
{identity?.email_verified ? (
|
{identity?.email_verified && !changingEmail ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2 font-medium text-foreground">
|
<div className="flex items-center gap-2 font-medium text-foreground">
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||||
{t("email_verified")}
|
{t("email_verified")}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">{t("email_desc")}</p>
|
<p className="text-muted-foreground">{t("email_desc")}</p>
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
|
||||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
||||||
{identity.email}
|
{identity.email}
|
||||||
</code>
|
</code>
|
||||||
|
<Button variant="link" size="sm" onClick={startChangeEmail} className="h-auto p-0 font-normal">
|
||||||
|
{t("change_email")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -309,7 +351,7 @@ export function Account() {
|
|||||||
<StepBadge n={1} />
|
<StepBadge n={1} />
|
||||||
<div className="w-full space-y-2">
|
<div className="w-full space-y-2">
|
||||||
<p className="font-medium text-foreground">{t("email_step1")}</p>
|
<p className="font-medium text-foreground">{t("email_step1")}</p>
|
||||||
<p className="text-muted-foreground">{t("email_step1_desc")}</p>
|
<p className="text-muted-foreground">{t(changingEmail ? "email_change_desc" : "email_step1_desc")}</p>
|
||||||
{!emailSent ? (
|
{!emailSent ? (
|
||||||
<form onSubmit={sendEmailOtp} className="flex gap-2 max-w-md">
|
<form onSubmit={sendEmailOtp} className="flex gap-2 max-w-md">
|
||||||
<Input
|
<Input
|
||||||
@@ -324,6 +366,11 @@ export function Account() {
|
|||||||
<Button type="submit" disabled={emailSending || !emailInput}>
|
<Button type="submit" disabled={emailSending || !emailInput}>
|
||||||
{emailSending ? t("sending_code") : t("send_code")}
|
{emailSending ? t("sending_code") : t("send_code")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{changingEmail && (
|
||||||
|
<Button type="button" variant="ghost" onClick={cancelChangeEmail} disabled={emailSending}>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-emerald-600 font-medium dark:text-emerald-400">
|
<div className="flex items-center gap-2 text-emerald-600 font-medium dark:text-emerald-400">
|
||||||
@@ -409,6 +456,7 @@ export function Account() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{passkeyNotice && <MessageLine kind="success" message={passkeyNotice} />}
|
||||||
<InlineError message={passkeyError} />
|
<InlineError message={passkeyError} />
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -518,6 +566,8 @@ export function Account() {
|
|||||||
onSignOut={() => void signOut()}
|
onSignOut={() => void signOut()}
|
||||||
signingOut={signingOut}
|
signingOut={signingOut}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{reauth.dialog}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -654,29 +704,7 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo
|
|||||||
function confirmWithPasskey() {
|
function confirmWithPasskey() {
|
||||||
void run(async () => {
|
void run(async () => {
|
||||||
const options = await api.migrateConfirmPasskeyBegin();
|
const options = await api.migrateConfirmPasskeyBegin();
|
||||||
const pk = options.publicKey;
|
await api.migrateConfirmPasskeyFinish(await requestAssertion(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 mig.reload();
|
await mig.reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof import("@/lib/api")>();
|
||||||
|
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: "[email protected]",
|
||||||
|
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(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Account />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [email protected]" }));
|
||||||
|
await userEvent.type(await within(dialog).findByLabelText("Code sent to [email protected]"), "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 [email protected]"));
|
||||||
|
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: "[email protected]" });
|
||||||
|
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("[email protected]");
|
||||||
|
expect((address as HTMLInputElement).value).toBe("");
|
||||||
|
await userEvent.type(address, "[email protected]");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Send Code" }));
|
||||||
|
await proveByEmail(await checkDialog());
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.emailStart).toHaveBeenCalledTimes(2));
|
||||||
|
expect(mocks.emailStart).toHaveBeenLastCalledWith("[email protected]");
|
||||||
|
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("[email protected]")).toBeTruthy();
|
||||||
|
expect(screen.queryByPlaceholderText("[email protected]")).toBeNull();
|
||||||
|
expect(mocks.emailStart).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,9 +9,9 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
import { loginReturnPath } from "@/lib/auth";
|
import { loginReturnPath } from "@/lib/auth";
|
||||||
import { api, clientError, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
import { loadConfig } from "@/lib/config";
|
import { loadConfig } from "@/lib/config";
|
||||||
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
import { requestAssertion } from "@/lib/passkey";
|
||||||
import { InlineError } from "@/components/MessageLine";
|
import { InlineError } from "@/components/MessageLine";
|
||||||
import { formatCountdown, opLoginDeadline, useOpLoginPoll } from "@/lib/opLoginPoll";
|
import { formatCountdown, opLoginDeadline, useOpLoginPoll } from "@/lib/opLoginPoll";
|
||||||
|
|
||||||
@@ -143,41 +143,10 @@ export function Login() {
|
|||||||
|
|
||||||
const identifier = email.trim();
|
const identifier = email.trim();
|
||||||
try {
|
try {
|
||||||
let assertion: any;
|
|
||||||
if (!identifier) {
|
if (!identifier) {
|
||||||
// Discoverable (Usernameless) passkey login
|
// Discoverable (Usernameless) passkey login
|
||||||
const options = await api.authPasskeyDiscoverableBegin();
|
const options = await api.authPasskeyDiscoverableBegin();
|
||||||
const publicKey: PublicKeyCredentialRequestOptions = {
|
await api.authPasskeyDiscoverableFinish(options.login_id, await requestAssertion(options.publicKey));
|
||||||
...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);
|
|
||||||
} else {
|
} else {
|
||||||
// Email-first passkey login
|
// Email-first passkey login
|
||||||
if (!identifier.includes("@")) {
|
if (!identifier.includes("@")) {
|
||||||
@@ -185,37 +154,7 @@ export function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const options = await api.authPasskeyLoginBegin(identifier);
|
const options = await api.authPasskeyLoginBegin(identifier);
|
||||||
const publicKey: PublicKeyCredentialRequestOptions = {
|
await api.authPasskeyLoginFinish(identifier, await requestAssertion(options));
|
||||||
...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 refresh();
|
await refresh();
|
||||||
|
|||||||
Reference in new issue
Block a user