Unverified Commit d3769b5c authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 9e7f23ca
Loading
Loading
Loading
Loading
+237 −7
Changes for docs/openapi.yaml: 237 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -157,6 +157,27 @@ components:
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Reauthed:
      description: This session is reauthed until the returned time.
      content:
        application/json:
          schema:
            type: object
            required: [ok, until]
            properties:
              ok: { type: boolean, const: true }
              until: { type: string, format: date-time }
    ReauthRequired:
      description: >
        reauth_required: this change adds, removes or moves a way into the account,
        and the account has a passkey or a verified email, so the session must have
        proven one of them within the last 5 minutes. Signing in by passkey, email
        code, op-login or the setup token counts; a bind-code sign-in does not.
        GET /api/v1/account/reauth lists the factors that can give the proof, then
        retry the change.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    MailUndeliverable:
      description: >
        The configured SMTP relay refused the message (code mail_undeliverable), so no
@@ -3977,7 +3998,8 @@ paths:
        Generates a one-time code bound to the authenticated principal and the
        supplied address, persists only its hash, and delivers it out of band. The
        code is never returned in the response. A re-request supersedes the prior
        unconsumed code.
        unconsumed code. Once the account has a passkey or a verified email, the
        session must have reauthed within 5 minutes (403 reauth_required).
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
@@ -4008,6 +4030,8 @@ paths:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ReauthRequired'
        '429':
          description: >-
            Resend requested before the cooldown elapsed (otp_resend_cooldown); or the
@@ -4030,7 +4054,9 @@ paths:
        success the user's email is written and email_verified is set true. When the
        new address replaces a different verified one, every other session of the
        caller is signed out: sign-in codes now go to the new address, so a session
        opened through the old one ends. Too many
        opened through the old one ends; the old address is mailed a notice with the
        new one masked. A verified code also counts as a reauth for this session.
        Too many
        incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h,
        counted across every code, lock the account's email-code door until the
        window ends (429 otp_account_locked with Retry-After). An unknown, expired,
@@ -4082,7 +4108,9 @@ paths:
        Writes the supplied address to the authenticated principal's user row and
        clears email_verified (already false for a fresh Owner). The setup bootstrap
        has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP
        flow proves control of the address via /account/email/verify.
        flow proves control of the address via /account/email/verify. Clearing a
        verified address strips a factor, so once the account has one the session
        must have reauthed within 5 minutes (403 reauth_required).
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
@@ -4112,6 +4140,8 @@ paths:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ReauthRequired'

  /api/v1/account/passkey/register/begin:
    post:
@@ -4122,8 +4152,10 @@ paths:
        Mints a credential-creation challenge bound to the authenticated principal,
        stashes the server-side ceremony state under a short TTL, and returns the
        WebAuthn publicKey creation options for navigator.credentials.create(). The
        challenge is never echoed by the client. Enrollment only — passkey login is a
        deferred slice. 503 when the WebAuthn verifier is not configured on this instance.
        challenge is never echoed by the client. Once the account has a passkey or a
        verified email, the session must have reauthed within 5 minutes (403
        reauth_required). 503 when the WebAuthn verifier is not configured on this
        instance.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
@@ -4137,6 +4169,8 @@ paths:
                description: Opaque WebAuthn PublicKeyCredentialCreationOptions, passed verbatim to the browser.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ReauthRequired'
        '503':
          description: Passkey subsystem is not configured.
          content:
@@ -4153,7 +4187,9 @@ paths:
        authenticator's attestation against the server-stashed ceremony state, and
        persists the public credential. A missing or expired ceremony is a 400; an
        attestation that fails verification is a 400; a credential already bound to any
        account is a 409. 503 when the WebAuthn verifier is not configured.
        account is a 409. The verified email is mailed a notice, and the ceremony
        counts as a reauth for this session. 503 when the WebAuthn verifier is not
        configured.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
@@ -4231,7 +4267,9 @@ paths:
        silently no-ops as success. The account's only passkey cannot be removed while
        its email is unverified (409 last_passkey): it is then the account's only
        durable way in. Removing a passkey signs out every other session of the
        caller, so a session opened with that passkey ends with it.
        caller, so a session opened with that passkey ends with it, and mails the
        verified email a notice. The session must have reauthed within 5 minutes
        (403 reauth_required).
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
@@ -4246,6 +4284,8 @@ paths:
          description: Passkey unbound.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ReauthRequired'
        '404':
          description: No such passkey for this caller.
          content:
