diff --git a/package.json b/package.json index 7ade218..b57af77 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@types/better-sqlite3": "^7.6.11", "@types/jsonwebtoken": "^9.0.7", "@types/ms": "^0.7.34", + "@types/node-schedule": "^2.1.7", "autoprefixer": "^10.4.20", "dexie": "^4.0.10", "drizzle-kit": "^0.22.0", @@ -50,6 +51,8 @@ "drizzle-orm": "^0.33.0", "jsonwebtoken": "^9.0.2", "ms": "^2.1.3", + "node-schedule": "^2.1.1", + "uuid": "^11.0.3", "zod": "^3.24.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb35a7d..df5d1cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ dependencies: ms: specifier: ^2.1.3 version: 2.1.3 + node-schedule: + specifier: ^2.1.1 + version: 2.1.1 + uuid: + specifier: ^11.0.3 + version: 11.0.3 zod: specifier: ^3.24.1 version: 3.24.1 @@ -49,6 +55,9 @@ devDependencies: '@types/ms': specifier: ^0.7.34 version: 0.7.34 + '@types/node-schedule': + specifier: ^2.1.7 + version: 2.1.7 autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.49) @@ -1293,6 +1302,12 @@ packages: resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} dev: true + /@types/node-schedule@2.1.7: + resolution: {integrity: sha512-G7Z3R9H7r3TowoH6D2pkzUHPhcJrDF4Jz1JOQ80AX0K2DWTHoN9VC94XzFAPNMdbW9TBzMZ3LjpFi7RYdbxtXA==} + dependencies: + '@types/node': 22.10.2 + dev: true + /@types/node@22.10.2: resolution: {integrity: sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==} dependencies: @@ -1694,6 +1709,13 @@ packages: engines: {node: '>= 0.6'} dev: true + /cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + dependencies: + luxon: 3.5.0 + dev: false + /cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2621,10 +2643,19 @@ packages: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} dev: false + /long-timeout@0.1.1: + resolution: {integrity: sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==} + dev: false + /lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} dev: true + /luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + dev: false + /magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} dependencies: @@ -2740,6 +2771,15 @@ packages: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} dev: true + /node-schedule@2.1.1: + resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==} + engines: {node: '>=6'} + dependencies: + cron-parser: 4.9.0 + long-timeout: 0.1.1 + sorted-array-functions: 1.3.0 + dev: false + /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -3239,6 +3279,10 @@ packages: totalist: 3.0.1 dev: true + /sorted-array-functions@1.3.0: + resolution: {integrity: sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==} + dev: false + /source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3591,6 +3635,11 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + /uuid@11.0.3: + resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==} + hasBin: true + dev: false + /vite@5.4.11: resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} engines: {node: ^18.0.0 || >=20.0.0} diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..c26afde --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,9 @@ +import type { ServerInit } from "@sveltejs/kit"; +import schedule from "node-schedule"; +import { cleanupExpiredRefreshTokens } from "$lib/server/db/token"; + +export const init: ServerInit = () => { + schedule.scheduleJob("0 * * * *", () => { + cleanupExpiredRefreshTokens(); + }); +}; diff --git a/src/lib/server/db/client.ts b/src/lib/server/db/client.ts index c3c64ad..006aa32 100644 --- a/src/lib/server/db/client.ts +++ b/src/lib/server/db/client.ts @@ -5,8 +5,10 @@ import { client, userClient } from "./schema"; export const createClient = async (pubKey: string, userId: number) => { await db.transaction(async (tx) => { const insertRes = await tx.insert(client).values({ pubKey }).returning({ id: client.id }); - const { id: clientId } = insertRes[0]!; - await tx.insert(userClient).values({ userId, clientId }); + await tx.insert(userClient).values({ + userId, + clientId: insertRes[0]!.id, + }); }); }; diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 6674d19..68981bb 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -1,2 +1,3 @@ export * from "./client"; +export * from "./token"; export * from "./user"; diff --git a/src/lib/server/db/schema/token.ts b/src/lib/server/db/schema/token.ts new file mode 100644 index 0000000..bbf6acf --- /dev/null +++ b/src/lib/server/db/schema/token.ts @@ -0,0 +1,18 @@ +import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core"; +import { client } from "./client"; +import { user } from "./user"; + +export const refreshToken = sqliteTable( + "refresh_token", + { + id: text("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => user.id), + clientId: integer("client_id").references(() => client.id), + expiresAt: integer("expires_at").notNull(), // Only used for cleanup + }, + (t) => ({ + unq: unique().on(t.userId, t.clientId), + }), +); diff --git a/src/lib/server/db/schema/user.ts b/src/lib/server/db/schema/user.ts index 474db82..2ad0e3c 100644 --- a/src/lib/server/db/schema/user.ts +++ b/src/lib/server/db/schema/user.ts @@ -5,9 +5,3 @@ export const user = sqliteTable("user", { email: text("email").notNull().unique(), password: text("password").notNull(), }); - -export const revokedToken = sqliteTable("revoked_token", { - id: integer("id").primaryKey(), - token: text("token").notNull().unique(), - revokedAt: integer("revoked_at").notNull(), -}); diff --git a/src/lib/server/db/token.ts b/src/lib/server/db/token.ts new file mode 100644 index 0000000..89e0deb --- /dev/null +++ b/src/lib/server/db/token.ts @@ -0,0 +1,75 @@ +import { SqliteError } from "better-sqlite3"; +import { eq, lte } from "drizzle-orm"; +import ms from "ms"; +import env from "$lib/server/loadenv"; +import db from "./drizzle"; +import { refreshToken } from "./schema"; + +const expiresIn = ms(env.jwt.refreshExp); +const expiresAt = () => Date.now() + expiresIn; + +export const registerRefreshToken = async ( + userId: number, + clientId: number | null, + tokenId: string, +) => { + try { + await db + .insert(refreshToken) + .values({ + id: tokenId, + userId, + clientId, + expiresAt: expiresAt(), + }) + .execute(); + return true; + } catch (e) { + if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + return false; + } + throw e; + } +}; + +export const getRefreshToken = async (tokenId: string) => { + const tokens = await db.select().from(refreshToken).where(eq(refreshToken.id, tokenId)).execute(); + return tokens[0] ?? null; +}; + +export const rotateRefreshToken = async (oldTokenId: string, newTokenId: string) => { + const res = await db + .update(refreshToken) + .set({ + id: newTokenId, + expiresAt: expiresAt(), + }) + .where(eq(refreshToken.id, oldTokenId)) + .execute(); + return res.changes > 0; +}; + +export const upgradeRefreshToken = async ( + oldTokenId: string, + newTokenId: string, + clientId: number, +) => { + const res = await db + .update(refreshToken) + .set({ + id: newTokenId, + clientId, + expiresAt: expiresAt(), + }) + .where(eq(refreshToken.id, oldTokenId)) + .execute(); + return res.changes > 0; +}; + +export const revokeRefreshToken = async (tokenId: string) => { + await db.delete(refreshToken).where(eq(refreshToken.id, tokenId)).execute(); +}; + +export const cleanupExpiredRefreshTokens = async () => { + await db.delete(refreshToken).where(lte(refreshToken.expiresAt, Date.now())).execute(); +}; diff --git a/src/lib/server/db/user.ts b/src/lib/server/db/user.ts index f564680..38a53f0 100644 --- a/src/lib/server/db/user.ts +++ b/src/lib/server/db/user.ts @@ -1,27 +1,8 @@ import { eq } from "drizzle-orm"; import db from "./drizzle"; -import { user, revokedToken } from "./schema"; +import { user } from "./schema"; export const getUserByEmail = async (email: string) => { const users = await db.select().from(user).where(eq(user.email, email)).execute(); return users[0] ?? null; }; - -export const revokeToken = async (token: string) => { - await db - .insert(revokedToken) - .values({ - token, - revokedAt: Date.now(), - }) - .execute(); -}; - -export const isTokenRevoked = async (token: string) => { - const tokens = await db - .select() - .from(revokedToken) - .where(eq(revokedToken.token, token)) - .execute(); - return tokens.length > 0; -}; diff --git a/src/lib/server/modules/auth.ts b/src/lib/server/modules/auth.ts index 4d456b1..da8a2d8 100644 --- a/src/lib/server/modules/auth.ts +++ b/src/lib/server/modules/auth.ts @@ -2,34 +2,31 @@ import { error } from "@sveltejs/kit"; import jwt from "jsonwebtoken"; import env from "$lib/server/loadenv"; -interface TokenData { - type: "access" | "refresh"; - userId: number; - clientId?: number; -} +type TokenPayload = + | { + type: "access"; + userId: number; + clientId?: number; + } + | { + type: "refresh"; + jti: string; + }; export enum TokenError { EXPIRED, INVALID, } -export const issueToken = (type: "access" | "refresh", userId: number, clientId?: number) => { - return jwt.sign( - { - type, - userId, - clientId, - } satisfies TokenData, - env.jwt.secret, - { - expiresIn: type === "access" ? env.jwt.accessExp : env.jwt.refreshExp, - }, - ); +export const issueToken = (payload: TokenPayload) => { + return jwt.sign(payload, env.jwt.secret, { + expiresIn: payload.type === "access" ? env.jwt.accessExp : env.jwt.refreshExp, + }); }; export const verifyToken = (token: string) => { try { - return jwt.verify(token, env.jwt.secret) as TokenData; + return jwt.verify(token, env.jwt.secret) as TokenPayload; } catch (error) { if (error instanceof jwt.TokenExpiredError) { return TokenError.EXPIRED; @@ -41,18 +38,18 @@ export const verifyToken = (token: string) => { export const authenticate = (request: Request) => { const accessToken = request.headers.get("Authorization"); if (!accessToken?.startsWith("Bearer ")) { - error(401, "Token required"); + error(401, "Access token required"); } - const tokenData = verifyToken(accessToken.slice(7)); - if (tokenData === TokenError.EXPIRED) { - error(401, "Token expired"); - } else if (tokenData === TokenError.INVALID || tokenData.type !== "access") { - error(401, "Invalid token"); + const tokenPayload = verifyToken(accessToken.slice(7)); + if (tokenPayload === TokenError.EXPIRED) { + error(401, "Access token expired"); + } else if (tokenPayload === TokenError.INVALID || tokenPayload.type !== "access") { + error(401, "Invalid access token"); } return { - userId: tokenData.userId, - clientId: tokenData.clientId, + userId: tokenPayload.userId, + clientId: tokenPayload.clientId, }; }; diff --git a/src/lib/server/services/auth.ts b/src/lib/server/services/auth.ts index af2a9ab..172fc4a 100644 --- a/src/lib/server/services/auth.ts +++ b/src/lib/server/services/auth.ts @@ -1,21 +1,37 @@ import { error } from "@sveltejs/kit"; import argon2 from "argon2"; +import { v4 as uuidv4 } from "uuid"; import { getClientByPubKey } from "$lib/server/db/client"; -import { getUserByEmail, revokeToken, isTokenRevoked } from "$lib/server/db/user"; +import { getUserByEmail } from "$lib/server/db/user"; +import { + getRefreshToken, + registerRefreshToken, + rotateRefreshToken, + revokeRefreshToken, +} from "$lib/server/db/token"; import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth"; const verifyPassword = async (hash: string, password: string) => { return await argon2.verify(hash, password); }; +const issueAccessToken = (userId: number, clientId?: number) => { + return issueToken({ type: "access", userId, clientId }); +}; + +const issueRefreshToken = async (userId: number, clientId?: number) => { + const jti = uuidv4(); + const token = issueToken({ type: "refresh", jti }); + + if (!(await registerRefreshToken(userId, clientId ?? null, jti))) { + error(403, "Already logged in"); + } + return token; +}; + export const login = async (email: string, password: string, pubKey?: string) => { const user = await getUserByEmail(email); - if (!user) { - error(401, "Invalid email or password"); - } - - const isValid = await verifyPassword(user.password, password); - if (!isValid) { + if (!user || !(await verifyPassword(user.password, password))) { error(401, "Invalid email or password"); } @@ -25,36 +41,45 @@ export const login = async (email: string, password: string, pubKey?: string) => } return { - accessToken: issueToken("access", user.id, client?.id), - refreshToken: issueToken("refresh", user.id, client?.id), + accessToken: issueAccessToken(user.id, client?.id), + refreshToken: await issueRefreshToken(user.id, client?.id), }; }; const verifyRefreshToken = async (refreshToken: string) => { - const tokenData = verifyToken(refreshToken); - if (tokenData === TokenError.EXPIRED) { - error(401, "Token expired"); - } else if ( - tokenData === TokenError.INVALID || - tokenData.type !== "refresh" || - (await isTokenRevoked(refreshToken)) - ) { - error(401, "Invalid token"); + const tokenPayload = verifyToken(refreshToken); + if (tokenPayload === TokenError.EXPIRED) { + error(401, "Refresh token expired"); + } else if (tokenPayload === TokenError.INVALID || tokenPayload.type !== "refresh") { + error(401, "Invalid refresh token"); } - return tokenData; + + const tokenData = await getRefreshToken(tokenPayload.jti); + if (!tokenData) { + error(500, "Refresh token not found"); + } + + return { + jti: tokenPayload.jti, + userId: tokenData.userId, + clientId: tokenData.clientId ?? undefined, + }; }; export const logout = async (refreshToken: string) => { - await verifyRefreshToken(refreshToken); - await revokeToken(refreshToken); + const { jti } = await verifyRefreshToken(refreshToken); + await revokeRefreshToken(jti); }; export const refreshToken = async (refreshToken: string) => { - const tokenData = await verifyRefreshToken(refreshToken); + const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken); + const newJti = uuidv4(); - await revokeToken(refreshToken); + if (!(await rotateRefreshToken(oldJti, newJti))) { + error(500, "Refresh token not found"); + } return { - accessToken: issueToken("access", tokenData.userId, tokenData.clientId), - refreshToken: issueToken("refresh", tokenData.userId, tokenData.clientId), + accessToken: issueAccessToken(userId, clientId), + refreshToken: issueToken({ type: "refresh", jti: newJti }), }; };