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,并带过期时间和使用状态。
|
- 重置 token 只保存 hash,并带过期时间和使用状态。
|
||||||
- 密码重置成功后不自动登录,并删除该用户已有 session。
|
- 密码重置成功后不自动登录,并删除该用户已有 session。
|
||||||
- 登录页提供 Remember me:
|
- 登录页提供 Remember me:
|
||||||
- 未勾选时前端将登录 token 保存在 `sessionStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 1 天。
|
- 未勾选时 session 有效期为 1 天。
|
||||||
- 勾选时前端将登录 token 保存在 `localStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 30 天。
|
- 勾选时 session 有效期为 30 天。
|
||||||
- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。
|
- SSR 迁移期认证使用 hybrid session model:
|
||||||
- 用户可退出登录,退出时删除对应 session。
|
- 登录成功后后端设置 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`、`email`、`displayName`、`emailVerified`
|
||||||
- 编辑署名:`id`、`displayName`
|
- 编辑署名:`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] 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] 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.
|
- [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.
|
- [ ] 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
|
## Phase 3: Authentication And Session Model
|
||||||
|
|
||||||
- [ ] Decide and document the SSR-compatible auth model in `DESIGN.md` before implementation.
|
- [x] 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.
|
- [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.
|
||||||
- [ ] Update backend login/logout/session endpoints to support the chosen cookie/session model without exposing session token hashes or internal session metadata.
|
- [x] 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.
|
- [x] 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] 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`.
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] 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
|
## Phase 4: Nuxt SSR Enablement
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ const app = Fastify({
|
|||||||
logger: true,
|
logger: true,
|
||||||
trustProxy: process.env.TRUST_PROXY === 'true'
|
trustProxy: process.env.TRUST_PROXY === 'true'
|
||||||
});
|
});
|
||||||
|
const sessionCookieName = 'pokopia_session';
|
||||||
|
const rememberedSessionDays = 30;
|
||||||
|
const sessionOnlySessionDays = 1;
|
||||||
|
|
||||||
function configuredCorsOrigin(): true | string | string[] {
|
function configuredCorsOrigin(): true | string | string[] {
|
||||||
const rawOrigin = process.env.FRONTEND_ORIGIN?.trim();
|
const rawOrigin = process.env.FRONTEND_ORIGIN?.trim();
|
||||||
@@ -183,7 +186,8 @@ function configuredCorsOrigin(): true | string | string[] {
|
|||||||
|
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'],
|
allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'],
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
origin: configuredCorsOrigin()
|
origin: configuredCorsOrigin()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -248,6 +252,54 @@ function getBearerToken(authorization: string | undefined): string | null {
|
|||||||
return scheme === 'Bearer' && token ? token : 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 {
|
function requestLocale(request: FastifyRequest): string {
|
||||||
const query = request.query as Record<string, string | string[] | undefined>;
|
const query = request.query as Record<string, string | string[] | undefined>;
|
||||||
const queryLocale = Array.isArray(query.locale) ? query.locale[0] : query.locale;
|
const queryLocale = Array.isArray(query.locale) ? query.locale[0] : query.locale;
|
||||||
@@ -868,7 +920,7 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
const locale = requestLocale(request);
|
const locale = requestLocale(request);
|
||||||
|
|
||||||
@@ -950,7 +1002,7 @@ async function requireAnyPermissionWithRateLimits(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
|
async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -983,7 +1035,10 @@ app.post('/api/auth/login', async (request, reply) => {
|
|||||||
return;
|
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) => {
|
app.post('/api/auth/request-password-reset', async (request, reply) => {
|
||||||
@@ -1007,7 +1062,7 @@ app.get('/api/auth/me', async (request, reply) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -1022,7 +1077,7 @@ app.patch('/api/auth/me', async (request, reply) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -1042,7 +1097,7 @@ app.patch('/api/auth/me/password', async (request, reply) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|
||||||
if (!user || !token) {
|
if (!user || !token) {
|
||||||
@@ -1062,7 +1117,7 @@ app.get('/api/auth/referral', async (request, reply) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -1099,11 +1154,12 @@ app.post('/api/notifications/:id/read', async (request, reply) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/auth/logout', async (request, reply) => {
|
app.post('/api/auth/logout', async (request, reply) => {
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
if (token) {
|
if (token) {
|
||||||
await logoutSession(token);
|
await logoutSession(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearSessionCookie(reply);
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -112,18 +112,15 @@ const navItems = computed<NavItem[]>(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function loadCurrentUser() {
|
async function loadCurrentUser() {
|
||||||
if (!getAuthToken()) {
|
|
||||||
currentUser.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.me();
|
const response = await api.me();
|
||||||
currentUser.value = response.user;
|
currentUser.value = response.user;
|
||||||
} catch {
|
} catch {
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
|
if (getAuthToken()) {
|
||||||
setAuthToken(null);
|
setAuthToken(null);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api, getAuthToken, setAuthToken } from '../src/services/api';
|
import { api, setAuthToken } from '../src/services/api';
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
const requiredPermissions = to.matched
|
const requiredPermissions = to.matched
|
||||||
@@ -16,10 +16,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!getAuthToken()) {
|
|
||||||
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.me();
|
const response = await api.me();
|
||||||
if (requiresVerified && !response.user.emailVerified) {
|
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> {
|
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||||
const response = await fetch(apiUrl(path), {
|
const response = await fetch(apiUrl(path), {
|
||||||
|
credentials: 'include',
|
||||||
headers: requestHeaders(),
|
headers: requestHeaders(),
|
||||||
signal
|
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> {
|
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
|
||||||
const response = await fetch(apiUrl(path), {
|
const response = await fetch(apiUrl(path), {
|
||||||
|
credentials: 'include',
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'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> {
|
async function sendFormData<T>(path: string, body: FormData): Promise<T> {
|
||||||
const response = await fetch(apiUrl(path), {
|
const response = await fetch(apiUrl(path), {
|
||||||
|
credentials: 'include',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: requestHeaders(),
|
headers: requestHeaders(),
|
||||||
body
|
body
|
||||||
@@ -1180,6 +1183,7 @@ async function sendFormData<T>(path: string, body: FormData): Promise<T> {
|
|||||||
|
|
||||||
async function postEmpty(path: string): Promise<void> {
|
async function postEmpty(path: string): Promise<void> {
|
||||||
const response = await fetch(apiUrl(path), {
|
const response = await fetch(apiUrl(path), {
|
||||||
|
credentials: 'include',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: requestHeaders()
|
headers: requestHeaders()
|
||||||
});
|
});
|
||||||
@@ -1191,6 +1195,7 @@ async function postEmpty(path: string): Promise<void> {
|
|||||||
|
|
||||||
async function deleteJson(path: string): Promise<void> {
|
async function deleteJson(path: string): Promise<void> {
|
||||||
const response = await fetch(apiUrl(path), {
|
const response = await fetch(apiUrl(path), {
|
||||||
|
credentials: 'include',
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: requestHeaders()
|
headers: requestHeaders()
|
||||||
});
|
});
|
||||||
@@ -1202,6 +1207,7 @@ async function deleteJson(path: string): Promise<void> {
|
|||||||
|
|
||||||
async function deleteAndGetJson<T>(path: string): Promise<T> {
|
async function deleteAndGetJson<T>(path: string): Promise<T> {
|
||||||
const response = await fetch(apiUrl(path), {
|
const response = await fetch(apiUrl(path), {
|
||||||
|
credentials: 'include',
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: requestHeaders()
|
headers: requestHeaders()
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user