feat(api): 添加或删除 passkey、修改邮箱前须 5 分钟内用已有因子重新验证,变更后邮件通知账户,面板加确认对话框与修改邮箱入口
This commit is contained in:
36 files changed
+2735
-288
No files matched your search
@@ -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: "GET", Pattern: "/api/v1/account/passkey/credentials", SetupAllowed: true, h: a.handlePasskeyList},
|
||||
{Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", SetupAllowed: true, h: a.handlePasskeyDelete},
|
||||
// Reauth (reauth.go): the fresh proof that passkey enrollment and removal and an
|
||||
// email change require once the account has a factor. Status says whether one
|
||||
// is needed and how to give it; the pairs below take a passkey assertion or an
|
||||
// email code and mark the caller's session. SetupAllowed like the routes they
|
||||
// unlock.
|
||||
{Method: "GET", Pattern: "/api/v1/account/reauth", SetupAllowed: true, h: a.handleReauthStatus},
|
||||
{Method: "POST", Pattern: "/api/v1/account/reauth/passkey/begin", SetupAllowed: true, h: a.handleReauthPasskeyBegin},
|
||||
{Method: "POST", Pattern: "/api/v1/account/reauth/passkey/finish", SetupAllowed: true, h: a.handleReauthPasskeyFinish},
|
||||
{Method: "POST", Pattern: "/api/v1/account/reauth/email/start", SetupAllowed: true, h: a.handleReauthEmailStart},
|
||||
{Method: "POST", Pattern: "/api/v1/account/reauth/email/verify", SetupAllowed: true, h: a.handleReauthEmailVerify},
|
||||
// The caller's own sessions (handlers_account_sessions.go): list every signed-in
|
||||
// device and sign out one or all the others. App-tier and scoped to the caller
|
||||
// inside the handler, like the passkey routes above.
|
||||
|
||||
@@ -75,10 +75,12 @@ type fakeRepo struct {
|
||||
// failSessionUser / failGetSetting force those reads to fail with a generic
|
||||
// (non-ErrNotFound) error, simulating a store outage for the 503 auth path.
|
||||
failSessionUser error
|
||||
// failTouchSession / failRevokeOthers force those session writes to fail.
|
||||
// failTouchSession / failRevokeOthers / failMarkReauth force those session
|
||||
// writes to fail.
|
||||
failTouchSession error
|
||||
failRevokeOthers error
|
||||
failGetSetting error
|
||||
failMarkReauth error
|
||||
failGetSetting error
|
||||
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
|
||||
// newest live (user, purpose) just as the PG query does.
|
||||
otps map[string]*fakeEmailOTP
|
||||
@@ -220,6 +222,8 @@ type fakeSession struct {
|
||||
userAgent string
|
||||
clientIP string
|
||||
touches int
|
||||
// reauthAt is reauth_at: when the session last proved a factor; zero = never.
|
||||
reauthAt time.Time
|
||||
}
|
||||
|
||||
// fakeBackup mirrors a world_backups row: the client-facing view plus the
|
||||
@@ -904,7 +908,16 @@ func (f *fakeRepo) CreateSession(_ context.Context, ns NewSession) error {
|
||||
now := ns.ExpiresAt.Add(-sessionTTL)
|
||||
f.sessions[ns.TokenHash] = &fakeSession{
|
||||
userID: ns.UserID, expiresAt: ns.ExpiresAt, createdAt: now, lastSeen: now,
|
||||
userAgent: ns.UserAgent, clientIP: ns.ClientIP,
|
||||
userAgent: ns.UserAgent, clientIP: ns.ClientIP, reauthAt: ns.ReauthAt,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepo) MarkSessionReauth(_ context.Context, tokenHash string, at time.Time) error {
|
||||
if f.failMarkReauth != nil {
|
||||
return f.failMarkReauth
|
||||
}
|
||||
if s, ok := f.sessions[tokenHash]; ok && !s.revoked {
|
||||
s.reauthAt = at
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -951,7 +964,7 @@ func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Tim
|
||||
}
|
||||
return &SessionedUser{
|
||||
ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role,
|
||||
EmailVerified: u.EmailVerified, LastSeenAt: s.lastSeenAt(now),
|
||||
EmailVerified: u.EmailVerified, LastSeenAt: s.lastSeenAt(now), ReauthAt: s.reauthAt,
|
||||
}, nil
|
||||
}
|
||||
func (f *fakeRepo) TouchSession(_ context.Context, tokenHash string, now time.Time) error {
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestAuditCannotBeSignedWithAnotherPersonsEmail(t *testing.T) {
|
||||
repo.settings[LocalAuthEnabledKey] = []byte("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.sessions[hashCookie("tok")] = &fakeSession{userID: "u2", expiresAt: time.Unix(1_700_000_000, 0).Add(time.Hour)}
|
||||
repo.sessions[hashCookie("tok")] = &fakeSession{userID: "u2", expiresAt: frozenNow.Add(time.Hour), reauthAt: frozenNow}
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
|
||||
api.ClientIPHeader = "CF-Connecting-IP"
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
@@ -41,6 +42,9 @@ type Principal struct {
|
||||
// passed Zero Trust at the edge, so the local-email-verification gate is not
|
||||
// the right boundary for them.
|
||||
ViaSession bool
|
||||
// ReauthAt is when the holder of the session last proved a factor of the
|
||||
// account; zero for a session that never did and for a JWT caller.
|
||||
ReauthAt time.Time
|
||||
}
|
||||
|
||||
// staffRole reports whether a stored user role carries staff standing: admin,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -25,7 +23,9 @@ import (
|
||||
// email-OTP — advancing to 'confirmed'. Mere
|
||||
// session possession is never enough; a stolen
|
||||
// session cannot read the mailbox nor present the
|
||||
// authenticator.
|
||||
// authenticator, and cannot enroll one of its own
|
||||
// without a recent proof of an existing factor
|
||||
// (reauth.go).
|
||||
// 3. web issue code + name target → handleMigrateIssueCode: the source names the
|
||||
// target account by id and mints a one-time code
|
||||
// ('code_issued').
|
||||
@@ -205,54 +205,7 @@ func (a *API) handleMigrateConfirmOTPStart(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
// Per-recipient cooldown, namespaced apart from the other OTP doors so they never
|
||||
// perturb each other's throttle.
|
||||
if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, otpPurposeMigrate, a.now()); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
} else if !until.IsZero() {
|
||||
writeOTPAccountLocked(w, r, until, a.now())
|
||||
return
|
||||
}
|
||||
emailKey := "migrate:confirm:" + strings.ToLower(p.Email)
|
||||
lim := a.otpLimiter()
|
||||
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
|
||||
if !ok {
|
||||
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
|
||||
"a code was sent recently; wait a moment before requesting another"))
|
||||
return
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
lim.release(emailKey, emailAt)
|
||||
}
|
||||
}()
|
||||
code, err := newEmailOTP()
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
id, err := newOTPID()
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
expiresAt := a.now().Add(otpTTL)
|
||||
if err := a.Repo.CreateEmailOTP(r.Context(), id, p.UserID, p.Email, otpCodeHash(code), otpPurposeMigrate, expiresAt); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := a.deliverOTP(r.Context(), p.Email, code); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
a.audit(r, "account.migrate.confirm_otp_sent", "")
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()})
|
||||
}
|
||||
|
||||
// migrateConfirmOTPVerifyRequest is the OTP step-up verify body: the code from the email.
|
||||
type migrateConfirmOTPVerifyRequest struct {
|
||||
Code string `json:"code"`
|
||||
a.startStepUpOTP(w, r, p, otpPurposeMigrate, "migrate:confirm:", "account.migrate.confirm_otp_sent")
|
||||
}
|
||||
|
||||
// handleMigrateConfirmOTPVerify redeems the migration step-up code and, on a match,
|
||||
@@ -260,7 +213,7 @@ type migrateConfirmOTPVerifyRequest struct {
|
||||
// is the login-door one (no identity side-effect): the address is already proven.
|
||||
func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||
p := principalFromContext(r.Context())
|
||||
var req migrateConfirmOTPVerifyRequest
|
||||
var req stepUpOTPVerifyRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
@@ -273,25 +226,7 @@ func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Reque
|
||||
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
|
||||
return
|
||||
}
|
||||
var lock *OTPAccountLockedError
|
||||
err := a.Repo.ConsumeLoginEmailOTP(r.Context(), p.UserID, otpPurposeMigrate, otpCodeHash(code), a.now())
|
||||
if isOTPRefusal(err) {
|
||||
a.authFailure(r, "migrate_confirm", otpFailureReason(err), nil)
|
||||
}
|
||||
switch {
|
||||
case errors.As(err, &lock):
|
||||
a.noteOTPLock(r, err, p.UserID, otpPurposeMigrate)
|
||||
writeOTPAccountLocked(w, r, lock.Until, a.now())
|
||||
return
|
||||
case errors.Is(err, ErrOTPLocked):
|
||||
writeError(w, r, newError(http.StatusTooManyRequests, "otp_locked",
|
||||
"too many incorrect attempts; request a new code"))
|
||||
return
|
||||
case errors.Is(err, ErrOTPInvalid):
|
||||
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired"))
|
||||
return
|
||||
case err != nil:
|
||||
writeError(w, r, err)
|
||||
if !a.verifyStepUpOTP(w, r, p, otpPurposeMigrate, "migrate_confirm", code) {
|
||||
return
|
||||
}
|
||||
if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "email_otp", a.now()); err != nil {
|
||||
@@ -307,17 +242,6 @@ func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Reque
|
||||
writeJSON(w, http.StatusOK, map[string]any{"confirmed": true})
|
||||
}
|
||||
|
||||
// migratePasskeyUser builds the PasskeyUser the assertion ceremony needs for the
|
||||
// already-logged-in source (contrast the login door, which resolves it from a typed
|
||||
// email). The credential set must be identical between begin and finish.
|
||||
func migratePasskeyUser(p *Principal, creds []PasskeyCredential) PasskeyUser {
|
||||
name := p.Email
|
||||
if name == "" {
|
||||
name = p.UserID
|
||||
}
|
||||
return PasskeyUser{ID: p.UserID, Name: name, DisplayName: name, Credentials: creds}
|
||||
}
|
||||
|
||||
// handleMigrateConfirmPasskeyBegin starts a fresh passkey assertion bound to the
|
||||
// migration step-up (spec §B3, external app face). Unlike the login door it needs no
|
||||
// email — the caller is already authenticated — so it scopes the challenge to the
|
||||
@@ -331,39 +255,8 @@ func (a *API) handleMigrateConfirmPasskeyBegin(w http.ResponseWriter, r *http.Re
|
||||
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
|
||||
return
|
||||
}
|
||||
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if len(creds) == 0 {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "no_passkey",
|
||||
"no passkey enrolled; confirm the migration with an email code"))
|
||||
return
|
||||
}
|
||||
options, sessionData, err := a.Passkey.BeginLogin(migratePasskeyUser(p, creds))
|
||||
if err != nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed",
|
||||
"could not start passkey confirmation"))
|
||||
return
|
||||
}
|
||||
id, err := newPasskeyID()
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
expiresAt := a.now().Add(passkeyChallengeTTL)
|
||||
if err := a.Repo.CreatePasskeyChallenge(r.Context(), id, p.UserID, passkeyPurposeMigrate, sessionData, expiresAt); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, options)
|
||||
}
|
||||
|
||||
// migrateConfirmPasskeyFinishRequest is the assertion the browser produced, captured
|
||||
// as raw bytes so the exact response reaches the verifier without re-encoding.
|
||||
type migrateConfirmPasskeyFinishRequest struct {
|
||||
Assertion json.RawMessage `json:"assertion"`
|
||||
a.beginStepUpPasskey(w, r, p, passkeyPurposeMigrate,
|
||||
"no passkey enrolled; confirm the migration with an email code")
|
||||
}
|
||||
|
||||
// handleMigrateConfirmPasskeyFinish verifies the migration step-up assertion and, on
|
||||
@@ -375,7 +268,7 @@ func (a *API) handleMigrateConfirmPasskeyFinish(w http.ResponseWriter, r *http.R
|
||||
writeError(w, r, errPasskeyUnavailable)
|
||||
return
|
||||
}
|
||||
var req migrateConfirmPasskeyFinishRequest
|
||||
var req stepUpPasskeyFinishRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
@@ -387,42 +280,7 @@ func (a *API) handleMigrateConfirmPasskeyFinish(w http.ResponseWriter, r *http.R
|
||||
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
|
||||
return
|
||||
}
|
||||
sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), p.UserID, passkeyPurposeMigrate, a.now())
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrPasskeyChallengeInvalid) {
|
||||
a.authFailure(r, "migrate_passkey", "challenge_invalid", nil)
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||
"passkey confirmation could not be completed; begin again"))
|
||||
return
|
||||
}
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
va, err := a.Passkey.FinishLogin(migratePasskeyUser(p, creds), sessionData, bytes.NewReader(req.Assertion))
|
||||
if err != nil {
|
||||
a.authFailure(r, "migrate_passkey", "bad_assertion", nil)
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||
"passkey confirmation could not be completed; begin again"))
|
||||
return
|
||||
}
|
||||
// Same clone policy as the login door (applyAssertionCounter): a rolled-back counter
|
||||
// fails closed with the opaque envelope and advances nothing, so the migrate step-up is
|
||||
// never a weaker sibling that would accept an authenticator login refuses. A clean
|
||||
// assertion advances the stored sign-count, keeping the clone signal meaningful for the
|
||||
// next login.
|
||||
if err := a.applyAssertionCounter(r.Context(), va); err != nil {
|
||||
if errors.Is(err, errPasskeyClonedAuthenticator) {
|
||||
a.passkeyCloneRejected(r, "migrate_passkey", nil, va.CredentialID)
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||
"passkey confirmation could not be completed; begin again"))
|
||||
return
|
||||
}
|
||||
writeError(w, r, err)
|
||||
if !a.finishStepUpPasskey(w, r, p, passkeyPurposeMigrate, "migrate_passkey", req.Assertion) {
|
||||
return
|
||||
}
|
||||
if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "passkey", a.now()); err != nil {
|
||||
|
||||
@@ -35,7 +35,9 @@ func newSessionsFixture(t *testing.T) *sessionsFixture {
|
||||
api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
|
||||
now := api.now()
|
||||
for tok, s := range map[string]*fakeSession{
|
||||
laptopTok: {userID: "u1", lastSeen: now.Add(-10 * time.Minute), userAgent: "Firefox on Linux", clientIP: "203.0.113.5"},
|
||||
// The laptop signed in by a proving door a minute ago, so the guarded
|
||||
// changes below run without a reauth (reauth_test.go covers the gate).
|
||||
laptopTok: {userID: "u1", lastSeen: now.Add(-10 * time.Minute), userAgent: "Firefox on Linux", clientIP: "203.0.113.5", reauthAt: now.Add(-time.Minute)},
|
||||
phoneTok: {userID: "u1", lastSeen: now.Add(-2 * time.Hour), userAgent: "Safari on iPhone", clientIP: "198.51.100.7"},
|
||||
alexTok: {userID: "u2", lastSeen: now.Add(-time.Minute)},
|
||||
} {
|
||||
|
||||
@@ -271,7 +271,7 @@ func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.startSession(w, r, u.ID); err != nil {
|
||||
if err := a.startSession(w, r, u.ID, provenSignIn); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,6 +131,10 @@ func (a *API) handleEmailOTPStart(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
|
||||
return
|
||||
}
|
||||
// Gate the start: the verify only redeems a code minted here.
|
||||
if !a.requireReauth(w, r, p) {
|
||||
return
|
||||
}
|
||||
// Atomically reserve the cooldown on both the caller and the recipient BEFORE
|
||||
// minting, so a burst of truly concurrent starts yields exactly one winner. Here
|
||||
// the throttle is the sole defense and each admitted send is a real, non-idempotent
|
||||
@@ -249,10 +253,14 @@ func (a *API) handleEmailOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
a.audit(r, "account.email.verified", "")
|
||||
// Proving the address is an email reauth.
|
||||
a.markReauthQuietly(r)
|
||||
// Replacing a verified address moves where sign-in codes go, so a session
|
||||
// opened through the old one ends. A first verification retires nothing.
|
||||
// opened through the old one ends, and the old mailbox hears about it. A
|
||||
// first verification retires nothing.
|
||||
if p.EmailVerified && !strings.EqualFold(p.Email, email) {
|
||||
a.revokeOtherSessionsAfter(r, "email change")
|
||||
a.notifyEmailChanged(r, p.Email, email)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"verified": true, "email": email})
|
||||
}
|
||||
@@ -320,6 +328,11 @@ func (a *API) handleSetEmail(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
|
||||
return
|
||||
}
|
||||
// Recording an address unverifies the current one, which would strip the
|
||||
// account's email factor and with it the reauth that guards adding a passkey.
|
||||
if !a.requireReauth(w, r, p) {
|
||||
return
|
||||
}
|
||||
if err := a.Repo.SetUserEmail(r.Context(), p.UserID, email); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -125,7 +125,7 @@ func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.startSession(w, r, userID); err != nil {
|
||||
if err := a.startSession(w, r, userID, bindCodeSignIn); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, newError(http.StatusForbidden, "staff_account", "that account is not an operator"))
|
||||
return
|
||||
}
|
||||
if err := a.startSession(w, r, u.ID); err != nil {
|
||||
if err := a.startSession(w, r, u.ID, provenSignIn); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -253,6 +253,10 @@ func (a *API) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
p := principalFromContext(r.Context())
|
||||
// Gate the begin: the finish only consumes the challenge minted here.
|
||||
if !a.requireReauth(w, r, p) {
|
||||
return
|
||||
}
|
||||
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
@@ -356,6 +360,11 @@ func (a *API) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
a.audit(r, "account.passkey.registered", cred.ID)
|
||||
// The session just showed an authenticator now bound to the account, the
|
||||
// same strength as a passkey reauth, so the next guarded step of a first-time
|
||||
// setup (verifying an email) runs without asking again.
|
||||
a.markReauthQuietly(r)
|
||||
a.notifyPasskeyAdded(r, p)
|
||||
writeJSON(w, http.StatusCreated, passkeyView(cred))
|
||||
}
|
||||
|
||||
@@ -409,6 +418,9 @@ func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "credential id is required"))
|
||||
return
|
||||
}
|
||||
if !a.requireReauth(w, r, p) {
|
||||
return
|
||||
}
|
||||
if err := a.Repo.DeletePasskeyCredential(r.Context(), p.UserID, id); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeError(w, r, newError(http.StatusNotFound, "not_found", "no such passkey"))
|
||||
@@ -424,6 +436,7 @@ func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
a.audit(r, "account.passkey.removed", id)
|
||||
a.revokeOtherSessionsAfter(r, "passkey removal")
|
||||
a.notifyPasskeyRemoved(r, p)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -654,7 +667,7 @@ func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.startSession(w, r, u.ID); err != nil {
|
||||
if err := a.startSession(w, r, u.ID, provenSignIn); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.startSession(w, r, resolved.ID); err != nil {
|
||||
if err := a.startSession(w, r, resolved.ID, provenSignIn); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Mint the session — a regular felis_session; the lockdown is a product-level
|
||||
// restriction the frontend enforces until email is verified / a passkey is bound.
|
||||
if err := a.startSession(w, r, u.ID); err != nil {
|
||||
if err := a.startSession(w, r, u.ID, provenSignIn); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestSetEmailClearsVerified(t *testing.T) {
|
||||
repo.staff["u"] = &StaffUser{ID: "u1", Username: "u", Role: "admin", Email: "[email protected]", EmailVerified: true}
|
||||
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
api.External = staticExternal{p: &Principal{UserID: "u1", Role: "admin", ViaSession: true, EmailVerified: true}}
|
||||
api.External = staticExternal{p: &Principal{UserID: "u1", Role: "admin", ViaSession: true, EmailVerified: true, ReauthAt: frozenNow}}
|
||||
h := api.ExternalHandler()
|
||||
|
||||
w := do(h, "POST", "/api/v1/account/email", `{"email":"[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
|
||||
// (spec §B). Only the hash is stored, mirroring tokens.
|
||||
func (p *PGRepo) CreateSession(ctx context.Context, s NewSession) error {
|
||||
reauth := sql.NullTime{Time: s.ReauthAt, Valid: !s.ReauthAt.IsZero()}
|
||||
_, err := p.db.ExecContext(ctx,
|
||||
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP)
|
||||
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip, reauth_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP, reauth)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1136,17 +1137,21 @@ const sessionLive = `s.revoked_at IS NULL AND s.expires_at > $2
|
||||
// SessionUser resolves a live session hash to its user, or ErrNotFound.
|
||||
func (p *PGRepo) SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error) {
|
||||
const q = `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text, COALESCE(u.email_verified, false),
|
||||
s.last_seen_at
|
||||
s.last_seen_at, s.reauth_at
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1 AND ` + sessionLive
|
||||
var u SessionedUser
|
||||
var reauth sql.NullTime
|
||||
switch err := p.db.QueryRowContext(ctx, q, tokenHash, now, now.Add(-staffSessionIdle)).Scan(
|
||||
&u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified, &u.LastSeenAt); {
|
||||
&u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified, &u.LastSeenAt, &reauth); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, ErrNotFound
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
if reauth.Valid {
|
||||
u.ReauthAt = reauth.Time
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
@@ -1158,6 +1163,14 @@ func (p *PGRepo) TouchSession(ctx context.Context, tokenHash string, now time.Ti
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkSessionReauth records a proven factor on a live session.
|
||||
func (p *PGRepo) MarkSessionReauth(ctx context.Context, tokenHash string, at time.Time) error {
|
||||
_, err := p.db.ExecContext(ctx,
|
||||
`UPDATE sessions SET reauth_at = $2 WHERE token_hash = $1 AND revoked_at IS NULL`,
|
||||
tokenHash, at)
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeSession marks a session revoked (logout). Idempotent: a missing or
|
||||
// already-revoked session is not an error.
|
||||
func (p *PGRepo) RevokeSession(ctx context.Context, tokenHash string) error {
|
||||
|
||||
@@ -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
|
||||
// recorded by TouchSession (so up to sessionTouchEvery stale).
|
||||
LastSeenAt time.Time
|
||||
// ReauthAt is when the holder last proved a factor of the account (see
|
||||
// requireReauth); zero when the session never did.
|
||||
ReauthAt time.Time
|
||||
}
|
||||
|
||||
// NewSession is one session to record at sign-in: the sha-256 of the opaque
|
||||
@@ -176,6 +179,9 @@ type NewSession struct {
|
||||
ExpiresAt time.Time
|
||||
UserAgent string
|
||||
ClientIP string
|
||||
// ReauthAt is set when the sign-in itself proved a factor (passkey, email
|
||||
// code, op-login, setup token); zero for a bind-code sign-in.
|
||||
ReauthAt time.Time
|
||||
}
|
||||
|
||||
// OpLoginRequest is one op.console staff-login attempt (spec §B op-login): the
|
||||
@@ -596,6 +602,9 @@ type Repo interface {
|
||||
// never moves last_seen_at backwards, and touching an absent session is not
|
||||
// an error.
|
||||
TouchSession(ctx context.Context, tokenHash string, now time.Time) error
|
||||
// MarkSessionReauth records that the holder of a live session proved a factor
|
||||
// at the given time. Marking an absent or revoked session is not an error.
|
||||
MarkSessionReauth(ctx context.Context, tokenHash string, at time.Time) error
|
||||
// RevokeSession marks a session revoked (logout). It is idempotent: revoking an
|
||||
// absent or already-revoked session is not an error.
|
||||
RevokeSession(ctx context.Context, tokenHash string) error
|
||||
|
||||
+22
-2
@@ -66,24 +66,43 @@ func hashCookie(value string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// signInProof says whether the sign-in minting a session proved a factor of the
|
||||
// account. A proven sign-in counts as a fresh reauth, so the new session may add
|
||||
// a passkey or change the email straight away (requireReauth).
|
||||
type signInProof bool
|
||||
|
||||
const (
|
||||
// provenSignIn: a passkey, an email code, op-login or the setup token.
|
||||
provenSignIn signInProof = true
|
||||
// bindCodeSignIn: the in-game identity alone, which never unlocks the
|
||||
// account's other factors.
|
||||
bindCodeSignIn signInProof = false
|
||||
)
|
||||
|
||||
// startSession mints a session for userID and sets its cookie. Every sign-in door
|
||||
// ends here, so every session records the device it was minted for.
|
||||
func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||
func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string, proof signInProof) error {
|
||||
token, err := newSessionToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expires := a.now().Add(sessionTTL)
|
||||
now := a.now()
|
||||
expires := now.Add(sessionTTL)
|
||||
ip := ""
|
||||
if addr := a.clientIP(r); addr.IsValid() {
|
||||
ip = addr.String()
|
||||
}
|
||||
var reauth time.Time
|
||||
if proof == provenSignIn {
|
||||
reauth = now
|
||||
}
|
||||
if err := a.Repo.CreateSession(r.Context(), NewSession{
|
||||
TokenHash: hashCookie(token),
|
||||
UserID: userID,
|
||||
ExpiresAt: expires,
|
||||
UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent),
|
||||
ClientIP: ip,
|
||||
ReauthAt: reauth,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -227,6 +246,7 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
|
||||
ViaAdminAccess: staffRole(u.Role) && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname),
|
||||
EmailVerified: u.EmailVerified,
|
||||
ViaSession: true,
|
||||
ReauthAt: u.ReauthAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -187,3 +187,59 @@ func TestRevokeOtherUserSessionsKeepsOne(t *testing.T) {
|
||||
t.Fatalf("revoke-others keeping nothing = %d, %v; want 1", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// reauth_at: a proven sign-in stores the proof, a bind-code sign-in stores
|
||||
// none, SessionUser reads it back, and a reauth marks only a live session.
|
||||
func TestSessionReauthProof(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := mustNow()
|
||||
u := newUser(t, "user", "reauth")
|
||||
|
||||
unproven := newSession(t, u.ID, "unproven", now.Add(time.Hour))
|
||||
su, err := repo.SessionUser(ctx, unproven, now)
|
||||
if err != nil {
|
||||
t.Fatalf("SessionUser: %v", err)
|
||||
}
|
||||
if !su.ReauthAt.IsZero() {
|
||||
t.Fatalf("ReauthAt = %v on a session with no proof, want zero", su.ReauthAt)
|
||||
}
|
||||
|
||||
proven := "proven-" + suffix(t)
|
||||
signedIn := now.Add(-time.Minute)
|
||||
if err := repo.CreateSession(ctx, api.NewSession{
|
||||
TokenHash: proven, UserID: u.ID, ExpiresAt: now.Add(time.Hour), ReauthAt: signedIn,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
if su, err = repo.SessionUser(ctx, proven, now); err != nil || !sameMicro(su.ReauthAt, signedIn) {
|
||||
t.Fatalf("SessionUser = %+v, %v; want ReauthAt %v", su, err, signedIn)
|
||||
}
|
||||
|
||||
if err := repo.MarkSessionReauth(ctx, unproven, now); err != nil {
|
||||
t.Fatalf("MarkSessionReauth: %v", err)
|
||||
}
|
||||
if su, err = repo.SessionUser(ctx, unproven, now); err != nil || !sameMicro(su.ReauthAt, now) {
|
||||
t.Fatalf("after a mark SessionUser = %+v, %v; want ReauthAt %v", su, err, now)
|
||||
}
|
||||
// The other session keeps its own proof.
|
||||
if su, _ = repo.SessionUser(ctx, proven, now); !sameMicro(su.ReauthAt, signedIn) {
|
||||
t.Fatalf("marking one session moved another's proof to %v", su.ReauthAt)
|
||||
}
|
||||
|
||||
if err := repo.RevokeSession(ctx, proven); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.MarkSessionReauth(ctx, proven, now.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("marking a revoked session: %v", err)
|
||||
}
|
||||
var stored time.Time
|
||||
if err := db.QueryRow(`SELECT reauth_at FROM sessions WHERE token_hash = $1`, proven).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameMicro(stored, signedIn) {
|
||||
t.Fatalf("a revoked session's reauth_at moved to %v", stored)
|
||||
}
|
||||
if err := repo.MarkSessionReauth(ctx, "no-such-"+suffix(t), now); err != nil {
|
||||
t.Fatalf("marking an absent session: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user