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

feat(api): 会话记录设备与最近活动,账户页可查看并退出任一设备,删除 passkey 或更换邮箱时退出其它设备,staff 会话空闲 30 分钟失效,吊销会话校验所属用户

parent 98295e63
Loading
Loading
Loading
Loading
+124 −6
Changes for docs/openapi.yaml: 124 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -574,16 +574,38 @@ components:

    SessionView:
      type: object
      description: One live session of a user visible to an admin (internal/api/repo.go SessionView).
      required: [token_hash, created_at, expires_at]
      description: >-
        One live session, as the account holder and an admin see it
        (internal/api/repo.go SessionView).
      required: [token_hash, created_at, expires_at, last_seen_at, user_agent, client_ip]
      properties:
        token_hash: { type: string }
        token_hash:
          type: string
          description: The sha-256 of the session cookie; the id the revoke routes take.
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        last_seen_at:
          type: string
          format: date-time
          description: >-
            When the session last authenticated a request, recorded at most once a
            minute. A staff session idle for 30 minutes stops authenticating and
            leaves the list.
        user_agent:
          type: string
          description: The browser's User-Agent at sign-in (at most 256 bytes; empty when none was sent).
        client_ip:
          type: string
          description: The address the sign-in came from (empty when unknown).
        revoked_at:
          type: string
          format: date-time
          description: Present only once the session is revoked.
        current:
          type: boolean
          description: >-
            On the holder's own list only, true on the session the request came in
            on. Absent otherwise.

paths:
  # ----------------------------------------------------------------- health ---
@@ -3693,7 +3715,7 @@ paths:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Live (unrevoked, unexpired) sessions, newest first.
          description: Live sessions, most recently seen first.
          content:
            application/json:
              schema:
@@ -3756,6 +3778,13 @@ paths:
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: >-
            session_not_found — the hash is not a live session of this user (another
            user's, already ended, or unknown). Nothing is revoked.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/users/{id}/passkeys:
    delete:
@@ -3998,7 +4027,10 @@ paths:
      summary: Redeem an email one-time code and mark the caller's email verified (spec §B2).
      description: >
        Consumes a previously delivered code for the authenticated principal. On
        success the user's email is written and email_verified is set true. Too many
        success the user's email is written and email_verified is set true. When the
        new address replaces a different verified one, every other session of the
        caller is signed out: sign-in codes now go to the new address, so a session
        opened through the old one ends. Too many
        incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h,
        counted across every code, lock the account's email-code door until the
        window ends (429 otp_account_locked with Retry-After). An unknown, expired,
@@ -4198,7 +4230,8 @@ paths:
        unbind their OWN credential. An unknown or cross-user id is a 404; it never
        silently no-ops as success. The account's only passkey cannot be removed while
        its email is unverified (409 last_passkey): it is then the account's only
        durable way in.
        durable way in. Removing a passkey signs out every other session of the
        caller, so a session opened with that passkey ends with it.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
@@ -4224,6 +4257,91 @@ paths:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/account/sessions:
    get:
      tags: [account]
      operationId: listMySessions
      summary: List the caller's own live sessions, marking the one this request came in on.
      description: >
        Every device signed in to the caller's account, most recently seen first. A
        caller signed in through Cloudflare Access has no session of its own, so no
        entry is marked current.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
      responses:
        '200':
          description: The caller's live sessions.
          content:
            application/json:
              schema:
                type: object
                required: [sessions]
                properties:
                  sessions:
                    type: array
                    items: { $ref: '#/components/schemas/SessionView' }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/account/sessions/{hash}:
    delete:
      tags: [account]
      operationId: revokeMySession
      summary: Sign out one of the caller's sessions.
      description: >
        Scoped to the caller: a hash that is not one of the caller's live sessions is
        a 404 whoever it belongs to. Revoking the session the request came in on is
        a sign-out; the cookie is cleared and signed_out is true.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
      parameters:
        - { name: hash, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Session revoked.
          content:
            application/json:
              schema:
                type: object
                required: [ok, signed_out]
                properties:
                  ok: { type: boolean, const: true }
                  signed_out:
                    type: boolean
                    description: True when the revoked session was the caller's own, which is now signed out.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: session_not_found — not a live session of the caller.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/account/sessions/revoke-others:
    post:
      tags: [account]
      operationId: revokeMyOtherSessions
      summary: Sign out every session of the caller except the one making this request.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
      responses:
        '200':
          description: Other sessions revoked.
          content:
            application/json:
              schema:
                type: object
                required: [revoked]
                properties:
                  revoked:
                    type: integer
                    description: How many sessions were signed out.
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/account/migrate:
    get:
      tags: [account]
+6 −0
Changes for internal/api/api.go: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -560,6 +560,12 @@ 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},
		// 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.
		{Method: "GET", Pattern: "/api/v1/account/sessions", h: a.handleListMySessions},
		{Method: "DELETE", Pattern: "/api/v1/account/sessions/{hash}", h: a.handleRevokeMySession},
		{Method: "POST", Pattern: "/api/v1/account/sessions/revoke-others", h: a.handleRevokeMyOtherSessions},
		// Account migration (spec §B3 inherit), web side. App-tier, principal-scoped: the
		// SOURCE drives status → step-up confirm (passkey forced when enrolled, else
		// email-OTP) → issue-code+name-target; the TARGET drives redeem as itself. Not
+85 −10
Changes for internal/api/api_test.go: 85 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -75,6 +75,9 @@ 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 error
	failRevokeOthers 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.
@@ -210,6 +213,13 @@ type fakeSession struct {
	userID    string
	expiresAt time.Time
	revoked   bool
	// lastSeen is last_seen_at; zero reads as "seen at the moment it is asked
	// about", so a literal session in a test is fresh unless it says otherwise.
	lastSeen  time.Time
	createdAt time.Time
	userAgent string
	clientIP  string
	touches   int
}

// fakeBackup mirrors a world_backups row: the client-facing view plus the
@@ -889,30 +899,70 @@ func (f *fakeRepo) UpsertOwner(_ context.Context, id, username, email string) er
	}
	return nil
}
func (f *fakeRepo) CreateSession(_ context.Context, tokenHash, userID string, expiresAt time.Time) error {
	f.sessions[tokenHash] = &fakeSession{userID: userID, expiresAt: expiresAt}
func (f *fakeRepo) CreateSession(_ context.Context, ns NewSession) error {
	// The API clock minted ExpiresAt, so this is the sign-in time on that clock.
	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,
	}
	return nil
}

