From fd1f3ef6365d208854bc3d83eb94c47fca6350d3 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Wed, 6 May 2026 09:48:18 +0800 Subject: [PATCH] feat(auth): implement hybrid session model with HTTP-only cookies Add HTTP-only cookie session support to backend for SSR compatibility Update frontend fetch calls to include credentials Maintain legacy bearer token support for SPA transition --- DESIGN.md | 12 +++-- SSR_MIGRATION_TASKLIST.md | 21 ++++++--- backend/src/server.ts | 74 ++++++++++++++++++++++++++---- frontend/app.vue | 9 ++-- frontend/middleware/auth.global.ts | 6 +-- frontend/src/services/api.ts | 6 +++ 6 files changed, 97 insertions(+), 31 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 7a01959..23ebc91 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -120,10 +120,14 @@ - 重置 token 只保存 hash,并带过期时间和使用状态。 - 密码重置成功后不自动登录,并删除该用户已有 session。 - 登录页提供 Remember me: - - 未勾选时前端将登录 token 保存在 `sessionStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 1 天。 - - 勾选时前端将登录 token 保存在 `localStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 30 天。 -- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。 -- 用户可退出登录,退出时删除对应 session。 + - 未勾选时 session 有效期为 1 天。 + - 勾选时 session 有效期为 30 天。 +- SSR 迁移期认证使用 hybrid session model: + - 登录成功后后端设置 HTTP-only `pokopia_session` cookie;cookie 只保存明文 session token,数据库只保存 session token hash。 + - 迁移期登录响应仍返回明文 session token,前端继续按 Remember me 语义保存到 `sessionStorage` 或 `localStorage` 的 `pokopia_auth_token`,用于保持现有 SPA 客户端流程兼容。 + - 受保护 API 优先接受 HTTP-only cookie session,并继续兼容 `Authorization: Bearer` legacy token。 + - 前端 API 请求携带 credentials,以便浏览器自动发送 HTTP-only session cookie;JavaScript 不读取该 cookie。 +- 用户可退出登录,退出时删除对应 session、清除 HTTP-only session cookie,并清理前端 legacy token storage。 - 对外用户字段只包含必要信息: - 当前用户:`id`、`email`、`displayName`、`emailVerified` - 编辑署名:`id`、`displayName` diff --git a/SSR_MIGRATION_TASKLIST.md b/SSR_MIGRATION_TASKLIST.md index 53c4a45..ae24e63 100644 --- a/SSR_MIGRATION_TASKLIST.md +++ b/SSR_MIGRATION_TASKLIST.md @@ -35,20 +35,27 @@ Keep this file aligned with implementation progress while the SSR migration is i - [x] Define separate public/browser API origin and internal server API origin if Docker networking requires different URLs for server-side fetches and browser fetches. - [x] Ensure every server-side API read sends the correct `X-Locale` and never sends browser-only bearer tokens unless a secure SSR auth mechanism is implemented. - [x] Add a small SSR-safe fetch wrapper or adapt `frontend/src/services/api.ts` so public reads can be called from server-side setup without depending on `window`, storage, or DOM APIs. -- [ ] Keep frontend API response types consistent with `frontend/src/services/api.ts`. +- [x] Keep frontend API response types consistent with `frontend/src/services/api.ts`. - [ ] Ensure API errors used for SSR public routes degrade to intended empty/error states without leaking stack traces or internal fields into rendered HTML. ## Phase 3: Authentication And Session Model -- [ ] Decide and document the SSR-compatible auth model in `DESIGN.md` before implementation. -- [ ] Migrate auth from `localStorage` / `sessionStorage` bearer-token-only behavior to an HTTP-only cookie/session model, or explicitly document a hybrid model if Remember me must preserve current storage behavior. -- [ ] Update backend login/logout/session endpoints to support the chosen cookie/session model without exposing session token hashes or internal session metadata. -- [ ] Preserve Remember me semantics: 1 day for non-remembered sessions, 30 days for remembered sessions. -- [ ] Preserve email verification as the base requirement for protected writes. +- [x] Decide and document the SSR-compatible auth model in `DESIGN.md` before implementation. +- [x] Migrate auth from `localStorage` / `sessionStorage` bearer-token-only behavior to an HTTP-only cookie/session model, or explicitly document a hybrid model if Remember me must preserve current storage behavior. +- [x] Update backend login/logout/session endpoints to support the chosen cookie/session model without exposing session token hashes or internal session metadata. +- [x] Preserve Remember me semantics: 1 day for non-remembered sessions, 30 days for remembered sessions. +- [x] Preserve email verification as the base requirement for protected writes. - [ ] Ensure current-user SSR reads expose only the allowed current-user fields defined in `DESIGN.md`. - [ ] Update route middleware so server-side redirects for authenticated and permissioned routes match current client-side behavior. - [ ] Ensure public SSR pages never render private current-user data into HTML meant for anonymous users. -- [ ] Add a clear logout flow that clears both server cookies and any legacy client storage during the transition. +- [x] Add a clear logout flow that clears both server cookies and any legacy client storage during the transition. + +### Phase 3 Auth Notes + +- The migration now uses a hybrid session model: backend login sets an HTTP-only `pokopia_session` cookie and still returns the legacy bearer token so existing SPA storage behavior keeps working during the transition. +- Protected backend reads and writes accept the HTTP-only cookie first and remain compatible with `Authorization: Bearer` tokens. +- Frontend API requests use `credentials: 'include'` so browser requests can carry the cookie without exposing it to JavaScript. +- Login still stores the legacy token according to Remember me semantics; logout deletes the server session, clears the cookie, and clears legacy frontend storage. ## Phase 4: Nuxt SSR Enablement diff --git a/backend/src/server.ts b/backend/src/server.ts index c97c8dd..27e329f 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -166,6 +166,9 @@ const app = Fastify({ logger: true, trustProxy: process.env.TRUST_PROXY === 'true' }); +const sessionCookieName = 'pokopia_session'; +const rememberedSessionDays = 30; +const sessionOnlySessionDays = 1; function configuredCorsOrigin(): true | string | string[] { const rawOrigin = process.env.FRONTEND_ORIGIN?.trim(); @@ -183,7 +186,8 @@ function configuredCorsOrigin(): true | string | string[] { await app.register(cors, { allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'], - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], origin: configuredCorsOrigin() }); @@ -248,6 +252,54 @@ function getBearerToken(authorization: string | undefined): string | null { return scheme === 'Bearer' && token ? token : null; } +function getCookieValue(cookieHeader: string | undefined, name: string): string | null { + if (!cookieHeader) { + return null; + } + + for (const cookiePart of cookieHeader.split(';')) { + const [rawName, ...rawValue] = cookiePart.trim().split('='); + if (rawName === name) { + try { + return decodeURIComponent(rawValue.join('=')); + } catch { + return rawValue.join('='); + } + } + } + + return null; +} + +function getSessionToken(request: FastifyRequest): string | null { + return getCookieValue(request.headers.cookie, sessionCookieName) ?? getBearerToken(request.headers.authorization); +} + +function sessionCookieSecure(): boolean { + const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? ''; + return origin.split(',').some((value) => value.trim().startsWith('https://')); +} + +function sessionCookie(value: string, maxAgeSeconds: number): string { + return [ + `${sessionCookieName}=${encodeURIComponent(value)}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + `Max-Age=${maxAgeSeconds}`, + ...(sessionCookieSecure() ? ['Secure'] : []) + ].join('; '); +} + +function setSessionCookie(reply: FastifyReply, token: string, rememberMe: boolean): void { + const sessionDays = rememberMe ? rememberedSessionDays : sessionOnlySessionDays; + reply.header('Set-Cookie', sessionCookie(token, sessionDays * 24 * 60 * 60)); +} + +function clearSessionCookie(reply: FastifyReply): void { + reply.header('Set-Cookie', `${sessionCookie('', 0)}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`); +} + function requestLocale(request: FastifyRequest): string { const query = request.query as Record; const queryLocale = Array.isArray(query.locale) ? query.locale[0] : query.locale; @@ -868,7 +920,7 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply) return null; } - const token = getBearerToken(request.headers.authorization); + const token = getSessionToken(request); const user = token ? await getUserBySessionToken(token) : null; const locale = requestLocale(request); @@ -950,7 +1002,7 @@ async function requireAnyPermissionWithRateLimits( } async function optionalUser(request: FastifyRequest): Promise { - const token = getBearerToken(request.headers.authorization); + const token = getSessionToken(request); if (!token) { return null; } @@ -983,7 +1035,10 @@ app.post('/api/auth/login', async (request, reply) => { return; } - return loginUser(request.body as Record, requestLocale(request)); + const payload = request.body as Record; + const response = await loginUser(payload, requestLocale(request)); + setSessionCookie(reply, response.token, payload.rememberMe === true); + return response; }); app.post('/api/auth/request-password-reset', async (request, reply) => { @@ -1007,7 +1062,7 @@ app.get('/api/auth/me', async (request, reply) => { return; } - const token = getBearerToken(request.headers.authorization); + const token = getSessionToken(request); const user = token ? await getUserBySessionToken(token) : null; if (!user) { @@ -1022,7 +1077,7 @@ app.patch('/api/auth/me', async (request, reply) => { return; } - const token = getBearerToken(request.headers.authorization); + const token = getSessionToken(request); const user = token ? await getUserBySessionToken(token) : null; if (!user) { @@ -1042,7 +1097,7 @@ app.patch('/api/auth/me/password', async (request, reply) => { return; } - const token = getBearerToken(request.headers.authorization); + const token = getSessionToken(request); const user = token ? await getUserBySessionToken(token) : null; if (!user || !token) { @@ -1062,7 +1117,7 @@ app.get('/api/auth/referral', async (request, reply) => { return; } - const token = getBearerToken(request.headers.authorization); + const token = getSessionToken(request); const user = token ? await getUserBySessionToken(token) : null; if (!user) { @@ -1099,11 +1154,12 @@ app.post('/api/notifications/:id/read', async (request, reply) => { }); app.post('/api/auth/logout', async (request, reply) => { - const token = getBearerToken(request.headers.authorization); + const token = getSessionToken(request); if (token) { await logoutSession(token); } + clearSessionCookie(reply); return reply.code(204).send(); }); diff --git a/frontend/app.vue b/frontend/app.vue index bf845d2..0bd0c6c 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -112,17 +112,14 @@ const navItems = computed(() => { }); async function loadCurrentUser() { - if (!getAuthToken()) { - currentUser.value = null; - return; - } - try { const response = await api.me(); currentUser.value = response.user; } catch { currentUser.value = null; - setAuthToken(null); + if (getAuthToken()) { + setAuthToken(null); + } } } diff --git a/frontend/middleware/auth.global.ts b/frontend/middleware/auth.global.ts index 3c00312..4560fb2 100644 --- a/frontend/middleware/auth.global.ts +++ b/frontend/middleware/auth.global.ts @@ -1,4 +1,4 @@ -import { api, getAuthToken, setAuthToken } from '../src/services/api'; +import { api, setAuthToken } from '../src/services/api'; export default defineNuxtRouteMiddleware(async (to) => { const requiredPermissions = to.matched @@ -16,10 +16,6 @@ export default defineNuxtRouteMiddleware(async (to) => { return; } - if (!getAuthToken()) { - return navigateTo({ path: '/login', query: { redirect: to.fullPath } }); - } - try { const response = await api.me(); if (requiresVerified && !response.user.emailVerified) { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2c904d2..8d24ed8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1136,6 +1136,7 @@ async function getErrorMessage(response: Response): Promise { async function getJson(path: string, signal?: AbortSignal): Promise { const response = await fetch(apiUrl(path), { + credentials: 'include', headers: requestHeaders(), signal }); @@ -1149,6 +1150,7 @@ async function getJson(path: string, signal?: AbortSignal): Promise { async function sendJson(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise { const response = await fetch(apiUrl(path), { + credentials: 'include', method, headers: { 'Content-Type': 'application/json', @@ -1166,6 +1168,7 @@ async function sendJson(path: string, method: 'PATCH' | 'POST' | 'PUT', body: async function sendFormData(path: string, body: FormData): Promise { const response = await fetch(apiUrl(path), { + credentials: 'include', method: 'POST', headers: requestHeaders(), body @@ -1180,6 +1183,7 @@ async function sendFormData(path: string, body: FormData): Promise { async function postEmpty(path: string): Promise { const response = await fetch(apiUrl(path), { + credentials: 'include', method: 'POST', headers: requestHeaders() }); @@ -1191,6 +1195,7 @@ async function postEmpty(path: string): Promise { async function deleteJson(path: string): Promise { const response = await fetch(apiUrl(path), { + credentials: 'include', method: 'DELETE', headers: requestHeaders() }); @@ -1202,6 +1207,7 @@ async function deleteJson(path: string): Promise { async function deleteAndGetJson(path: string): Promise { const response = await fetch(apiUrl(path), { + credentials: 'include', method: 'DELETE', headers: requestHeaders() });