diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts deleted file mode 100644 index 87b3bf7..0000000 --- a/src/lib/server/auth.ts +++ /dev/null @@ -1,44 +0,0 @@ -import argon2 from "argon2"; -import jwt from "jsonwebtoken"; -import { getClientByPubKey } from "$lib/server/db/client"; -import { getUserByEmail } from "$lib/server/db/user"; -import env from "$lib/server/loadenv"; - -interface TokenData { - type: "access" | "refresh"; - userId: number; - clientId?: number; -} - -const verifyPassword = async (hash: string, password: string) => { - return await argon2.verify(hash, password); -}; - -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 login = async (email: string, password: string, pubKey?: string) => { - const user = await getUserByEmail(email); - if (!user) return null; - - const isValid = await verifyPassword(user.password, password); - if (!isValid) return null; - - const client = pubKey ? await getClientByPubKey(pubKey) : null; - - return { - accessToken: issueToken("access", user.id, client?.id), - refreshToken: issueToken("refresh", user.id, client?.id), - }; -}; diff --git a/src/lib/server/db/schema/user.ts b/src/lib/server/db/schema/user.ts index 2ad0e3c..474db82 100644 --- a/src/lib/server/db/schema/user.ts +++ b/src/lib/server/db/schema/user.ts @@ -5,3 +5,9 @@ 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/user.ts b/src/lib/server/db/user.ts index 38a53f0..f564680 100644 --- a/src/lib/server/db/user.ts +++ b/src/lib/server/db/user.ts @@ -1,8 +1,27 @@ import { eq } from "drizzle-orm"; import db from "./drizzle"; -import { user } from "./schema"; +import { user, revokedToken } 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 new file mode 100644 index 0000000..9ab7420 --- /dev/null +++ b/src/lib/server/modules/auth.ts @@ -0,0 +1,38 @@ +import jwt from "jsonwebtoken"; +import env from "$lib/server/loadenv"; + +interface TokenData { + type: "access" | "refresh"; + userId: number; + clientId?: number; +} + +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 verifyToken = (token: string) => { + try { + return jwt.verify(token, env.jwt.secret) as TokenData; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + return TokenError.EXPIRED; + } + return TokenError.INVALID; + } +}; diff --git a/src/lib/server/services/auth.ts b/src/lib/server/services/auth.ts new file mode 100644 index 0000000..af2a9ab --- /dev/null +++ b/src/lib/server/services/auth.ts @@ -0,0 +1,60 @@ +import { error } from "@sveltejs/kit"; +import argon2 from "argon2"; +import { getClientByPubKey } from "$lib/server/db/client"; +import { getUserByEmail, revokeToken, isTokenRevoked } from "$lib/server/db/user"; +import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth"; + +const verifyPassword = async (hash: string, password: string) => { + return await argon2.verify(hash, password); +}; + +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) { + error(401, "Invalid email or password"); + } + + const client = pubKey ? await getClientByPubKey(pubKey) : undefined; + if (client === null) { + error(401, "Invalid public key"); + } + + return { + accessToken: issueToken("access", user.id, client?.id), + refreshToken: issueToken("refresh", 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"); + } + return tokenData; +}; + +export const logout = async (refreshToken: string) => { + await verifyRefreshToken(refreshToken); + await revokeToken(refreshToken); +}; + +export const refreshToken = async (refreshToken: string) => { + const tokenData = await verifyRefreshToken(refreshToken); + + await revokeToken(refreshToken); + return { + accessToken: issueToken("access", tokenData.userId, tokenData.clientId), + refreshToken: issueToken("refresh", tokenData.userId, tokenData.clientId), + }; +}; diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 2ec24c3..8364a76 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -1,6 +1,6 @@ import { error, json } from "@sveltejs/kit"; import { z } from "zod"; -import { login } from "$lib/server/auth"; +import { login } from "$lib/server/services/auth"; import type { RequestHandler } from "./$types"; export const POST: RequestHandler = async ({ request }) => { @@ -14,9 +14,5 @@ export const POST: RequestHandler = async ({ request }) => { if (!zodRes.success) error(400, zodRes.error.message); const { email, password, pubKey } = zodRes.data; - const loginRes = await login(email.trim(), password.trim(), pubKey?.trim()); - if (!loginRes) error(401, "Invalid email, password, or public key"); - - const { accessToken, refreshToken } = loginRes; - return json({ accessToken, refreshToken }); + return json(await login(email.trim(), password.trim(), pubKey?.trim())); }; diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts new file mode 100644 index 0000000..29df035 --- /dev/null +++ b/src/routes/api/auth/logout/+server.ts @@ -0,0 +1,18 @@ +import { error, text } from "@sveltejs/kit"; +import { z } from "zod"; +import { logout } from "$lib/server/services/auth"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request }) => { + const zodRes = z + .object({ + refreshToken: z.string().nonempty(), + }) + .safeParse(await request.json()); + if (!zodRes.success) error(400, zodRes.error.message); + + const { refreshToken } = zodRes.data; + await logout(refreshToken.trim()); + + return text("Logged out"); +}; diff --git a/src/routes/api/auth/refreshToken/+server.ts b/src/routes/api/auth/refreshToken/+server.ts new file mode 100644 index 0000000..f07de53 --- /dev/null +++ b/src/routes/api/auth/refreshToken/+server.ts @@ -0,0 +1,16 @@ +import { error, json } from "@sveltejs/kit"; +import { z } from "zod"; +import { refreshToken } from "$lib/server/services/auth"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request }) => { + const zodRes = z + .object({ + refreshToken: z.string().nonempty(), + }) + .safeParse(await request.json()); + if (!zodRes.success) error(400, zodRes.error.message); + + const { refreshToken: token } = zodRes.data; + return json(await refreshToken(token.trim())); +};