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
This commit is contained in:
2026-05-06 09:48:18 +08:00
parent afed409127
commit fd1f3ef636
6 changed files with 97 additions and 31 deletions

View File

@@ -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` cookiecookie 只保存明文 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 cookieJavaScript 不读取该 cookie。
- 用户可退出登录,退出时删除对应 session、清除 HTTP-only session cookie并清理前端 legacy token storage。
- 对外用户字段只包含必要信息:
- 当前用户:`id``email``displayName``emailVerified`
- 编辑署名:`id``displayName`

View File

@@ -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

View File

@@ -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<string, string | string[] | undefined>;
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<AuthUser | null> {
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<string, unknown>, requestLocale(request));
const payload = request.body as Record<string, unknown>;
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();
});

View File

@@ -112,17 +112,14 @@ const navItems = computed<NavItem[]>(() => {
});
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);
}
}
}

View File

@@ -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) {

View File

@@ -1136,6 +1136,7 @@ async function getErrorMessage(response: Response): Promise<string> {
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(apiUrl(path), {
credentials: 'include',
headers: requestHeaders(),
signal
});
@@ -1149,6 +1150,7 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method,
headers: {
'Content-Type': 'application/json',
@@ -1166,6 +1168,7 @@ async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body:
async function sendFormData<T>(path: string, body: FormData): Promise<T> {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method: 'POST',
headers: requestHeaders(),
body
@@ -1180,6 +1183,7 @@ async function sendFormData<T>(path: string, body: FormData): Promise<T> {
async function postEmpty(path: string): Promise<void> {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method: 'POST',
headers: requestHeaders()
});
@@ -1191,6 +1195,7 @@ async function postEmpty(path: string): Promise<void> {
async function deleteJson(path: string): Promise<void> {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method: 'DELETE',
headers: requestHeaders()
});
@@ -1202,6 +1207,7 @@ async function deleteJson(path: string): Promise<void> {
async function deleteAndGetJson<T>(path: string): Promise<T> {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method: 'DELETE',
headers: requestHeaders()
});