Token Upgrade시 챌린지를 거치도록 변경

This commit is contained in:
static
2024-12-31 03:01:29 +09:00
parent 4f20d2edbf
commit b84d6fd5ad
14 changed files with 208 additions and 69 deletions

View File

@@ -5,4 +5,5 @@ JWT_SECRET=
DATABASE_URL= DATABASE_URL=
JWT_ACCESS_TOKEN_EXPIRES= JWT_ACCESS_TOKEN_EXPIRES=
JWT_REFRESH_TOKEN_EXPIRES= JWT_REFRESH_TOKEN_EXPIRES=
PUBKEY_CHALLENGE_EXPIRES= USER_CLIENT_CHALLENGE_EXPIRES=
TOKEN_UPGRADE_CHALLENGE_EXPIRES=

View File

@@ -2,7 +2,10 @@ import { redirect, type ServerInit, type Handle } from "@sveltejs/kit";
import schedule from "node-schedule"; import schedule from "node-schedule";
import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client"; import { cleanupExpiredUserClientChallenges } from "$lib/server/db/client";
import { migrateDB } from "$lib/server/db/drizzle"; 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 = () => { export const init: ServerInit = () => {
migrateDB(); migrateDB();
@@ -10,6 +13,7 @@ export const init: ServerInit = () => {
schedule.scheduleJob("0 * * * *", () => { schedule.scheduleJob("0 * * * *", () => {
cleanupExpiredUserClientChallenges(); cleanupExpiredUserClientChallenges();
cleanupExpiredRefreshTokens(); cleanupExpiredRefreshTokens();
cleanupExpiredTokenUpgradeChallenges();
}); });
}; };

View File

@@ -90,7 +90,7 @@ export const setUserClientStateToActive = async (userId: number, clientId: numbe
.execute(); .execute();
}; };
export const createUserClientChallenge = async ( export const registerUserClientChallenge = async (
userId: number, userId: number,
clientId: number, clientId: number,
answer: string, answer: string,

View File

@@ -16,3 +16,16 @@ export const refreshToken = sqliteTable(
unq: unique().on(t.userId, t.clientId), 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(),
});

View File

@@ -1,9 +1,9 @@
import { SqliteError } from "better-sqlite3"; import { SqliteError } from "better-sqlite3";
import { eq, lte } from "drizzle-orm"; import { and, eq, gt, lte } from "drizzle-orm";
import ms from "ms"; import ms from "ms";
import env from "$lib/server/loadenv"; import env from "$lib/server/loadenv";
import db from "./drizzle"; import db from "./drizzle";
import { refreshToken } from "./schema"; import { refreshToken, tokenUpgradeChallenge } from "./schema";
const expiresIn = ms(env.jwt.refreshExp); const expiresIn = ms(env.jwt.refreshExp);
const expiresAt = () => new Date(Date.now() + expiresIn); const expiresAt = () => new Date(Date.now() + expiresIn);
@@ -73,3 +73,44 @@ export const revokeRefreshToken = async (tokenId: string) => {
export const cleanupExpiredRefreshTokens = async () => { export const cleanupExpiredRefreshTokens = async () => {
await db.delete(refreshToken).where(lte(refreshToken.expiresAt, new Date())).execute(); 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();
};

View File

@@ -13,6 +13,7 @@ export default {
refreshExp: env.JWT_REFRESH_TOKEN_EXPIRES || "14d", refreshExp: env.JWT_REFRESH_TOKEN_EXPIRES || "14d",
}, },
challenge: { challenge: {
pubKeyExp: env.PUBKEY_CHALLENGE_EXPIRES || "5m", userClientExp: env.USER_CLIENT_CHALLENGE_EXPIRES || "5m",
tokenUpgradeExp: env.TOKEN_UPGRADE_CHALLENGE_EXPIRES || "5m",
}, },
}; };

View File

@@ -1,10 +1,6 @@
import { constants, randomBytes, createPublicKey, publicEncrypt, verify } from "crypto"; import { constants, randomBytes, createPublicKey, publicEncrypt, verify } from "crypto";
import { promisify } from "util"; import { promisify } from "util";
export const generateRandomBytes = async (length: number) => {
return await promisify(randomBytes)(length);
};
const makePubKeyPem = (pubKey: string) => const makePubKeyPem = (pubKey: string) =>
`-----BEGIN PUBLIC KEY-----\n${pubKey}\n-----END PUBLIC KEY-----`; `-----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"), 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 };
};

View File

@@ -1,16 +1,21 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import argon2 from "argon2"; import argon2 from "argon2";
import ms from "ms";
import { v4 as uuidv4 } from "uuid"; 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 { getUserByEmail } from "$lib/server/db/user";
import env from "$lib/server/loadenv";
import { import {
getRefreshToken, getRefreshToken,
registerRefreshToken, registerRefreshToken,
rotateRefreshToken, rotateRefreshToken,
upgradeRefreshToken, upgradeRefreshToken,
revokeRefreshToken, revokeRefreshToken,
registerTokenUpgradeChallenge,
getTokenUpgradeChallenge,
} from "$lib/server/db/token"; } from "$lib/server/db/token";
import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth"; import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth";
import { verifySignature, generateChallenge } from "$lib/server/modules/crypto";
const verifyPassword = async (hash: string, password: string) => { const verifyPassword = async (hash: string, password: string) => {
return await argon2.verify(hash, password); return await argon2.verify(hash, password);
@@ -30,23 +35,15 @@ const issueRefreshToken = async (userId: number, clientId?: number) => {
return token; 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); const user = await getUserByEmail(email);
if (!user || !(await verifyPassword(user.password, password))) { if (!user || !(await verifyPassword(user.password, password))) {
error(401, "Invalid email or 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 { return {
accessToken: issueAccessToken(user.id, client?.id), accessToken: issueAccessToken(user.id),
refreshToken: await issueRefreshToken(user.id, client?.id), refreshToken: await issueRefreshToken(user.id),
}; };
}; };
@@ -75,7 +72,7 @@ export const logout = async (refreshToken: string) => {
await revokeRefreshToken(jti); await revokeRefreshToken(jti);
}; };
export const refreshTokens = async (refreshToken: string) => { export const refreshToken = async (refreshToken: string) => {
const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken); const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken);
const newJti = uuidv4(); 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); const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken);
if (clientId) { if (clientId) {
error(403, "Forbidden"); error(403, "Forbidden");
} }
const client = await getClientByPubKey(pubKey); const challenge = await getTokenUpgradeChallenge(answer, ip);
const userClient = client ? await getUserClient(userId, client.id) : undefined; if (!challenge) {
if (!client) { error(401, "Invalid challenge answer");
error(401, "Invalid public key"); } else if (challenge.refreshTokenId !== oldJti) {
} else if (client && (!userClient || userClient.state === "challenging")) { error(403, "Forbidden");
error(401, "Unregistered public key");
} }
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(); const newJti = uuidv4();
if (!(await upgradeRefreshToken(oldJti, newJti, client.id))) { if (!(await upgradeRefreshToken(oldJti, newJti, client.id))) {
error(500, "Refresh token not found"); error(500, "Refresh token not found");

View File

@@ -9,15 +9,10 @@ import {
getAllUserClients, getAllUserClients,
getUserClient, getUserClient,
setUserClientStateToPending, setUserClientStateToPending,
createUserClientChallenge, registerUserClientChallenge,
getUserClientChallenge, getUserClientChallenge,
} from "$lib/server/db/client"; } from "$lib/server/db/client";
import { import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto";
generateRandomBytes,
verifyPubKey,
encryptAsymmetric,
verifySignature,
} from "$lib/server/modules/crypto";
import { isInitialMekNeeded } from "$lib/server/modules/mek"; import { isInitialMekNeeded } from "$lib/server/modules/mek";
import env from "$lib/server/loadenv"; 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 expiresAt = () => new Date(Date.now() + expiresIn);
const generateChallenge = async ( const createUserClientChallenge = async (
userId: number, userId: number,
ip: string, ip: string,
clientId: number, clientId: number,
encPubKey: string, encPubKey: string,
) => { ) => {
const answer = await generateRandomBytes(32); const { answer, challenge } = await generateChallenge(32, encPubKey);
const answerBase64 = answer.toString("base64"); await registerUserClientChallenge(userId, clientId, answer.toString("base64"), ip, expiresAt());
await createUserClientChallenge(userId, clientId, answerBase64, ip, expiresAt());
const challenge = encryptAsymmetric(answer, encPubKey);
return challenge.toString("base64"); return challenge.toString("base64");
}; };
@@ -80,7 +72,7 @@ export const registerUserClient = async (
clientId = await createClient(encPubKey, sigPubKey, userId); 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) => { export const getUserClientStatus = async (userId: number, clientId: number) => {
@@ -115,5 +107,7 @@ export const verifyUserClient = async (
error(401, "Invalid challenge answer signature"); error(401, "Invalid challenge answer signature");
} }
// TODO: Replay attack prevention
await setUserClientStateToPending(userId, challenge.clientId); await setUserClientStateToPending(userId, challenge.clientId);
}; };

View File

@@ -10,14 +10,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
.object({ .object({
email: z.string().email().nonempty(), email: z.string().email().nonempty(),
password: z.string().nonempty(), password: z.string().nonempty(),
pubKey: z.string().base64().nonempty().optional(),
}) })
.safeParse(await request.json()); .safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body"); 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());
const { accessToken, refreshToken } = await login(email.trim(), password.trim(), pubKey?.trim());
cookies.set("accessToken", accessToken, { cookies.set("accessToken", accessToken, {
path: "/", path: "/",
maxAge: Math.floor(ms(env.jwt.accessExp) / 1000), 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), maxAge: Math.floor(ms(env.jwt.refreshExp) / 1000),
sameSite: "strict", sameSite: "strict",
}); });
return text("Logged in", { headers: { "Content-Type": "text/plain" } }); return text("Logged in", { headers: { "Content-Type": "text/plain" } });
}; };

View File

@@ -7,8 +7,8 @@ export const POST: RequestHandler = async ({ cookies }) => {
if (!token) error(401, "Refresh token not found"); if (!token) error(401, "Refresh token not found");
await logout(token.trim()); await logout(token.trim());
cookies.delete("accessToken", { path: "/" }); cookies.delete("accessToken", { path: "/" });
cookies.delete("refreshToken", { path: "/api/auth" }); cookies.delete("refreshToken", { path: "/api/auth" });
return text("Logged out", { headers: { "Content-Type": "text/plain" } }); return text("Logged out", { headers: { "Content-Type": "text/plain" } });
}; };

View File

@@ -1,13 +1,12 @@
import { error, text } from "@sveltejs/kit"; 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"; import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ cookies }) => { export const POST: RequestHandler = async ({ cookies }) => {
const token = cookies.get("refreshToken"); const token = cookies.get("refreshToken");
if (!token) error(401, "Refresh token not found"); 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, { cookies.set("accessToken", accessToken, {
path: "/", path: "/",
sameSite: "strict", sameSite: "strict",
@@ -16,5 +15,6 @@ export const POST: RequestHandler = async ({ cookies }) => {
path: "/api/auth", path: "/api/auth",
sameSite: "strict", sameSite: "strict",
}); });
return text("Token refreshed", { headers: { "Content-Type": "text/plain" } }); return text("Token refreshed", { headers: { "Content-Type": "text/plain" } });
}; };

View File

@@ -1,29 +1,26 @@
import { error, text } from "@sveltejs/kit"; import { error, json } from "@sveltejs/kit";
import { z } from "zod"; import { z } from "zod";
import { upgradeTokens } from "$lib/server/services/auth"; import { createTokenUpgradeChallenge } from "$lib/server/services/auth";
import type { RequestHandler } from "./$types"; 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"); const token = cookies.get("refreshToken");
if (!token) error(401, "Refresh token not found"); if (!token) error(401, "Refresh token not found");
const zodRes = z const zodRes = z
.object({ .object({
pubKey: z.string().base64().nonempty(), encPubKey: z.string().base64().nonempty(),
sigPubKey: z.string().base64().nonempty(),
}) })
.safeParse(await request.json()); .safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body"); if (!zodRes.success) error(400, "Invalid request body");
const { encPubKey, sigPubKey } = zodRes.data;
const { pubKey } = zodRes.data; const { challenge } = await createTokenUpgradeChallenge(
const { accessToken, refreshToken } = await upgradeTokens(token.trim(), pubKey.trim()); token.trim(),
getClientAddress(),
cookies.set("accessToken", accessToken, { encPubKey.trim(),
path: "/", sigPubKey.trim(),
sameSite: "strict", );
}); return json({ challenge });
cookies.set("refreshToken", refreshToken, {
path: "/api/auth",
sameSite: "strict",
});
return text("Token upgraded", { headers: { "Content-Type": "text/plain" } });
}; };

View File

@@ -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" } });
};