/api/auth 아래의 Endpoint들을 tRPC로 마이그레이션

This commit is contained in:
static
2025-12-25 23:44:23 +09:00
parent b92b4a0b1b
commit 3fc29cf8db
15 changed files with 214 additions and 286 deletions

153
src/trpc/routers/auth.ts Normal file
View File

@@ -0,0 +1,153 @@
import { TRPCError } from "@trpc/server";
import argon2 from "argon2";
import { z } from "zod";
import { ClientRepo, SessionRepo, UserRepo, IntegrityError } from "$lib/server/db";
import env from "$lib/server/loadenv";
import { startSession } from "$lib/server/modules/auth";
import { generateChallenge, verifySignature } from "$lib/server/modules/crypto";
import { publicProcedure, roleProcedure, router } from "../init.server";
const hashPassword = async (password: string) => {
return await argon2.hash(password);
};
const verifyPassword = async (hash: string, password: string) => {
return await argon2.verify(hash, password);
};
const authRouter = router({
login: publicProcedure
.input(
z.object({
email: z.email(),
password: z.string().trim().nonempty(),
}),
)
.mutation(async ({ ctx, input }) => {
const user = await UserRepo.getUserByEmail(input.email);
if (!user || !(await verifyPassword(user.password, input.password))) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid email or password" });
}
const sessionIdSigned = await startSession(user.id, ctx.locals.ip, ctx.locals.userAgent);
ctx.cookies.set("sessionId", sessionIdSigned, {
path: "/",
maxAge: env.session.exp / 1000,
secure: true,
sameSite: "strict",
});
}),
logout: roleProcedure["any"].mutation(async ({ ctx }) => {
await SessionRepo.deleteSession(ctx.session.sessionId);
ctx.cookies.delete("sessionId", { path: "/" });
}),
changePassword: roleProcedure["any"]
.input(
z.object({
oldPassword: z.string().trim().nonempty(),
newPassword: z.string().trim().nonempty(),
}),
)
.mutation(async ({ ctx, input }) => {
if (input.oldPassword === input.newPassword) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Same passwords" });
} else if (input.newPassword.length < 8) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Too short password" });
}
const user = await UserRepo.getUser(ctx.session.userId);
if (!user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid session id" });
} else if (!(await verifyPassword(user.password, input.oldPassword))) {
throw new TRPCError({ code: "FORBIDDEN", message: "Invalid password" });
}
await UserRepo.setUserPassword(ctx.session.userId, await hashPassword(input.newPassword));
await SessionRepo.deleteAllOtherSessions(ctx.session.userId, ctx.session.sessionId);
}),
upgradeSession: roleProcedure["notClient"]
.input(
z.object({
encPubKey: z.base64().nonempty(),
sigPubKey: z.base64().nonempty(),
}),
)
.mutation(async ({ ctx, input }) => {
const client = await ClientRepo.getClientByPubKeys(input.encPubKey, input.sigPubKey);
const userClient = client
? await ClientRepo.getUserClient(ctx.session.userId, client.id)
: undefined;
if (!client) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid public key(s)" });
} else if (!userClient || userClient.state === "challenging") {
throw new TRPCError({ code: "FORBIDDEN", message: "Unregistered client" });
}
const { answer, challenge } = await generateChallenge(32, input.encPubKey);
const { id } = await SessionRepo.registerSessionUpgradeChallenge(
ctx.session.sessionId,
client.id,
answer.toString("base64"),
ctx.locals.ip,
new Date(Date.now() + env.challenge.sessionUpgradeExp),
);
return { id, challenge: challenge.toString("base64") };
}),
verifySessionUpgrade: roleProcedure["notClient"]
.input(
z.object({
id: z.int().positive(),
answerSig: z.base64().nonempty(),
force: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
const challenge = await SessionRepo.consumeSessionUpgradeChallenge(
input.id,
ctx.session.sessionId,
ctx.locals.ip,
);
if (!challenge) {
throw new TRPCError({ code: "FORBIDDEN", message: "Invalid challenge answer" });
}
const client = await ClientRepo.getClient(challenge.clientId);
if (!client) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid challenge answer" });
} else if (
!verifySignature(Buffer.from(challenge.answer, "base64"), input.answerSig, client.sigPubKey)
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid challenge answer signature",
});
}
try {
await SessionRepo.upgradeSession(
ctx.session.userId,
ctx.session.sessionId,
client.id,
input.force,
);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "Session not found") {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Invalid challenge answer",
});
} else if (!input.force && e.message === "Session already exists") {
throw new TRPCError({ code: "CONFLICT", message: "Already logged in" });
}
}
throw e;
}
}),
});
export default authRouter;

View File

@@ -1,3 +1,4 @@
export { default as authRouter } from "./auth";
export { default as categoryRouter } from "./category";
export { default as clientRouter } from "./client";
export { default as directoryRouter } from "./directory";