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?
|
avatarUrl String?
|
||||||
authProvider String @default("local") // local | oauth
|
authProvider String @default("local") // local | oauth
|
||||||
role String @default("user") // admin | user
|
role String @default("user") // admin | user
|
||||||
refreshToken String?
|
|
||||||
refreshTokenExpiresAt DateTime?
|
|
||||||
onboardingComplete Boolean @default(false)
|
onboardingComplete Boolean @default(false)
|
||||||
trackRecentApps Boolean @default(true)
|
trackRecentApps Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -29,6 +27,7 @@ model User {
|
|||||||
locale String?
|
locale String?
|
||||||
|
|
||||||
groups UserGroup[]
|
groups UserGroup[]
|
||||||
|
sessions Session[]
|
||||||
createdApps App[]
|
createdApps App[]
|
||||||
boards Board[]
|
boards Board[]
|
||||||
favorites UserFavorite[]
|
favorites UserFavorite[]
|
||||||
@@ -42,6 +41,24 @@ model User {
|
|||||||
@@index([email])
|
@@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 {
|
model Group {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
|
|||||||
+46
-47
@@ -9,7 +9,8 @@ import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
|
|||||||
import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
|
import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
|
||||||
import {
|
import {
|
||||||
clearSessionCookies,
|
clearSessionCookies,
|
||||||
setRotatedCookies
|
rotateSessionCookies,
|
||||||
|
COOKIE_NAMES
|
||||||
} from '$lib/server/utils/sessionCookies.js';
|
} from '$lib/server/utils/sessionCookies.js';
|
||||||
|
|
||||||
// Initialize backup scheduler on server startup
|
// 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));
|
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 }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
// Initialize locals
|
|
||||||
event.locals.user = null;
|
event.locals.user = null;
|
||||||
event.locals.session = null;
|
event.locals.session = null;
|
||||||
event.locals.apiTokenScope = null;
|
event.locals.apiTokenScope = null;
|
||||||
|
|
||||||
const accessToken = event.cookies.get(ACCESS_TOKEN_COOKIE);
|
const accessToken = event.cookies.get(COOKIE_NAMES.ACCESS_TOKEN);
|
||||||
const refreshToken = event.cookies.get(REFRESH_TOKEN_COOKIE);
|
const refreshToken = event.cookies.get(COOKIE_NAMES.REFRESH_TOKEN);
|
||||||
|
const sessionId = event.cookies.get(COOKIE_NAMES.SESSION_ID);
|
||||||
|
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
try {
|
try {
|
||||||
@@ -44,7 +42,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
role: user.role as 'admin' | 'user'
|
role: user.role as 'admin' | 'user'
|
||||||
};
|
};
|
||||||
event.locals.session = {
|
event.locals.session = {
|
||||||
id: payload.userId,
|
id: sessionId ?? payload.userId,
|
||||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
|
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -52,40 +50,36 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no valid session but refresh token exists, attempt rotation
|
// If no valid session but refresh + session id exist, try to rotate.
|
||||||
if (!event.locals.user && refreshToken) {
|
if (!event.locals.user && refreshToken && sessionId) {
|
||||||
try {
|
try {
|
||||||
// We need to find the user by refresh token.
|
const session = await authService.validateSession(sessionId, refreshToken);
|
||||||
// The refresh token is stored hashed per-user, so we need
|
if (session) {
|
||||||
// a userId from somewhere. We store it in a separate cookie.
|
const user = await userService.findById(session.userId);
|
||||||
const userIdFromCookie = event.cookies.get('refresh_user_id');
|
await rotateSessionCookies(
|
||||||
if (userIdFromCookie) {
|
event.cookies,
|
||||||
const isValid = await authService.validateRefreshToken(userIdFromCookie, refreshToken);
|
session.id,
|
||||||
if (isValid) {
|
{ id: user.id, email: user.email, role: user.role },
|
||||||
const user = await userService.findById(userIdFromCookie);
|
session.rememberMe
|
||||||
const tokens = await authService.rotateTokens(user.id, user.email, user.role);
|
);
|
||||||
|
|
||||||
setRotatedCookies(event.cookies, tokens);
|
event.locals.user = {
|
||||||
|
id: user.id,
|
||||||
event.locals.user = {
|
email: user.email,
|
||||||
id: user.id,
|
displayName: user.displayName,
|
||||||
email: user.email,
|
role: user.role as 'admin' | 'user'
|
||||||
displayName: user.displayName,
|
};
|
||||||
role: user.role as 'admin' | 'user'
|
event.locals.session = {
|
||||||
};
|
id: session.id,
|
||||||
event.locals.session = {
|
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
|
||||||
id: user.id,
|
};
|
||||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Refresh failed — clear stale cookies
|
|
||||||
clearSessionCookies(event.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) {
|
if (!event.locals.user) {
|
||||||
const bearerToken = extractBearerToken(event);
|
const bearerToken = extractBearerToken(event);
|
||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
@@ -117,27 +111,33 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
if (event.locals.apiTokenScope) {
|
if (event.locals.apiTokenScope) {
|
||||||
const method = event.request.method;
|
const method = event.request.method;
|
||||||
const scope = event.locals.apiTokenScope;
|
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');
|
const isAdminPath = pathname.startsWith('/api/admin') || pathname.startsWith('/admin');
|
||||||
|
|
||||||
if (scope === 'read' && isWriteMethod) {
|
if (scope === 'read' && isWriteMethod) {
|
||||||
return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope "read" does not allow write operations' }), {
|
return new Response(
|
||||||
status: 403,
|
JSON.stringify({
|
||||||
headers: { 'Content-Type': 'application/json' }
|
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) {
|
if (scope !== 'admin' && isAdminPath) {
|
||||||
return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope does not allow admin operations' }), {
|
return new Response(
|
||||||
status: 403,
|
JSON.stringify({
|
||||||
headers: { 'Content-Type': 'application/json' }
|
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)) {
|
if (!event.locals.user && !isPublicPath(pathname)) {
|
||||||
// Check if this is a guest-accessible board route
|
|
||||||
const boardMatch = pathname.match(/^\/boards\/([^/]+)/);
|
const boardMatch = pathname.match(/^\/boards\/([^/]+)/);
|
||||||
if (boardMatch) {
|
if (boardMatch) {
|
||||||
const boardId = boardMatch[1];
|
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 === '/') {
|
if (pathname === '/') {
|
||||||
return resolve(event);
|
return resolve(event);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
// Mock prisma before importing authService
|
// Mock prisma before importing authService
|
||||||
vi.mock('../../prisma.js', () => ({
|
vi.mock('../../prisma.js', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
user: {
|
session: {
|
||||||
|
create: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
findUnique: vi.fn()
|
deleteMany: vi.fn(),
|
||||||
|
findMany: vi.fn()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -19,8 +22,9 @@ import {
|
|||||||
signAccessToken,
|
signAccessToken,
|
||||||
verifyAccessToken,
|
verifyAccessToken,
|
||||||
generateRefreshToken,
|
generateRefreshToken,
|
||||||
getRefreshTokenExpiry,
|
createSession,
|
||||||
rotateTokens
|
rotateSession,
|
||||||
|
validateSession
|
||||||
} from '../authService.js';
|
} from '../authService.js';
|
||||||
import { prisma } from '../../prisma.js';
|
import { prisma } from '../../prisma.js';
|
||||||
|
|
||||||
@@ -84,31 +88,88 @@ describe('authService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getRefreshTokenExpiry', () => {
|
describe('createSession', () => {
|
||||||
it('returns a future date', () => {
|
it('creates a session row and returns the raw refresh token', async () => {
|
||||||
const expiry = getRefreshTokenExpiry();
|
vi.mocked(prisma.session.create).mockResolvedValue({
|
||||||
expect(expiry.getTime()).toBeGreaterThan(Date.now());
|
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', () => {
|
it('extends expiry for remember-me sessions', async () => {
|
||||||
const expiry = getRefreshTokenExpiry();
|
vi.mocked(prisma.session.create).mockImplementation(
|
||||||
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
|
(({ data }: { data: Record<string, unknown> }) =>
|
||||||
const diff = expiry.getTime() - Date.now();
|
Promise.resolve({
|
||||||
// Allow 10 seconds tolerance
|
id: 'ses-2',
|
||||||
expect(diff).toBeGreaterThan(sevenDaysMs - 10000);
|
...data,
|
||||||
expect(diff).toBeLessThan(sevenDaysMs + 10000);
|
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', () => {
|
describe('validateSession', () => {
|
||||||
it('generates new token pair and saves refresh token', async () => {
|
it('returns null for missing session', async () => {
|
||||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
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();
|
describe('rotateSession', () => {
|
||||||
expect(result.refreshToken).toBeTruthy();
|
it('updates token hash and keeps the same session id', async () => {
|
||||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
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 jwt from 'jsonwebtoken';
|
||||||
import { prisma } from '../prisma.js';
|
import { prisma } from '../prisma.js';
|
||||||
import { DEFAULTS } from '$lib/utils/constants.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;
|
const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
@@ -18,15 +18,6 @@ function getJwtExpiry(): string {
|
|||||||
return process.env.JWT_EXPIRY || DEFAULTS.JWT_EXPIRY;
|
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> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
return bcrypt.hash(password, SALT_ROUNDS);
|
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('');
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRefreshTokenExpiry(): Date {
|
export interface SessionMetadata {
|
||||||
const days = getRefreshTokenExpiryDays();
|
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();
|
const expiry = new Date();
|
||||||
expiry.setDate(expiry.getDate() + days);
|
expiry.setDate(expiry.getDate() + days);
|
||||||
return expiry;
|
return expiry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveRefreshToken(userId: string, refreshToken: string): Promise<void> {
|
/**
|
||||||
const hashedToken = await bcrypt.hash(refreshToken, SALT_ROUNDS);
|
* Create a new session row and return the session id + raw refresh token.
|
||||||
await prisma.user.update({
|
* The raw token is returned once — only the hash is stored.
|
||||||
where: { id: userId },
|
*/
|
||||||
data: {
|
export async function createSession(
|
||||||
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(
|
|
||||||
userId: string,
|
userId: string,
|
||||||
email: string,
|
meta: SessionMetadata = {}
|
||||||
role: string
|
): Promise<IssuedSession> {
|
||||||
): Promise<TokenPair> {
|
|
||||||
const accessToken = signAccessToken({ userId, email, role });
|
|
||||||
const refreshToken = generateRefreshToken();
|
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.
|
* JWT utilities — thin re-exports from authService.
|
||||||
* authService already handles sign, verify, and refresh token generation.
|
|
||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
signAccessToken,
|
signAccessToken,
|
||||||
verifyAccessToken,
|
verifyAccessToken,
|
||||||
generateRefreshToken,
|
generateRefreshToken
|
||||||
getRefreshTokenExpiry
|
|
||||||
} from '../services/authService.js';
|
} 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 * as authService from '$lib/server/services/authService.js';
|
||||||
|
import { parseUserAgentLabel } from './userAgent.js';
|
||||||
|
|
||||||
export const ACCESS_TOKEN_TTL_SEC = 900; // 15 minutes
|
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 {
|
function isHttpsOrigin(): boolean {
|
||||||
const origin = process.env.ORIGIN;
|
const origin = process.env.ORIGIN;
|
||||||
if (origin) return origin.startsWith('https://');
|
if (origin) return origin.startsWith('https://');
|
||||||
// Fall back to NODE_ENV only when ORIGIN is unset.
|
|
||||||
return process.env.NODE_ENV === 'production';
|
return process.env.NODE_ENV === 'production';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared cookie attributes (httpOnly, secure, sameSite=lax, path=/).
|
* Shared cookie attributes. `secure` is derived from ORIGIN (https://...)
|
||||||
* `secure` is derived from ORIGIN (https://...) rather than NODE_ENV so
|
* rather than NODE_ENV so plain-HTTP production deployments don't silently
|
||||||
* plain-HTTP production deployments don't silently drop cookies.
|
* drop cookies.
|
||||||
*/
|
*/
|
||||||
export function cookieBase() {
|
export function cookieBase() {
|
||||||
return {
|
return {
|
||||||
@@ -31,40 +36,99 @@ interface SessionUser {
|
|||||||
readonly role: string;
|
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.
|
* Create a new session, persist it, and set access + refresh + session_id cookies.
|
||||||
* Persists a new refresh token in the DB.
|
|
||||||
*/
|
*/
|
||||||
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({
|
const accessToken = authService.signAccessToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role
|
role: user.role
|
||||||
});
|
});
|
||||||
const refreshToken = authService.generateRefreshToken();
|
|
||||||
await authService.saveRefreshToken(user.id, refreshToken);
|
|
||||||
|
|
||||||
const base = cookieBase();
|
const base = cookieBase();
|
||||||
cookies.set('access_token', accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC });
|
const ttl = refreshTtl(rememberMe);
|
||||||
cookies.set('refresh_token', refreshToken, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC });
|
|
||||||
cookies.set('refresh_user_id', user.id, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC });
|
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).
|
* Rotate an existing session's tokens and set new cookies. Used on refresh.
|
||||||
* Does not touch the DB.
|
|
||||||
*/
|
*/
|
||||||
export function setRotatedCookies(
|
export async function rotateSessionCookies(
|
||||||
cookies: Cookies,
|
cookies: Cookies,
|
||||||
tokens: { readonly accessToken: string; readonly refreshToken: string }
|
sessionId: string,
|
||||||
): void {
|
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();
|
const base = cookieBase();
|
||||||
cookies.set('access_token', tokens.accessToken, { ...base, maxAge: ACCESS_TOKEN_TTL_SEC });
|
const ttl = refreshTtl(rememberMe);
|
||||||
cookies.set('refresh_token', tokens.refreshToken, { ...base, maxAge: REFRESH_TOKEN_TTL_SEC });
|
|
||||||
|
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 {
|
export function clearSessionCookies(cookies: Cookies): void {
|
||||||
cookies.delete('access_token', { path: '/' });
|
cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' });
|
||||||
cookies.delete('refresh_token', { 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: '/' });
|
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;
|
readonly role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenPair {
|
|
||||||
readonly accessToken: string;
|
|
||||||
readonly refreshToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
readonly email: string;
|
readonly email: string;
|
||||||
readonly password: string;
|
readonly password: string;
|
||||||
|
readonly rememberMe?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterRequest {
|
export interface RegisterRequest {
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
|
|
||||||
export const loginSchema = z.object({
|
export const loginSchema = z.object({
|
||||||
email: z.string().email('Invalid email address'),
|
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({
|
export const registerSchema = z.object({
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export const GET: RequestHandler = async () => {
|
|||||||
* POST /api/onboarding — Complete an onboarding step.
|
* POST /api/onboarding — Complete an onboarding step.
|
||||||
* No auth required (onboarding runs before any user exists).
|
* 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;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
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 });
|
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 }));
|
return json(success({ complete: true }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types.js';
|
import type { RequestHandler } from './$types.js';
|
||||||
import * as authService from '$lib/server/services/authService.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 }) => {
|
export const POST: RequestHandler = async ({ cookies }) => {
|
||||||
// Revoke refresh token in database
|
// Revoke the current session if we have its id
|
||||||
if (locals.user) {
|
const sessionId = cookies.get(COOKIE_NAMES.SESSION_ID);
|
||||||
|
if (sessionId) {
|
||||||
try {
|
try {
|
||||||
await authService.revokeRefreshToken(locals.user.id);
|
await authService.revokeSession(sessionId);
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort revocation — continue with cookie cleanup
|
// Best-effort revocation — continue with cookie cleanup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all auth cookies
|
clearSessionCookies(cookies);
|
||||||
cookies.delete('access_token', { path: '/' });
|
|
||||||
cookies.delete('refresh_token', { path: '/' });
|
|
||||||
cookies.delete('refresh_user_id', { path: '/' });
|
|
||||||
|
|
||||||
throw redirect(302, '/login');
|
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 * as userService from '$lib/server/services/userService.js';
|
||||||
import { issueSessionCookies } from '$lib/server/utils/sessionCookies.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 {
|
try {
|
||||||
// Check for error response from the provider
|
// Check for error response from the provider
|
||||||
const oauthError = url.searchParams.get('error');
|
const oauthError = url.searchParams.get('error');
|
||||||
@@ -51,7 +52,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
|||||||
groups: userInfo.groups ? [...userInfo.groups] : undefined
|
groups: userInfo.groups ? [...userInfo.groups] : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
await issueSessionCookies(cookies, user);
|
await issueSessionCookies(cookies, user, { event });
|
||||||
|
|
||||||
throw redirect(302, '/');
|
throw redirect(302, '/');
|
||||||
} catch (err) {
|
} 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 { error as apiError } from '$lib/server/utils/response.js';
|
||||||
import {
|
import {
|
||||||
ACCESS_TOKEN_TTL_SEC,
|
ACCESS_TOKEN_TTL_SEC,
|
||||||
|
COOKIE_NAMES,
|
||||||
clearSessionCookies,
|
clearSessionCookies,
|
||||||
setRotatedCookies
|
rotateSessionCookies
|
||||||
} from '$lib/server/utils/sessionCookies.js';
|
} from '$lib/server/utils/sessionCookies.js';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ cookies }) => {
|
export const POST: RequestHandler = async ({ cookies }) => {
|
||||||
const refreshToken = cookies.get('refresh_token');
|
const refreshToken = cookies.get(COOKIE_NAMES.REFRESH_TOKEN);
|
||||||
const userId = cookies.get('refresh_user_id');
|
const sessionId = cookies.get(COOKIE_NAMES.SESSION_ID);
|
||||||
|
|
||||||
if (!refreshToken || !userId) {
|
if (!refreshToken || !sessionId) {
|
||||||
return json(apiError('No refresh token provided'), { status: 401 });
|
return json(apiError('No refresh token provided'), { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isValid = await authService.validateRefreshToken(userId, refreshToken);
|
const session = await authService.validateSession(sessionId, refreshToken);
|
||||||
if (!isValid) {
|
if (!session) {
|
||||||
clearSessionCookies(cookies);
|
clearSessionCookies(cookies);
|
||||||
return json(apiError('Invalid or expired refresh token'), { status: 401 });
|
return json(apiError('Invalid or expired refresh token'), { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await userService.findById(userId);
|
const user = await userService.findById(session.userId);
|
||||||
const tokens = await authService.rotateTokens(user.id, user.email, user.role);
|
await rotateSessionCookies(
|
||||||
|
cookies,
|
||||||
setRotatedCookies(cookies, tokens);
|
session.id,
|
||||||
|
{ id: user.id, email: user.email, role: user.role },
|
||||||
|
session.rememberMe
|
||||||
|
);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -28,14 +28,15 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async ({ request, cookies }) => {
|
default: async (event) => {
|
||||||
|
const { request, cookies } = event;
|
||||||
const form = await superValidate(request, zod(loginSchema));
|
const form = await superValidate(request, zod(loginSchema));
|
||||||
|
|
||||||
if (!form.valid) {
|
if (!form.valid) {
|
||||||
return fail(400, { form });
|
return fail(400, { form });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, password } = form.data;
|
const { email, password, rememberMe } = form.data;
|
||||||
|
|
||||||
const user = await userService.findByEmail(email);
|
const user = await userService.findByEmail(email);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -51,7 +52,7 @@ export const actions: Actions = {
|
|||||||
return setError(form, 'email', 'Invalid email or password');
|
return setError(form, 'email', 'Invalid email or password');
|
||||||
}
|
}
|
||||||
|
|
||||||
await issueSessionCookies(cookies, user);
|
await issueSessionCookies(cookies, user, { rememberMe, event });
|
||||||
|
|
||||||
throw redirect(302, '/');
|
throw redirect(302, '/');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={$submitting}
|
disabled={$submitting}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async ({ request, cookies }) => {
|
default: async (event) => {
|
||||||
|
const { request, cookies } = event;
|
||||||
const registrationEnabled = await isRegistrationEnabled();
|
const registrationEnabled = await isRegistrationEnabled();
|
||||||
if (!registrationEnabled) {
|
if (!registrationEnabled) {
|
||||||
throw error(403, { message: 'Registration is currently disabled' });
|
throw error(403, { message: 'Registration is currently disabled' });
|
||||||
@@ -65,7 +66,7 @@ export const actions: Actions = {
|
|||||||
// Add user to default groups
|
// Add user to default groups
|
||||||
await groupService.addUserToDefaultGroups(user.id);
|
await groupService.addUserToDefaultGroups(user.id);
|
||||||
|
|
||||||
await issueSessionCookies(cookies, user);
|
await issueSessionCookies(cookies, user, { event });
|
||||||
|
|
||||||
throw redirect(302, '/');
|
throw redirect(302, '/');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user