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:
+46
-47
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user