feat(auth): Session model + remember-me

Replace the single `user.refreshToken` column with a proper Session
table so users can have multiple concurrent sessions (phone, laptop,
etc.), each with their own refresh token, expiry, label, and
remember-me flag.

- Add Session model (id, userId, tokenHash, label, userAgent,
  ipAddress, rememberMe, lastUsedAt, expiresAt).
- Drop `User.refreshToken` and `User.refreshTokenExpiresAt`.
- authService: new createSession/validateSession/rotateSession/
  revokeSession/listUserSessions helpers; remove refresh-token-on-user
  functions.
- sessionCookies helper now issues a session_id cookie alongside
  access_token and refresh_token; rotateSessionCookies keeps the same
  session id on refresh.
- Login form adds a "Keep me signed in for 30 days" checkbox;
  TTL is 7d by default, 30d with remember-me.
- User-Agent parsed into a friendly label ("Chrome on Windows") for
  the upcoming sessions page.
- hooks.server.ts, refresh endpoint, logout, register, oauth callback,
  and onboarding all switched to the new session API.
This commit is contained in:
2026-04-16 03:41:52 +03:00
parent 3fa30f72a3
commit b9f3a2ca0b
17 changed files with 489 additions and 187 deletions
+46 -47
View File
@@ -9,7 +9,8 @@ import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
import {
clearSessionCookies,
setRotatedCookies
rotateSessionCookies,
COOKIE_NAMES
} from '$lib/server/utils/sessionCookies.js';
// Initialize backup scheduler on server startup
@@ -21,17 +22,14 @@ function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some((path) => pathname === path || pathname.startsWith(path));
}
const ACCESS_TOKEN_COOKIE = 'access_token';
const REFRESH_TOKEN_COOKIE = 'refresh_token';
export const handle: Handle = async ({ event, resolve }) => {
// Initialize locals
event.locals.user = null;
event.locals.session = null;
event.locals.apiTokenScope = null;
const accessToken = event.cookies.get(ACCESS_TOKEN_COOKIE);
const refreshToken = event.cookies.get(REFRESH_TOKEN_COOKIE);
const accessToken = event.cookies.get(COOKIE_NAMES.ACCESS_TOKEN);
const refreshToken = event.cookies.get(COOKIE_NAMES.REFRESH_TOKEN);
const sessionId = event.cookies.get(COOKIE_NAMES.SESSION_ID);
if (accessToken) {
try {
@@ -44,7 +42,7 @@ export const handle: Handle = async ({ event, resolve }) => {
role: user.role as 'admin' | 'user'
};
event.locals.session = {
id: payload.userId,
id: sessionId ?? payload.userId,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
} catch {
@@ -52,40 +50,36 @@ export const handle: Handle = async ({ event, resolve }) => {
}
}
// If no valid session but refresh token exists, attempt rotation
if (!event.locals.user && refreshToken) {
// If no valid session but refresh + session id exist, try to rotate.
if (!event.locals.user && refreshToken && sessionId) {
try {
// We need to find the user by refresh token.
// The refresh token is stored hashed per-user, so we need
// a userId from somewhere. We store it in a separate cookie.
const userIdFromCookie = event.cookies.get('refresh_user_id');
if (userIdFromCookie) {
const isValid = await authService.validateRefreshToken(userIdFromCookie, refreshToken);
if (isValid) {
const user = await userService.findById(userIdFromCookie);
const tokens = await authService.rotateTokens(user.id, user.email, user.role);
const session = await authService.validateSession(sessionId, refreshToken);
if (session) {
const user = await userService.findById(session.userId);
await rotateSessionCookies(
event.cookies,
session.id,
{ id: user.id, email: user.email, role: user.role },
session.rememberMe
);
setRotatedCookies(event.cookies, tokens);
event.locals.user = {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role as 'admin' | 'user'
};
event.locals.session = {
id: user.id,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
}
event.locals.user = {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role as 'admin' | 'user'
};
event.locals.session = {
id: session.id,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
}
} catch {
// Refresh failed — clear stale cookies
clearSessionCookies(event.cookies);
}
}
// If still no valid session, try API token from Authorization header
// Bearer API tokens (no session cookie).
if (!event.locals.user) {
const bearerToken = extractBearerToken(event);
if (bearerToken) {
@@ -117,27 +111,33 @@ export const handle: Handle = async ({ event, resolve }) => {
if (event.locals.apiTokenScope) {
const method = event.request.method;
const scope = event.locals.apiTokenScope;
const isWriteMethod = method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE';
const isWriteMethod =
method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE';
const isAdminPath = pathname.startsWith('/api/admin') || pathname.startsWith('/admin');
if (scope === 'read' && isWriteMethod) {
return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope "read" does not allow write operations' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
return new Response(
JSON.stringify({
success: false,
data: null,
error: 'API token scope "read" does not allow write operations'
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
if (scope !== 'admin' && isAdminPath) {
return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope does not allow admin operations' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
return new Response(
JSON.stringify({
success: false,
data: null,
error: 'API token scope does not allow admin operations'
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
}
// Route protection
if (!event.locals.user && !isPublicPath(pathname)) {
// Check if this is a guest-accessible board route
const boardMatch = pathname.match(/^\/boards\/([^/]+)/);
if (boardMatch) {
const boardId = boardMatch[1];
@@ -147,7 +147,6 @@ export const handle: Handle = async ({ event, resolve }) => {
}
}
// Root path — allow through so +page.server.ts can handle redirect logic
if (pathname === '/') {
return resolve(event);
}