feat(api): 添加或删除 passkey、修改邮箱前须 5 分钟内用已有因子重新验证,变更后邮件通知账户,面板加确认对话框与修改邮箱入口

This commit is contained in:
Lemon-miaow committed 2026-09-25 14:47:54 +08:00
1 parent 9e7f23ca13
commit d3769b5c31
36 files changed
+2735 -288

No files matched your search

+125
View File
@@ -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:]
}
+10
View File
@@ -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.
+17 -4
View File
@@ -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 {
+1 -1
View File
@@ -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"
+4
View File
@@ -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,
+10 -152
View File
@@ -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)},
} {
+1 -1
View File
@@ -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
}
+14 -1
View File
@@ -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
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+14 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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 {
+416
View File
@@ -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
}
+557
View File
@@ -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 := &noticeMailer{}
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)
}
}
}
+9
View File
@@ -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
View File
@@ -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
}
+56
View File
@@ -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;