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:
12
DESIGN.md
12
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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user