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:
@@ -0,0 +1,62 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"label" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"ipAddress" TEXT,
|
||||
"rememberMe" BOOLEAN NOT NULL DEFAULT false,
|
||||
"lastUsedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
|
||||
|
||||
-- RedefineTables: drop refreshToken + refreshTokenExpiresAt from User.
|
||||
-- All existing user sessions will be invalidated; users must re-login once after upgrade.
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"avatarUrl" TEXT,
|
||||
"authProvider" TEXT NOT NULL DEFAULT 'local',
|
||||
"role" TEXT NOT NULL DEFAULT 'user',
|
||||
"onboardingComplete" BOOLEAN NOT NULL DEFAULT false,
|
||||
"trackRecentApps" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"themeMode" TEXT,
|
||||
"primaryHue" INTEGER,
|
||||
"primarySaturation" INTEGER,
|
||||
"backgroundType" TEXT,
|
||||
"locale" TEXT
|
||||
);
|
||||
|
||||
INSERT INTO "new_User" (
|
||||
"id", "email", "password", "displayName", "avatarUrl", "authProvider", "role",
|
||||
"onboardingComplete", "trackRecentApps", "createdAt", "updatedAt",
|
||||
"themeMode", "primaryHue", "primarySaturation", "backgroundType", "locale"
|
||||
)
|
||||
SELECT
|
||||
"id", "email", "password", "displayName", "avatarUrl", "authProvider", "role",
|
||||
"onboardingComplete", "trackRecentApps", "createdAt", "updatedAt",
|
||||
"themeMode", "primaryHue", "primarySaturation", "backgroundType", "locale"
|
||||
FROM "User";
|
||||
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
CREATE INDEX "User_email_idx" ON "User"("email");
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
+19
-2
@@ -15,8 +15,6 @@ model User {
|
||||
avatarUrl String?
|
||||
authProvider String @default("local") // local | oauth
|
||||
role String @default("user") // admin | user
|
||||
refreshToken String?
|
||||
refreshTokenExpiresAt DateTime?
|
||||
onboardingComplete Boolean @default(false)
|
||||
trackRecentApps Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
@@ -29,6 +27,7 @@ model User {
|
||||
locale String?
|
||||
|
||||
groups UserGroup[]
|
||||
sessions Session[]
|
||||
createdApps App[]
|
||||
boards Board[]
|
||||
favorites UserFavorite[]
|
||||
@@ -42,6 +41,24 @@ model User {
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
tokenHash String // bcrypt hash of the refresh token
|
||||
label String? // user-friendly, e.g. "Chrome on Windows"
|
||||
userAgent String?
|
||||
ipAddress String?
|
||||
rememberMe Boolean @default(false)
|
||||
lastUsedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model Group {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
|
||||
+37
-38
@@ -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,20 +50,18 @@ 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);
|
||||
|
||||
setRotatedCookies(event.cookies, tokens);
|
||||
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
|
||||
);
|
||||
|
||||
event.locals.user = {
|
||||
id: user.id,
|
||||
@@ -74,18 +70,16 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
role: user.role as 'admin' | 'user'
|
||||
};
|
||||
event.locals.session = {
|
||||
id: user.id,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
// Mock prisma before importing authService
|
||||
vi.mock('../../prisma.js', () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
session: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findUnique: vi.fn()
|
||||
deleteMany: vi.fn(),
|
||||
findMany: vi.fn()
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -19,8 +22,9 @@ import {
|
||||
signAccessToken,
|
||||
verifyAccessToken,
|
||||
generateRefreshToken,
|
||||
getRefreshTokenExpiry,
|
||||
rotateTokens
|
||||
createSession,
|
||||
rotateSession,
|
||||
validateSession
|
||||
} from '../authService.js';
|
||||
import { prisma } from '../../prisma.js';
|
||||
|
||||
@@ -84,31 +88,88 @@ describe('authService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRefreshTokenExpiry', () => {
|
||||
it('returns a future date', () => {
|
||||
const expiry = getRefreshTokenExpiry();
|
||||
expect(expiry.getTime()).toBeGreaterThan(Date.now());
|
||||
describe('createSession', () => {
|
||||
it('creates a session row and returns the raw refresh token', async () => {
|
||||
vi.mocked(prisma.session.create).mockResolvedValue({
|
||||
id: 'ses-1',
|
||||
userId: 'usr-1',
|
||||
tokenHash: 'hash',
|
||||
label: 'Chrome on Windows',
|
||||
userAgent: 'ua',
|
||||
ipAddress: '127.0.0.1',
|
||||
rememberMe: false,
|
||||
lastUsedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
createdAt: new Date()
|
||||
} as never);
|
||||
|
||||
const result = await createSession('usr-1', { userAgent: 'ua', ipAddress: '127.0.0.1' });
|
||||
|
||||
expect(result.sessionId).toBe('ses-1');
|
||||
expect(result.refreshToken.length).toBe(96);
|
||||
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(prisma.session.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('defaults to 7 days from now', () => {
|
||||
const expiry = getRefreshTokenExpiry();
|
||||
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const diff = expiry.getTime() - Date.now();
|
||||
// Allow 10 seconds tolerance
|
||||
expect(diff).toBeGreaterThan(sevenDaysMs - 10000);
|
||||
expect(diff).toBeLessThan(sevenDaysMs + 10000);
|
||||
it('extends expiry for remember-me sessions', async () => {
|
||||
vi.mocked(prisma.session.create).mockImplementation(
|
||||
(({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({
|
||||
id: 'ses-2',
|
||||
...data,
|
||||
lastUsedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
})) as never
|
||||
);
|
||||
|
||||
const result = await createSession('usr-1', { rememberMe: true });
|
||||
|
||||
const diffDays = (result.expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000);
|
||||
expect(diffDays).toBeGreaterThan(29);
|
||||
expect(diffDays).toBeLessThan(31);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotateTokens', () => {
|
||||
it('generates new token pair and saves refresh token', async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
describe('validateSession', () => {
|
||||
it('returns null for missing session', async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue(null);
|
||||
const result = await validateSession('ses-x', 'token');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
const result = await rotateTokens('usr-1', 'test@test.com', 'user');
|
||||
it('returns null for expired session', async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
id: 'ses-1',
|
||||
userId: 'usr-1',
|
||||
tokenHash: 'hash',
|
||||
rememberMe: false,
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
lastUsedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
label: null,
|
||||
userAgent: null,
|
||||
ipAddress: null
|
||||
} as never);
|
||||
const result = await validateSession('ses-1', 'token');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.accessToken).toBeTruthy();
|
||||
expect(result.refreshToken).toBeTruthy();
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
describe('rotateSession', () => {
|
||||
it('updates token hash and keeps the same session id', async () => {
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
id: 'ses-1',
|
||||
userId: 'usr-1',
|
||||
rememberMe: false,
|
||||
expiresAt: new Date(Date.now() + 1000)
|
||||
} as never);
|
||||
vi.mocked(prisma.session.update).mockResolvedValue({} as never);
|
||||
|
||||
const result = await rotateSession('ses-1');
|
||||
|
||||
expect(result.sessionId).toBe('ses-1');
|
||||
expect(result.refreshToken.length).toBe(96);
|
||||
expect(prisma.session.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../prisma.js';
|
||||
import { DEFAULTS } from '$lib/utils/constants.js';
|
||||
import type { JwtPayload, TokenPair } from '$lib/types/auth.js';
|
||||
import type { JwtPayload } from '$lib/types/auth.js';
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
|
||||
@@ -18,15 +18,6 @@ function getJwtExpiry(): string {
|
||||
return process.env.JWT_EXPIRY || DEFAULTS.JWT_EXPIRY;
|
||||
}
|
||||
|
||||
function getRefreshTokenExpiryDays(): number {
|
||||
const envValue = process.env.REFRESH_TOKEN_EXPIRY;
|
||||
if (envValue) {
|
||||
const days = parseInt(envValue.replace('d', ''), 10);
|
||||
if (!isNaN(days)) return days;
|
||||
}
|
||||
return DEFAULTS.REFRESH_TOKEN_EXPIRY_DAYS;
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
@@ -60,59 +51,124 @@ export function generateRefreshToken(): string {
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function getRefreshTokenExpiry(): Date {
|
||||
const days = getRefreshTokenExpiryDays();
|
||||
export interface SessionMetadata {
|
||||
readonly userAgent?: string;
|
||||
readonly ipAddress?: string;
|
||||
readonly label?: string;
|
||||
readonly rememberMe?: boolean;
|
||||
}
|
||||
|
||||
export interface IssuedSession {
|
||||
readonly sessionId: string;
|
||||
readonly refreshToken: string;
|
||||
readonly expiresAt: Date;
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_TTL_DAYS = 7;
|
||||
const REMEMBER_ME_SESSION_TTL_DAYS = 30;
|
||||
|
||||
function sessionExpiry(rememberMe: boolean): Date {
|
||||
const days = rememberMe ? REMEMBER_ME_SESSION_TTL_DAYS : DEFAULT_SESSION_TTL_DAYS;
|
||||
const expiry = new Date();
|
||||
expiry.setDate(expiry.getDate() + days);
|
||||
return expiry;
|
||||
}
|
||||
|
||||
export async function saveRefreshToken(userId: string, refreshToken: string): Promise<void> {
|
||||
const hashedToken = await bcrypt.hash(refreshToken, SALT_ROUNDS);
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
refreshToken: hashedToken,
|
||||
refreshTokenExpiresAt: getRefreshTokenExpiry()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateRefreshToken(userId: string, refreshToken: string): Promise<boolean> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { refreshToken: true, refreshTokenExpiresAt: true }
|
||||
});
|
||||
|
||||
if (!user?.refreshToken || !user.refreshTokenExpiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (new Date() > user.refreshTokenExpiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return bcrypt.compare(refreshToken, user.refreshToken);
|
||||
}
|
||||
|
||||
export async function revokeRefreshToken(userId: string): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
refreshToken: null,
|
||||
refreshTokenExpiresAt: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function rotateTokens(
|
||||
/**
|
||||
* Create a new session row and return the session id + raw refresh token.
|
||||
* The raw token is returned once — only the hash is stored.
|
||||
*/
|
||||
export async function createSession(
|
||||
userId: string,
|
||||
email: string,
|
||||
role: string
|
||||
): Promise<TokenPair> {
|
||||
const accessToken = signAccessToken({ userId, email, role });
|
||||
meta: SessionMetadata = {}
|
||||
): Promise<IssuedSession> {
|
||||
const refreshToken = generateRefreshToken();
|
||||
await saveRefreshToken(userId, refreshToken);
|
||||
const tokenHash = await bcrypt.hash(refreshToken, SALT_ROUNDS);
|
||||
const expiresAt = sessionExpiry(meta.rememberMe ?? false);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash,
|
||||
label: meta.label ?? null,
|
||||
userAgent: meta.userAgent ?? null,
|
||||
ipAddress: meta.ipAddress ?? null,
|
||||
rememberMe: meta.rememberMe ?? false,
|
||||
expiresAt
|
||||
}
|
||||
});
|
||||
|
||||
return { sessionId: session.id, refreshToken, expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session id + refresh token. Returns the session row if valid, else null.
|
||||
* Does NOT mutate the session.
|
||||
*/
|
||||
export async function validateSession(sessionId: string, refreshToken: string) {
|
||||
const session = await prisma.session.findUnique({ where: { id: sessionId } });
|
||||
if (!session) return null;
|
||||
if (new Date() > session.expiresAt) return null;
|
||||
|
||||
const matches = await bcrypt.compare(refreshToken, session.tokenHash);
|
||||
if (!matches) return null;
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a session's refresh token. Keeps the same session id (so the sessions
|
||||
* page shows a stable row). Refreshes expiry based on the session's rememberMe.
|
||||
*/
|
||||
export async function rotateSession(sessionId: string): Promise<IssuedSession> {
|
||||
const existing = await prisma.session.findUnique({ where: { id: sessionId } });
|
||||
if (!existing) throw new Error('Session not found');
|
||||
|
||||
const refreshToken = generateRefreshToken();
|
||||
const tokenHash = await bcrypt.hash(refreshToken, SALT_ROUNDS);
|
||||
const expiresAt = sessionExpiry(existing.rememberMe);
|
||||
|
||||
await prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { tokenHash, expiresAt, lastUsedAt: new Date() }
|
||||
});
|
||||
|
||||
return { sessionId, refreshToken, expiresAt };
|
||||
}
|
||||
|
||||
export async function revokeSession(sessionId: string): Promise<void> {
|
||||
await prisma.session.deleteMany({ where: { id: sessionId } });
|
||||
}
|
||||
|
||||
export async function revokeAllUserSessions(userId: string, exceptSessionId?: string): Promise<void> {
|
||||
await prisma.session.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
...(exceptSessionId ? { NOT: { id: exceptSessionId } } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUserSessions(userId: string) {
|
||||
return prisma.session.findMany({
|
||||
where: { userId },
|
||||
orderBy: { lastUsedAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
userAgent: true,
|
||||
ipAddress: true,
|
||||
rememberMe: true,
|
||||
lastUsedAt: true,
|
||||
expiresAt: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteExpiredSessions(): Promise<number> {
|
||||
const result = await prisma.session.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } }
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* JWT utilities — thin re-exports from authService.
|
||||
* authService already handles sign, verify, and refresh token generation.
|
||||
*/
|
||||
export {
|
||||
signAccessToken,
|
||||
verifyAccessToken,
|
||||
generateRefreshToken,
|
||||
getRefreshTokenExpiry
|
||||
generateRefreshToken
|
||||
} from '../services/authService.js';
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import type { Cookies, RequestEvent } from '@sveltejs/kit';
|
||||
import * as authService from '$lib/server/services/authService.js';
|
||||
import { parseUserAgentLabel } from './userAgent.js';
|
||||
|
||||
export const ACCESS_TOKEN_TTL_SEC = 900; // 15 minutes
|
||||
export const REFRESH_TOKEN_TTL_SEC = 604800; // 7 days
|
||||
export const DEFAULT_REFRESH_TTL_SEC = 7 * 24 * 60 * 60; // 7 days
|
||||
export const REMEMBER_ME_REFRESH_TTL_SEC = 30 * 24 * 60 * 60; // 30 days
|
||||
|
||||
const ACCESS_TOKEN_COOKIE = 'access_token';
|
||||
const REFRESH_TOKEN_COOKIE = 'refresh_token';
|
||||
const SESSION_ID_COOKIE = 'session_id';
|
||||
|
||||
function isHttpsOrigin(): boolean {
|
||||
const origin = process.env.ORIGIN;
|
||||
if (origin) return origin.startsWith('https://');
|
||||
// Fall back to NODE_ENV only when ORIGIN is unset.
|
||||
return process.env.NODE_ENV === 'production';
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared cookie attributes (httpOnly, secure, sameSite=lax, path=/).
|
||||
* `secure` is derived from ORIGIN (https://...) rather than NODE_ENV so
|
||||
* plain-HTTP production deployments don't silently drop cookies.
|
||||
* Shared cookie attributes. `secure` is derived from ORIGIN (https://...)
|
||||
* rather than NODE_ENV so plain-HTTP production deployments don't silently
|
||||
* drop cookies.
|
||||
*/
|
||||
export function cookieBase() {
|
||||
return {
|
||||
@@ -31,40 +36,99 @@ interface SessionUser {
|
||||
readonly role: string;
|
||||
}
|
||||
|
||||
interface IssueOptions {
|
||||
readonly rememberMe?: boolean;
|
||||
/** When set, metadata (user agent, IP, label) is pulled from this event. */
|
||||
readonly event?: Pick<RequestEvent, 'request' | 'getClientAddress'>;
|
||||
}
|
||||
|
||||
function refreshTtl(rememberMe: boolean): number {
|
||||
return rememberMe ? REMEMBER_ME_REFRESH_TTL_SEC : DEFAULT_REFRESH_TTL_SEC;
|
||||
}
|
||||
|
||||
function readMetadata(event: IssueOptions['event']) {
|
||||
if (!event) return { userAgent: undefined, ipAddress: undefined, label: undefined };
|
||||
const userAgent = event.request.headers.get('user-agent') ?? undefined;
|
||||
let ipAddress: string | undefined;
|
||||
try {
|
||||
ipAddress = event.getClientAddress();
|
||||
} catch {
|
||||
ipAddress = undefined;
|
||||
}
|
||||
return {
|
||||
userAgent,
|
||||
ipAddress,
|
||||
label: parseUserAgentLabel(userAgent)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue access + refresh cookies for a freshly-authenticated user.
|
||||
* Persists a new refresh token in the DB.
|
||||
* Create a new session, persist it, and set access + refresh + session_id cookies.
|
||||
*/
|
||||
export async function issueSessionCookies(cookies: Cookies, user: SessionUser): Promise<void> {
|
||||
export async function issueSessionCookies(
|
||||
cookies: Cookies,
|
||||
user: SessionUser,
|
||||
options: IssueOptions = {}
|
||||
): Promise<void> {
|
||||
const rememberMe = options.rememberMe ?? false;
|
||||
const meta = readMetadata(options.event);
|
||||
|
||||
const session = await authService.createSession(user.id, {
|
||||
rememberMe,
|
||||
userAgent: meta.userAgent,
|
||||
ipAddress: meta.ipAddress,
|
||||
label: meta.label
|
||||
});
|
||||
|
||||
const accessToken = authService.signAccessToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
});
|
||||
const refreshToken = authService.generateRefreshToken();
|
||||
await authService.saveRefreshToken(user.id, refreshToken);
|
||||
|
||||
const base = cookieBase();
|
||||
cookies.set('access_token', accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC });
|
||||
cookies.set('refresh_token', refreshToken, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC });
|
||||
cookies.set('refresh_user_id', user.id, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC });
|
||||
const ttl = refreshTtl(rememberMe);
|
||||
|
||||
cookies.set(ACCESS_TOKEN_COOKIE, accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC });
|
||||
cookies.set(REFRESH_TOKEN_COOKIE, session.refreshToken, { ...base, maxAge: ttl });
|
||||
cookies.set(SESSION_ID_COOKIE, session.sessionId, { ...base, maxAge: ttl });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set access + refresh cookies from a pre-generated token pair (used by refresh rotation).
|
||||
* Does not touch the DB.
|
||||
* Rotate an existing session's tokens and set new cookies. Used on refresh.
|
||||
*/
|
||||
export function setRotatedCookies(
|
||||
export async function rotateSessionCookies(
|
||||
cookies: Cookies,
|
||||
tokens: { readonly accessToken: string; readonly refreshToken: string }
|
||||
): void {
|
||||
sessionId: string,
|
||||
user: SessionUser,
|
||||
rememberMe: boolean
|
||||
): Promise<void> {
|
||||
const rotated = await authService.rotateSession(sessionId);
|
||||
const accessToken = authService.signAccessToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
});
|
||||
|
||||
const base = cookieBase();
|
||||
cookies.set('access_token', tokens.accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC });
|
||||
cookies.set('refresh_token', tokens.refreshToken, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC });
|
||||
const ttl = refreshTtl(rememberMe);
|
||||
|
||||
cookies.set(ACCESS_TOKEN_COOKIE, accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC });
|
||||
cookies.set(REFRESH_TOKEN_COOKIE, rotated.refreshToken, { ...base, maxAge: ttl });
|
||||
cookies.set(SESSION_ID_COOKIE, rotated.sessionId, { ...base, maxAge: ttl });
|
||||
}
|
||||
|
||||
export function clearSessionCookies(cookies: Cookies): void {
|
||||
cookies.delete('access_token', { path: '/' });
|
||||
cookies.delete('refresh_token', { path: '/' });
|
||||
cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' });
|
||||
cookies.delete(REFRESH_TOKEN_COOKIE, { path: '/' });
|
||||
cookies.delete(SESSION_ID_COOKIE, { path: '/' });
|
||||
// Clean up legacy cookie name (from pre-Session refactor) so upgrades
|
||||
// don't leave a stale cookie in the browser.
|
||||
cookies.delete('refresh_user_id', { path: '/' });
|
||||
}
|
||||
|
||||
export const COOKIE_NAMES = {
|
||||
ACCESS_TOKEN: ACCESS_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN: REFRESH_TOKEN_COOKIE,
|
||||
SESSION_ID: SESSION_ID_COOKIE
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Parse a User-Agent header into a short, human-readable label like
|
||||
* "Chrome on Windows" or "Safari on iOS". Intentionally heuristic — not
|
||||
* a replacement for a full UA parser.
|
||||
*/
|
||||
export function parseUserAgentLabel(userAgent: string | null | undefined): string {
|
||||
if (!userAgent) return 'Unknown device';
|
||||
|
||||
const ua = userAgent;
|
||||
|
||||
let browser = 'Browser';
|
||||
if (/Edg\//.test(ua)) browser = 'Edge';
|
||||
else if (/OPR\//.test(ua) || /Opera/.test(ua)) browser = 'Opera';
|
||||
else if (/Firefox\//.test(ua)) browser = 'Firefox';
|
||||
else if (/Chrome\//.test(ua)) browser = 'Chrome';
|
||||
else if (/Safari\//.test(ua)) browser = 'Safari';
|
||||
else if (/curl\//i.test(ua)) browser = 'curl';
|
||||
else if (/PostmanRuntime/.test(ua)) browser = 'Postman';
|
||||
|
||||
let os = 'Unknown OS';
|
||||
if (/Windows NT/.test(ua)) os = 'Windows';
|
||||
else if (/Mac OS X|Macintosh/.test(ua)) os = 'macOS';
|
||||
else if (/Android/.test(ua)) os = 'Android';
|
||||
else if (/iPhone|iPad|iPod/.test(ua)) os = 'iOS';
|
||||
else if (/Linux/.test(ua)) os = 'Linux';
|
||||
|
||||
return `${browser} on ${os}`;
|
||||
}
|
||||
@@ -4,14 +4,10 @@ export interface JwtPayload {
|
||||
readonly role: string;
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
readonly accessToken: string;
|
||||
readonly refreshToken: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly rememberMe?: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(1, 'Password is required')
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
rememberMe: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
export const registerSchema = z.object({
|
||||
|
||||
@@ -32,7 +32,8 @@ export const GET: RequestHandler = async () => {
|
||||
* POST /api/onboarding — Complete an onboarding step.
|
||||
* No auth required (onboarding runs before any user exists).
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies } = event;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
@@ -197,7 +198,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
return json(error('Onboarding complete but no admin user found'), { status: 500 });
|
||||
}
|
||||
|
||||
await issueSessionCookies(cookies, adminUser);
|
||||
await issueSessionCookies(cookies, adminUser, { event });
|
||||
|
||||
return json(success({ complete: true }));
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import * as authService from '$lib/server/services/authService.js';
|
||||
import {
|
||||
COOKIE_NAMES,
|
||||
clearSessionCookies
|
||||
} from '$lib/server/utils/sessionCookies.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, locals }) => {
|
||||
// Revoke refresh token in database
|
||||
if (locals.user) {
|
||||
export const POST: RequestHandler = async ({ cookies }) => {
|
||||
// Revoke the current session if we have its id
|
||||
const sessionId = cookies.get(COOKIE_NAMES.SESSION_ID);
|
||||
if (sessionId) {
|
||||
try {
|
||||
await authService.revokeRefreshToken(locals.user.id);
|
||||
await authService.revokeSession(sessionId);
|
||||
} catch {
|
||||
// Best-effort revocation — continue with cookie cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all auth cookies
|
||||
cookies.delete('access_token', { path: '/' });
|
||||
cookies.delete('refresh_token', { path: '/' });
|
||||
cookies.delete('refresh_user_id', { path: '/' });
|
||||
clearSessionCookies(cookies);
|
||||
|
||||
throw redirect(302, '/login');
|
||||
};
|
||||
|
||||
@@ -4,7 +4,8 @@ import * as oauthService from '$lib/server/services/oauthService.js';
|
||||
import * as userService from '$lib/server/services/userService.js';
|
||||
import { issueSessionCookies } from '$lib/server/utils/sessionCookies.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const { url, cookies } = event;
|
||||
try {
|
||||
// Check for error response from the provider
|
||||
const oauthError = url.searchParams.get('error');
|
||||
@@ -51,7 +52,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
groups: userInfo.groups ? [...userInfo.groups] : undefined
|
||||
});
|
||||
|
||||
await issueSessionCookies(cookies, user);
|
||||
await issueSessionCookies(cookies, user, { event });
|
||||
|
||||
throw redirect(302, '/');
|
||||
} catch (err) {
|
||||
|
||||
@@ -5,29 +5,33 @@ import * as userService from '$lib/server/services/userService.js';
|
||||
import { error as apiError } from '$lib/server/utils/response.js';
|
||||
import {
|
||||
ACCESS_TOKEN_TTL_SEC,
|
||||
COOKIE_NAMES,
|
||||
clearSessionCookies,
|
||||
setRotatedCookies
|
||||
rotateSessionCookies
|
||||
} from '$lib/server/utils/sessionCookies.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies }) => {
|
||||
const refreshToken = cookies.get('refresh_token');
|
||||
const userId = cookies.get('refresh_user_id');
|
||||
const refreshToken = cookies.get(COOKIE_NAMES.REFRESH_TOKEN);
|
||||
const sessionId = cookies.get(COOKIE_NAMES.SESSION_ID);
|
||||
|
||||
if (!refreshToken || !userId) {
|
||||
if (!refreshToken || !sessionId) {
|
||||
return json(apiError('No refresh token provided'), { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = await authService.validateRefreshToken(userId, refreshToken);
|
||||
if (!isValid) {
|
||||
const session = await authService.validateSession(sessionId, refreshToken);
|
||||
if (!session) {
|
||||
clearSessionCookies(cookies);
|
||||
return json(apiError('Invalid or expired refresh token'), { status: 401 });
|
||||
}
|
||||
|
||||
const user = await userService.findById(userId);
|
||||
const tokens = await authService.rotateTokens(user.id, user.email, user.role);
|
||||
|
||||
setRotatedCookies(cookies, tokens);
|
||||
const user = await userService.findById(session.userId);
|
||||
await rotateSessionCookies(
|
||||
cookies,
|
||||
session.id,
|
||||
{ id: user.id, email: user.email, role: user.role },
|
||||
session.rememberMe
|
||||
);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
|
||||
@@ -28,14 +28,15 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
default: async (event) => {
|
||||
const { request, cookies } = event;
|
||||
const form = await superValidate(request, zod(loginSchema));
|
||||
|
||||
if (!form.valid) {
|
||||
return fail(400, { form });
|
||||
}
|
||||
|
||||
const { email, password } = form.data;
|
||||
const { email, password, rememberMe } = form.data;
|
||||
|
||||
const user = await userService.findByEmail(email);
|
||||
if (!user) {
|
||||
@@ -51,7 +52,7 @@ export const actions: Actions = {
|
||||
return setError(form, 'email', 'Invalid email or password');
|
||||
}
|
||||
|
||||
await issueSessionCookies(cookies, user);
|
||||
await issueSessionCookies(cookies, user, { rememberMe, event });
|
||||
|
||||
throw redirect(302, '/');
|
||||
}
|
||||
|
||||
@@ -105,6 +105,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="rememberMe"
|
||||
bind:checked={$form.rememberMe}
|
||||
class="h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-ring/30"
|
||||
/>
|
||||
<span>Keep me signed in for 30 days</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={$submitting}
|
||||
|
||||
@@ -33,7 +33,8 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
default: async (event) => {
|
||||
const { request, cookies } = event;
|
||||
const registrationEnabled = await isRegistrationEnabled();
|
||||
if (!registrationEnabled) {
|
||||
throw error(403, { message: 'Registration is currently disabled' });
|
||||
@@ -65,7 +66,7 @@ export const actions: Actions = {
|
||||
// Add user to default groups
|
||||
await groupService.addUserToDefaultGroups(user.id);
|
||||
|
||||
await issueSessionCookies(cookies, user);
|
||||
await issueSessionCookies(cookies, user, { event });
|
||||
|
||||
throw redirect(302, '/');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user