// liveSession mirrors PGRepo's sessionLive: unrevoked, unexpired, its account
// alive, and a staff session seen within staffSessionIdle.
func (f *fakeRepo) liveSession(s *fakeSession, now time.Time) (*StaffUser, bool) {
	if s.revoked || !s.expiresAt.After(now) {
		return nil, false
	}
	for _, u := range f.staff {
		if u.ID != s.userID {
			continue
		}
		if f.seededDead(u.ID) {
			return nil, false
		}
		if u.Role != "user" && !s.lastSeenAt(now).After(now.Add(-staffSessionIdle)) {
			return nil, false
		}
		return u, true
	}
	return nil, false
}

func (s *fakeSession) lastSeenAt(now time.Time) time.Time {
	if s.lastSeen.IsZero() {
		return now
	}
	return s.lastSeen
}

func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Time) (*SessionedUser, error) {
	if f.failSessionUser != nil {
		return nil, f.failSessionUser
	}
	s, ok := f.sessions[tokenHash]
	if !ok || s.revoked || !s.expiresAt.After(now) {
	if !ok {
		return nil, ErrNotFound
	}
	for _, u := range f.staff {
		if u.ID == s.userID {
			if f.seededDead(u.ID) {
	u, ok := f.liveSession(s, now)
	if !ok {
		return nil, ErrNotFound
	}
	return &SessionedUser{
		ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role,
				EmailVerified: u.EmailVerified,
		EmailVerified: u.EmailVerified, LastSeenAt: s.lastSeenAt(now),
	}, nil
}
func (f *fakeRepo) TouchSession(_ context.Context, tokenHash string, now time.Time) error {
	if f.failTouchSession != nil {
		return f.failTouchSession
	}
	return nil, ErrNotFound
	if s, ok := f.sessions[tokenHash]; ok && s.lastSeen.Before(now) {
		s.lastSeen = now
		s.touches++
	}
	return nil
}
func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error {
	if s, ok := f.sessions[tokenHash]; ok {
@@ -920,6 +970,27 @@ func (f *fakeRepo) RevokeSession(_ context.Context, tokenHash string) error {
	}
	return nil
}
func (f *fakeRepo) RevokeUserSession(_ context.Context, userID, tokenHash string) error {
	s, ok := f.sessions[tokenHash]
	if !ok || s.userID != userID || s.revoked {
		return ErrNotFound
	}
	s.revoked = true
	return nil
}
func (f *fakeRepo) RevokeOtherUserSessions(_ context.Context, userID, keepTokenHash string) (int, error) {
	if f.failRevokeOthers != nil {
		return 0, f.failRevokeOthers
	}
	n := 0
	for hash, s := range f.sessions {
		if s.userID == userID && hash != keepTokenHash && !s.revoked {
			s.revoked = true
			n++
		}
	}
	return n, nil
}
func (f *fakeRepo) GetSetting(_ context.Context, key string) ([]byte, error) {
	if f.failGetSetting != nil {
		return nil, f.failGetSetting
@@ -1331,10 +1402,14 @@ func (f *fakeRepo) SetQuotas(_ context.Context, userID string, qi QuotaInput, _
func (f *fakeRepo) ListUserSessions(_ context.Context, userID string, now time.Time) ([]SessionView, error) {
	var out []SessionView
	for hash, s := range f.sessions {
		if s.userID == userID && !s.revoked && s.expiresAt.After(now) {
			out = append(out, SessionView{TokenHash: hash, CreatedAt: time.Now(), ExpiresAt: s.expiresAt})
		if _, live := f.liveSession(s, now); live && s.userID == userID {
			out = append(out, SessionView{
				TokenHash: hash, CreatedAt: s.createdAt, ExpiresAt: s.expiresAt,
				LastSeenAt: s.lastSeenAt(now), UserAgent: s.userAgent, ClientIP: s.clientIP,
			})
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].LastSeenAt.After(out[j].LastSeenAt) })
	return out, nil
}

+102 −0
Changes for internal/api/handlers_account_sessions.go: 102 added lines, 0 removed lines.
Original line number Diff line number Diff line
package api

import (
	"errors"
	"log"
	"net/http"

	"felis.lolicon.best/internal/metrics"
)

// The account holder's own sessions: every device signed in to the account,
// which one is making this request, and a way to sign any of them out. The admin
// routes in handlers_users.go read and revoke the same rows for any user.

// handleListMySessions lists the caller's live sessions, most recently seen
// first, marking the one this request came in on (GET /account/sessions). A
// caller signed in through Cloudflare Access has no session of its own, so none
// is marked.
func (a *API) handleListMySessions(w http.ResponseWriter, r *http.Request) {
	p := principalFromContext(r.Context())
	sessions, err := a.Repo.ListUserSessions(r.Context(), p.UserID, a.now())
	if err != nil {
		writeError(w, r, err)
		return
	}
	if sessions == nil {
		sessions = []SessionView{}
	}
	if cur := callerSessionHash(r, p); cur != "" {
		for i := range sessions {
			sessions[i].Current = sessions[i].TokenHash == cur
		}
	}
	writeJSON(w, http.StatusOK, map[string]any{"sessions": sessions})
}

// handleRevokeMySession signs out one of the caller's sessions
// (DELETE /account/sessions/{hash}). A hash that is not a live session of the
// caller is 404, whoever it belongs to. Revoking the session this request came
// in on is a sign-out, so the cookie is cleared too.
func (a *API) handleRevokeMySession(w http.ResponseWriter, r *http.Request) {
	p := principalFromContext(r.Context())
	hash := r.PathValue("hash")
	if err := a.Repo.RevokeUserSession(r.Context(), p.UserID, hash); err != nil {
		if errors.Is(err, ErrNotFound) {
			writeError(w, r, newError(http.StatusNotFound, "session_not_found",
				"that session has already ended or is not one of yours"))
			return
		}
		writeError(w, r, err)
		return
	}
	current := hash == callerSessionHash(r, p)
	if current {
		clearSessionCookie(w)
	}
	metrics.SessionsRevokedTotal.WithLabelValues("self").Inc()
	a.audit(r, "account.session.revoked", "")
	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "signed_out": current})
}

// handleRevokeMyOtherSessions signs out every session of the caller except the
// one this request came in on (POST /account/sessions/revoke-others), and says
// how many it ended.
func (a *API) handleRevokeMyOtherSessions(w http.ResponseWriter, r *http.Request) {
	p := principalFromContext(r.Context())
	n, err := a.Repo.RevokeOtherUserSessions(r.Context(), p.UserID, callerSessionHash(r, p))
	if err != nil {
		writeError(w, r, err)
		return
	}
	metrics.SessionsRevokedTotal.WithLabelValues("self").Add(float64(n))
	a.audit(r, "account.session.revoked_others", "")
	writeJSON(w, http.StatusOK, map[string]any{"revoked": n})
}

// revokeOtherSessionsAfter signs out the caller's other devices after a change
// that retires a way in: a removed passkey, or a new verified email replacing the
// address sign-in codes went to. A session opened with the old factor ends with
// it. The change has already committed, so a failure here is logged and the
// request still succeeds; answering an error would invite retrying a change that
// took effect.
func (a *API) revokeOtherSessionsAfter(r *http.Request, change string) {
	p := principalFromContext(r.Context())
	n, err := a.Repo.RevokeOtherUserSessions(r.Context(), p.UserID, callerSessionHash(r, p))
	if err != nil {
		log.Printf("api: sign out other sessions after %s (request_id=%s): %v", change, requestIDFromContext(r.Context()), err)
		return
	}
	if n > 0 {
		metrics.SessionsRevokedTotal.WithLabelValues("security").Add(float64(n))
	}
}

// callerSessionHash is the session the request authenticated with, or "" when it
// authenticated some other way (a cookie beside an Access JWT names nothing).
func callerSessionHash(r *http.Request, p *Principal) string {
	if p == nil || !p.ViaSession {
		return ""
	}
	return currentSessionHash(r)
}
+322 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading