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=
JWT_ACCESS_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 { 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();
});
};

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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