@@ -4257,6 +4297,196 @@ paths:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/account/reauth:
    get:
      tags: [account]
      operationId: reauthStatus
      summary: Say whether a passkey or email change needs a reauth first, and how to give one.
      description: >
        needed is true when the account has a passkey or a verified email and this
        session has not proven one within the last 5 minutes. until is when the
        current proof stops counting. factors lists the ways this caller can
        reauth, best first: passkey (an enrolled passkey), email (a player's
        verified address), sign_in (an operator signs out and back in through
        op-login or a passkey).
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
      responses:
        '200':
          description: Where the caller stands.
          content:
            application/json:
              schema:
                type: object
                required: [needed, factors]
                properties:
                  needed: { type: boolean }
                  until: { type: string, format: date-time }
                  factors:
                    type: array
                    items: { type: string, enum: [passkey, email, sign_in] }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/account/reauth/passkey/begin:
    post:
      tags: [account]
      operationId: reauthPasskeyBegin
      summary: Begin a passkey assertion that reauths this session.
      description: >
        Returns WebAuthn assertion request options over the caller's own passkeys,
        bound to a fresh reauth-purpose challenge.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
      responses:
        '200':
          description: WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get.
          content:
            application/json:
              schema: { type: object, description: Opaque WebAuthn PublicKeyCredentialRequestOptions. }
        '400':
          description: The caller has no enrolled passkey (no_passkey), or no browser session to mark (no_session).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /api/v1/account/reauth/passkey/finish:
    post:
      tags: [account]
      operationId: reauthPasskeyFinish
      summary: Finish the passkey assertion and mark this session reauthed for 5 minutes.
      description: >
        Verifies the assertion against the reauth challenge with the login door's
        clone check (a cloned authenticator is 400 passkey_login_invalid).
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [assertion]
              properties:
                assertion:
                  type: object
                  description: The navigator.credentials.get() PublicKeyCredential assertion.
      responses:
        '200':
          $ref: '#/components/responses/Reauthed'
        '400':
          description: Assertion invalid, challenge stale, or a cloned authenticator (passkey_login_invalid); no browser session (no_session).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /api/v1/account/reauth/email/start:
    post:
      tags: [account]
      operationId: reauthEmailStart
      summary: Mail a reauth code to the caller's verified address.
      description: >
        For players with a verified email. Operators reauth with a passkey or by
        signing in again (403 staff_reauth).
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
      responses:
        '202':
          description: Code minted and dispatched.
          content:
            application/json:
              schema:
                type: object
                required: [sent, expires_at]
                properties:
                  sent: { type: boolean, const: true }
                  expires_at: { type: string, format: date-time }
        '400':
          description: No browser session to mark (no_session).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Operators cannot reauth by email (staff_reauth).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: The account has no verified email (no_step_up_factor).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: >-
            Resend requested before the cooldown elapsed (otp_resend_cooldown); or the
            account's daily wrong-code budget is spent (otp_account_locked, with
            Retry-After); or the install-wide mail budget is spent
            (mail_rate_limited, with Retry-After).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          $ref: '#/components/responses/MailUndeliverable'

  /api/v1/account/reauth/email/verify:
    post:
      tags: [account]
      operationId: reauthEmailVerify
      summary: Redeem the reauth code and mark this session reauthed for 5 minutes.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        '200':
          $ref: '#/components/responses/Reauthed'
        '400':
          description: Invalid or expired code (invalid_code), or no browser session (no_session).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Operators cannot reauth by email (staff_reauth).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: The account has no verified email (no_step_up_factor).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: >-
            Too many incorrect attempts on this code (otp_locked), or the account's
            daily wrong-code budget is spent (otp_account_locked, with Retry-After).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/account/sessions:
    get:
      tags: [account]
+125 −0
Changes for internal/api/account_notice.go: 125 added lines, 0 removed lines.
Original line number Diff line number Diff line
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 −0
Changes for internal/api/api.go: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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.
+16 −3
Changes for internal/api/api_test.go: 16 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -75,9 +75,11 @@ 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
	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.
@@ -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
Changes for internal/api/audit_test.go: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -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"
Loading