From b84d6fd5ad98c9b2a2015463f2b048e13d9ab07b Mon Sep 17 00:00:00 2001 From: static Date: Tue, 31 Dec 2024 03:01:29 +0900 Subject: [PATCH] =?UTF-8?q?Token=20Upgrade=EC=8B=9C=20=EC=B1=8C=EB=A6=B0?= =?UTF-8?q?=EC=A7=80=EB=A5=BC=20=EA=B1=B0=EC=B9=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 +- src/hooks.server.ts | 6 +- src/lib/server/db/client.ts | 2 +- src/lib/server/db/schema/token.ts | 13 +++ src/lib/server/db/token.ts | 45 ++++++++- src/lib/server/loadenv.ts | 3 +- src/lib/server/modules/crypto.ts | 10 +- src/lib/server/services/auth.ts | 92 +++++++++++++++---- src/lib/server/services/client.ts | 24 ++--- src/routes/api/auth/login/+server.ts | 7 +- src/routes/api/auth/logout/+server.ts | 2 +- src/routes/api/auth/refreshToken/+server.ts | 6 +- src/routes/api/auth/upgradeToken/+server.ts | 29 +++--- .../api/auth/upgradeToken/verify/+server.ts | 35 +++++++ 14 files changed, 208 insertions(+), 69 deletions(-) create mode 100644 src/routes/api/auth/upgradeToken/verify/+server.ts diff --git a/.env.example b/.env.example index 665a966..d0fe7e5 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,5 @@ JWT_SECRET= DATABASE_URL= JWT_ACCESS_TOKEN_EXPIRES= JWT_REFRESH_TOKEN_EXPIRES= -PUBKEY_CHALLENGE_EXPIRES= +USER_CLIENT_CHALLENGE_EXPIRES= +TOKEN_UPGRADE_CHALLENGE_EXPIRES= diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 777e492..e237845 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -2,7 +2,10 @@ import { redirect, type ServerInit, type Handle } from "@sveltejs/kit"; import schedule from "node-schedule"; import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client"; import { migrateDB } from "$lib/server/db/drizzle"; -import { cleanupExpiredRefreshTokens } from "$lib/server/db/token"; +import { + cleanupExpiredRefreshTokens, + cleanupExpiredTokenUpgradeChallenges, +} from "$lib/server/db/token"; export const init: ServerInit = () => { migrateDB(); @@ -10,6 +13,7 @@ export const init: ServerInit = () => { schedule.scheduleJob("0 * * * *", () => { cleanupExpiredUserClientChallenges(); cleanupExpiredRefreshTokens(); + cleanupExpiredTokenUpgradeChallenges(); }); }; diff --git a/src/lib/server/db/client.ts b/src/lib/server/db/client.ts index 595d665..5423949 100644 --- a/src/lib/server/db/client.ts +++ b/src/lib/server/db/client.ts @@ -90,7 +90,7 @@ export const setUserClientStateToActive = async (userId: number, clientId: numbe .execute(); }; -export const createUserClientChallenge = async ( +export const registerUserClientChallenge = async ( userId: number, clientId: number, answer: string, diff --git a/src/lib/server/db/schema/token.ts b/src/lib/server/db/schema/token.ts index 7007f77..dcf995d 100644 --- a/src/lib/server/db/schema/token.ts +++ b/src/lib/server/db/schema/token.ts @@ -16,3 +16,16 @@ export const refreshToken = sqliteTable( unq: unique().on(t.userId, t.clientId), }), ); + +export const tokenUpgradeChallenge = sqliteTable("token_upgrade_challenge", { + id: integer("id").primaryKey(), + refreshTokenId: text("refresh_token_id") + .notNull() + .references(() => refreshToken.id), + clientId: integer("client_id") + .notNull() + .references(() => client.id), + answer: text("challenge").notNull().unique(), // Base64 + allowedIp: text("allowed_ip").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), +}); diff --git a/src/lib/server/db/token.ts b/src/lib/server/db/token.ts index 133b3b4..51499cf 100644 --- a/src/lib/server/db/token.ts +++ b/src/lib/server/db/token.ts @@ -1,9 +1,9 @@ import { SqliteError } from "better-sqlite3"; -import { eq, lte } from "drizzle-orm"; +import { and, eq, gt, lte } from "drizzle-orm"; import ms from "ms"; import env from "$lib/server/loadenv"; import db from "./drizzle"; -import { refreshToken } from "./schema"; +import { refreshToken, tokenUpgradeChallenge } from "./schema"; const expiresIn = ms(env.jwt.refreshExp); const expiresAt = () => new Date(Date.now() + expiresIn); @@ -73,3 +73,44 @@ export const revokeRefreshToken = async (tokenId: string) => { export const cleanupExpiredRefreshTokens = async () => { await db.delete(refreshToken).where(lte(refreshToken.expiresAt, new Date())).execute(); }; + +export const registerTokenUpgradeChallenge = async ( + tokenId: string, + clientId: number, + answer: string, + allowedIp: string, + expiresAt: Date, +) => { + await db + .insert(tokenUpgradeChallenge) + .values({ + refreshTokenId: tokenId, + clientId, + answer, + allowedIp, + expiresAt, + }) + .execute(); +}; + +export const getTokenUpgradeChallenge = async (answer: string, ip: string) => { + const challenges = await db + .select() + .from(tokenUpgradeChallenge) + .where( + and( + eq(tokenUpgradeChallenge.answer, answer), + eq(tokenUpgradeChallenge.allowedIp, ip), + gt(tokenUpgradeChallenge.expiresAt, new Date()), + ), + ) + .execute(); + return challenges[0] ?? null; +}; + +export const cleanupExpiredTokenUpgradeChallenges = async () => { + await db + .delete(tokenUpgradeChallenge) + .where(lte(tokenUpgradeChallenge.expiresAt, new Date())) + .execute(); +}; diff --git a/src/lib/server/loadenv.ts b/src/lib/server/loadenv.ts index c31fb07..58f6e72 100644 --- a/src/lib/server/loadenv.ts +++ b/src/lib/server/loadenv.ts @@ -13,6 +13,7 @@ export default { refreshExp: env.JWT_REFRESH_TOKEN_EXPIRES || "14d", }, challenge: { - pubKeyExp: env.PUBKEY_CHALLENGE_EXPIRES || "5m", + userClientExp: env.USER_CLIENT_CHALLENGE_EXPIRES || "5m", + tokenUpgradeExp: env.TOKEN_UPGRADE_CHALLENGE_EXPIRES || "5m", }, }; diff --git a/src/lib/server/modules/crypto.ts b/src/lib/server/modules/crypto.ts index ccf77be..4b1a176 100644 --- a/src/lib/server/modules/crypto.ts +++ b/src/lib/server/modules/crypto.ts @@ -1,10 +1,6 @@ import { constants, randomBytes, createPublicKey, publicEncrypt, verify } from "crypto"; import { promisify } from "util"; -export const generateRandomBytes = async (length: number) => { - return await promisify(randomBytes)(length); -}; - const makePubKeyPem = (pubKey: string) => `-----BEGIN PUBLIC KEY-----\n${pubKey}\n-----END PUBLIC KEY-----`; @@ -32,3 +28,9 @@ export const verifySignature = (data: string, signature: string, sigPubKey: stri Buffer.from(signature, "base64"), ); }; + +export const generateChallenge = async (length: number, encPubKey: string) => { + const answer = await promisify(randomBytes)(length); + const challenge = encryptAsymmetric(answer, encPubKey); + return { answer, challenge }; +}; diff --git a/src/lib/server/services/auth.ts b/src/lib/server/services/auth.ts index 8a9a2c4..d8b3fa1 100644 --- a/src/lib/server/services/auth.ts +++ b/src/lib/server/services/auth.ts @@ -1,16 +1,21 @@ import { error } from "@sveltejs/kit"; import argon2 from "argon2"; +import ms from "ms"; import { v4 as uuidv4 } from "uuid"; -import { getClientByPubKey, getUserClient } from "$lib/server/db/client"; +import { getClient, getClientByPubKeys, getUserClient } from "$lib/server/db/client"; import { getUserByEmail } from "$lib/server/db/user"; +import env from "$lib/server/loadenv"; import { getRefreshToken, registerRefreshToken, rotateRefreshToken, upgradeRefreshToken, revokeRefreshToken, + registerTokenUpgradeChallenge, + getTokenUpgradeChallenge, } from "$lib/server/db/token"; import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth"; +import { verifySignature, generateChallenge } from "$lib/server/modules/crypto"; const verifyPassword = async (hash: string, password: string) => { return await argon2.verify(hash, password); @@ -30,23 +35,15 @@ const issueRefreshToken = async (userId: number, clientId?: number) => { return token; }; -export const login = async (email: string, password: string, pubKey?: string) => { +export const login = async (email: string, password: string) => { const user = await getUserByEmail(email); if (!user || !(await verifyPassword(user.password, password))) { error(401, "Invalid email or password"); } - const client = pubKey ? await getClientByPubKey(pubKey) : undefined; - const userClient = client ? await getUserClient(user.id, client.id) : undefined; - if (client === null) { - error(401, "Invalid public key"); - } else if (client && (!userClient || userClient.state === "challenging")) { - error(401, "Unregistered public key"); - } - return { - accessToken: issueAccessToken(user.id, client?.id), - refreshToken: await issueRefreshToken(user.id, client?.id), + accessToken: issueAccessToken(user.id), + refreshToken: await issueRefreshToken(user.id), }; }; @@ -75,7 +72,7 @@ export const logout = async (refreshToken: string) => { await revokeRefreshToken(jti); }; -export const refreshTokens = async (refreshToken: string) => { +export const refreshToken = async (refreshToken: string) => { const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken); const newJti = uuidv4(); @@ -88,20 +85,75 @@ export const refreshTokens = async (refreshToken: string) => { }; }; -export const upgradeTokens = async (refreshToken: string, pubKey: string) => { +const expiresIn = ms(env.challenge.tokenUpgradeExp); +const expiresAt = () => new Date(Date.now() + expiresIn); + +const createChallenge = async ( + ip: string, + tokenId: string, + clientId: number, + encPubKey: string, +) => { + const { answer, challenge } = await generateChallenge(32, encPubKey); + await registerTokenUpgradeChallenge( + tokenId, + clientId, + answer.toString("base64"), + ip, + expiresAt(), + ); + return challenge.toString("base64"); +}; + +export const createTokenUpgradeChallenge = async ( + refreshToken: string, + ip: string, + encPubKey: string, + sigPubKey: string, +) => { + const { jti, userId, clientId } = await verifyRefreshToken(refreshToken); + if (clientId) { + error(403, "Forbidden"); + } + + const client = await getClientByPubKeys(encPubKey, sigPubKey); + const userClient = client ? await getUserClient(userId, client.id) : undefined; + if (!client) { + error(401, "Invalid public key(s)"); + } else if (!userClient || userClient.state === "challenging") { + error(401, "Unregistered client"); + } + + return { challenge: await createChallenge(ip, jti, client.id, encPubKey) }; +}; + +export const upgradeToken = async ( + refreshToken: string, + ip: string, + answer: string, + sigAnswer: string, +) => { const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken); if (clientId) { error(403, "Forbidden"); } - const client = await getClientByPubKey(pubKey); - const userClient = client ? await getUserClient(userId, client.id) : undefined; - if (!client) { - error(401, "Invalid public key"); - } else if (client && (!userClient || userClient.state === "challenging")) { - error(401, "Unregistered public key"); + const challenge = await getTokenUpgradeChallenge(answer, ip); + if (!challenge) { + error(401, "Invalid challenge answer"); + } else if (challenge.refreshTokenId !== oldJti) { + error(403, "Forbidden"); } + const client = await getClient(challenge.clientId); + if (!client) { + error(500, "Invalid challenge answer"); + } else if (!verifySignature(answer, sigAnswer, client.sigPubKey)) { + error(401, "Invalid challenge answer signature"); + } + + // TODO: Replay attack prevention + const newJti = uuidv4(); if (!(await upgradeRefreshToken(oldJti, newJti, client.id))) { error(500, "Refresh token not found"); diff --git a/src/lib/server/services/client.ts b/src/lib/server/services/client.ts index 104e7a6..c3ad525 100644 --- a/src/lib/server/services/client.ts +++ b/src/lib/server/services/client.ts @@ -9,15 +9,10 @@ import { getAllUserClients, getUserClient, setUserClientStateToPending, - createUserClientChallenge, + registerUserClientChallenge, getUserClientChallenge, } from "$lib/server/db/client"; -import { - generateRandomBytes, - verifyPubKey, - encryptAsymmetric, - verifySignature, -} from "$lib/server/modules/crypto"; +import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto"; import { isInitialMekNeeded } from "$lib/server/modules/mek"; import env from "$lib/server/loadenv"; @@ -31,20 +26,17 @@ export const getUserClientList = async (userId: number) => { }; }; -const expiresIn = ms(env.challenge.pubKeyExp); +const expiresIn = ms(env.challenge.userClientExp); const expiresAt = () => new Date(Date.now() + expiresIn); -const generateChallenge = async ( +const createUserClientChallenge = async ( userId: number, ip: string, clientId: number, encPubKey: string, ) => { - const answer = await generateRandomBytes(32); - const answerBase64 = answer.toString("base64"); - await createUserClientChallenge(userId, clientId, answerBase64, ip, expiresAt()); - - const challenge = encryptAsymmetric(answer, encPubKey); + const { answer, challenge } = await generateChallenge(32, encPubKey); + await registerUserClientChallenge(userId, clientId, answer.toString("base64"), ip, expiresAt()); return challenge.toString("base64"); }; @@ -80,7 +72,7 @@ export const registerUserClient = async ( clientId = await createClient(encPubKey, sigPubKey, userId); } - return { challenge: await generateChallenge(userId, ip, clientId, encPubKey) }; + return { challenge: await createUserClientChallenge(userId, ip, clientId, encPubKey) }; }; export const getUserClientStatus = async (userId: number, clientId: number) => { @@ -115,5 +107,7 @@ export const verifyUserClient = async ( error(401, "Invalid challenge answer signature"); } + // TODO: Replay attack prevention + await setUserClientStateToPending(userId, challenge.clientId); }; diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index ccd86f5..1e2281f 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -10,14 +10,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => { .object({ email: z.string().email().nonempty(), password: z.string().nonempty(), - pubKey: z.string().base64().nonempty().optional(), }) .safeParse(await request.json()); if (!zodRes.success) error(400, "Invalid request body"); + const { email, password } = zodRes.data; - const { email, password, pubKey } = zodRes.data; - const { accessToken, refreshToken } = await login(email.trim(), password.trim(), pubKey?.trim()); - + const { accessToken, refreshToken } = await login(email.trim(), password.trim()); cookies.set("accessToken", accessToken, { path: "/", maxAge: Math.floor(ms(env.jwt.accessExp) / 1000), @@ -28,5 +26,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => { maxAge: Math.floor(ms(env.jwt.refreshExp) / 1000), sameSite: "strict", }); + return text("Logged in", { headers: { "Content-Type": "text/plain" } }); }; diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts index a2750c9..ae5dcaf 100644 --- a/src/routes/api/auth/logout/+server.ts +++ b/src/routes/api/auth/logout/+server.ts @@ -7,8 +7,8 @@ export const POST: RequestHandler = async ({ cookies }) => { if (!token) error(401, "Refresh token not found"); await logout(token.trim()); - cookies.delete("accessToken", { path: "/" }); cookies.delete("refreshToken", { path: "/api/auth" }); + return text("Logged out", { headers: { "Content-Type": "text/plain" } }); }; diff --git a/src/routes/api/auth/refreshToken/+server.ts b/src/routes/api/auth/refreshToken/+server.ts index d05fc52..54fcd03 100644 --- a/src/routes/api/auth/refreshToken/+server.ts +++ b/src/routes/api/auth/refreshToken/+server.ts @@ -1,13 +1,12 @@ import { error, text } from "@sveltejs/kit"; -import { refreshTokens } from "$lib/server/services/auth"; +import { refreshToken as doRefreshToken } from "$lib/server/services/auth"; import type { RequestHandler } from "./$types"; export const POST: RequestHandler = async ({ cookies }) => { const token = cookies.get("refreshToken"); if (!token) error(401, "Refresh token not found"); - const { accessToken, refreshToken } = await refreshTokens(token.trim()); - + const { accessToken, refreshToken } = await doRefreshToken(token.trim()); cookies.set("accessToken", accessToken, { path: "/", sameSite: "strict", @@ -16,5 +15,6 @@ export const POST: RequestHandler = async ({ cookies }) => { path: "/api/auth", sameSite: "strict", }); + return text("Token refreshed", { headers: { "Content-Type": "text/plain" } }); }; diff --git a/src/routes/api/auth/upgradeToken/+server.ts b/src/routes/api/auth/upgradeToken/+server.ts index 20237e4..46fc5ca 100644 --- a/src/routes/api/auth/upgradeToken/+server.ts +++ b/src/routes/api/auth/upgradeToken/+server.ts @@ -1,29 +1,26 @@ -import { error, text } from "@sveltejs/kit"; +import { error, json } from "@sveltejs/kit"; import { z } from "zod"; -import { upgradeTokens } from "$lib/server/services/auth"; +import { createTokenUpgradeChallenge } from "$lib/server/services/auth"; import type { RequestHandler } from "./$types"; -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { const token = cookies.get("refreshToken"); if (!token) error(401, "Refresh token not found"); const zodRes = z .object({ - pubKey: z.string().base64().nonempty(), + encPubKey: z.string().base64().nonempty(), + sigPubKey: z.string().base64().nonempty(), }) .safeParse(await request.json()); if (!zodRes.success) error(400, "Invalid request body"); + const { encPubKey, sigPubKey } = zodRes.data; - const { pubKey } = zodRes.data; - const { accessToken, refreshToken } = await upgradeTokens(token.trim(), pubKey.trim()); - - cookies.set("accessToken", accessToken, { - path: "/", - sameSite: "strict", - }); - cookies.set("refreshToken", refreshToken, { - path: "/api/auth", - sameSite: "strict", - }); - return text("Token upgraded", { headers: { "Content-Type": "text/plain" } }); + const { challenge } = await createTokenUpgradeChallenge( + token.trim(), + getClientAddress(), + encPubKey.trim(), + sigPubKey.trim(), + ); + return json({ challenge }); }; diff --git a/src/routes/api/auth/upgradeToken/verify/+server.ts b/src/routes/api/auth/upgradeToken/verify/+server.ts new file mode 100644 index 0000000..f4e291f --- /dev/null +++ b/src/routes/api/auth/upgradeToken/verify/+server.ts @@ -0,0 +1,35 @@ +import { error, text } from "@sveltejs/kit"; +import { z } from "zod"; +import { upgradeToken } from "$lib/server/services/auth"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { + const token = cookies.get("refreshToken"); + if (!token) error(401, "Refresh token not found"); + + const zodRes = z + .object({ + answer: z.string().base64().nonempty(), + sigAnswer: z.string().base64().nonempty(), + }) + .safeParse(await request.json()); + if (!zodRes.success) error(400, "Invalid request body"); + const { answer, sigAnswer } = zodRes.data; + + const { accessToken, refreshToken } = await upgradeToken( + token.trim(), + getClientAddress(), + answer.trim(), + sigAnswer.trim(), + ); + cookies.set("accessToken", accessToken, { + path: "/", + sameSite: "strict", + }); + cookies.set("refreshToken", refreshToken, { + path: "/api/auth", + sameSite: "strict", + }); + + return text("Token upgraded", { headers: { "Content-Type": "text/plain" } }); +};