From 3fc29cf8dbd5623a79f52f00a5b40a130486beb3 Mon Sep 17 00:00:00 2001 From: static Date: Thu, 25 Dec 2025 23:44:23 +0900 Subject: [PATCH] =?UTF-8?q?/api/auth=20=EC=95=84=EB=9E=98=EC=9D=98=20Endpo?= =?UTF-8?q?int=EB=93=A4=EC=9D=84=20tRPC=EB=A1=9C=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/middlewares/authenticate.ts | 6 +- src/lib/server/schemas/auth.ts | 32 ---- src/lib/server/schemas/index.ts | 1 - src/lib/server/services/auth.ts | 122 -------------- src/lib/services/auth.ts | 60 ++++--- .../auth/changePassword/service.ts | 17 +- src/routes/(fullscreen)/auth/login/service.ts | 14 +- src/routes/api/auth/changePassword/+server.ts | 16 -- src/routes/api/auth/login/+server.ts | 21 --- src/routes/api/auth/logout/+server.ts | 13 -- src/routes/api/auth/upgradeSession/+server.ts | 26 --- .../api/auth/upgradeSession/verify/+server.ts | 16 -- src/trpc/router.server.ts | 2 + src/trpc/routers/auth.ts | 153 ++++++++++++++++++ src/trpc/routers/index.ts | 1 + 15 files changed, 214 insertions(+), 286 deletions(-) delete mode 100644 src/lib/server/schemas/auth.ts delete mode 100644 src/lib/server/services/auth.ts delete mode 100644 src/routes/api/auth/changePassword/+server.ts delete mode 100644 src/routes/api/auth/login/+server.ts delete mode 100644 src/routes/api/auth/logout/+server.ts delete mode 100644 src/routes/api/auth/upgradeSession/+server.ts delete mode 100644 src/routes/api/auth/upgradeSession/verify/+server.ts create mode 100644 src/trpc/routers/auth.ts diff --git a/src/lib/server/middlewares/authenticate.ts b/src/lib/server/middlewares/authenticate.ts index cc635b4..49f6545 100644 --- a/src/lib/server/middlewares/authenticate.ts +++ b/src/lib/server/middlewares/authenticate.ts @@ -3,11 +3,6 @@ import env from "$lib/server/loadenv"; import { authenticate, AuthenticationError } from "$lib/server/modules/auth"; export const authenticateMiddleware: Handle = async ({ event, resolve }) => { - const { pathname, search } = event.url; - if (pathname === "/api/auth/login") { - return await resolve(event); - } - try { const sessionIdSigned = event.cookies.get("sessionId"); if (!sessionIdSigned) { @@ -24,6 +19,7 @@ export const authenticateMiddleware: Handle = async ({ event, resolve }) => { }); } catch (e) { if (e instanceof AuthenticationError) { + const { pathname, search } = event.url; if (pathname === "/auth/login" || pathname.startsWith("/api/trpc")) { return await resolve(event); } else if (pathname.startsWith("/api")) { diff --git a/src/lib/server/schemas/auth.ts b/src/lib/server/schemas/auth.ts deleted file mode 100644 index d4972d1..0000000 --- a/src/lib/server/schemas/auth.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from "zod"; - -export const passwordChangeRequest = z.object({ - oldPassword: z.string().trim().nonempty(), - newPassword: z.string().trim().nonempty(), -}); -export type PasswordChangeRequest = z.input; - -export const loginRequest = z.object({ - email: z.email(), - password: z.string().trim().nonempty(), -}); -export type LoginRequest = z.input; - -export const sessionUpgradeRequest = z.object({ - encPubKey: z.base64().nonempty(), - sigPubKey: z.base64().nonempty(), -}); -export type SessionUpgradeRequest = z.input; - -export const sessionUpgradeResponse = z.object({ - id: z.int().positive(), - challenge: z.base64().nonempty(), -}); -export type SessionUpgradeResponse = z.output; - -export const sessionUpgradeVerifyRequest = z.object({ - id: z.int().positive(), - answerSig: z.base64().nonempty(), - force: z.boolean().default(false), -}); -export type SessionUpgradeVerifyRequest = z.input; diff --git a/src/lib/server/schemas/index.ts b/src/lib/server/schemas/index.ts index d9ddce7..f7a2bc1 100644 --- a/src/lib/server/schemas/index.ts +++ b/src/lib/server/schemas/index.ts @@ -1,4 +1,3 @@ -export * from "./auth"; export * from "./category"; export * from "./directory"; export * from "./file"; diff --git a/src/lib/server/services/auth.ts b/src/lib/server/services/auth.ts deleted file mode 100644 index 1c6867f..0000000 --- a/src/lib/server/services/auth.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { error } from "@sveltejs/kit"; -import argon2 from "argon2"; -import { getClient, getClientByPubKeys, getUserClient } from "$lib/server/db/client"; -import { IntegrityError } from "$lib/server/db/error"; -import { - upgradeSession, - deleteSession, - deleteAllOtherSessions, - registerSessionUpgradeChallenge, - consumeSessionUpgradeChallenge, -} from "$lib/server/db/session"; -import { getUser, getUserByEmail, setUserPassword } from "$lib/server/db/user"; -import env from "$lib/server/loadenv"; -import { startSession } from "$lib/server/modules/auth"; -import { verifySignature, generateChallenge } from "$lib/server/modules/crypto"; - -const hashPassword = async (password: string) => { - return await argon2.hash(password); -}; - -const verifyPassword = async (hash: string, password: string) => { - return await argon2.verify(hash, password); -}; - -export const changePassword = async ( - userId: number, - sessionId: string, - oldPassword: string, - newPassword: string, -) => { - if (oldPassword === newPassword) { - error(400, "Same passwords"); - } else if (newPassword.length < 8) { - error(400, "Too short password"); - } - - const user = await getUser(userId); - if (!user) { - error(500, "Invalid session id"); - } else if (!(await verifyPassword(user.password, oldPassword))) { - error(403, "Invalid password"); - } - - await setUserPassword(userId, await hashPassword(newPassword)); - await deleteAllOtherSessions(userId, sessionId); -}; - -export const login = async (email: string, password: string, ip: string, userAgent: string) => { - const user = await getUserByEmail(email); - if (!user || !(await verifyPassword(user.password, password))) { - error(401, "Invalid email or password"); - } - - return { sessionIdSigned: await startSession(user.id, ip, userAgent) }; -}; - -export const logout = async (sessionId: string) => { - await deleteSession(sessionId); -}; - -export const createSessionUpgradeChallenge = async ( - sessionId: string, - userId: number, - ip: string, - encPubKey: string, - sigPubKey: string, -) => { - 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(403, "Unregistered client"); - } - - const { answer, challenge } = await generateChallenge(32, encPubKey); - const { id } = await registerSessionUpgradeChallenge( - sessionId, - client.id, - answer.toString("base64"), - ip, - new Date(Date.now() + env.challenge.sessionUpgradeExp), - ); - - return { id, challenge: challenge.toString("base64") }; -}; - -export const verifySessionUpgradeChallenge = async ( - sessionId: string, - userId: number, - ip: string, - challengeId: number, - answerSig: string, - force: boolean, -) => { - const challenge = await consumeSessionUpgradeChallenge(challengeId, sessionId, ip); - if (!challenge) { - error(403, "Invalid challenge answer"); - } - - const client = await getClient(challenge.clientId); - if (!client) { - error(500, "Invalid challenge answer"); - } else if ( - !verifySignature(Buffer.from(challenge.answer, "base64"), answerSig, client.sigPubKey) - ) { - error(403, "Invalid challenge answer signature"); - } - - try { - await upgradeSession(userId, sessionId, client.id, force); - } catch (e) { - if (e instanceof IntegrityError) { - if (e.message === "Session not found") { - error(500, "Invalid challenge answer"); - } else if (!force && e.message === "Session already exists") { - error(409, "Already logged in"); - } - } - throw e; - } -}; diff --git a/src/lib/services/auth.ts b/src/lib/services/auth.ts index d1975f4..5d2d01a 100644 --- a/src/lib/services/auth.ts +++ b/src/lib/services/auth.ts @@ -1,10 +1,6 @@ -import { callPostApi } from "$lib/hooks"; +import { TRPCClientError } from "@trpc/client"; import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto"; -import type { - SessionUpgradeRequest, - SessionUpgradeResponse, - SessionUpgradeVerifyRequest, -} from "$lib/server/schemas"; +import { useTRPC } from "$trpc/client"; export const requestSessionUpgrade = async ( encryptKeyBase64: string, @@ -13,27 +9,45 @@ export const requestSessionUpgrade = async ( signKey: CryptoKey, force = false, ) => { - let res = await callPostApi("/api/auth/upgradeSession", { - encPubKey: encryptKeyBase64, - sigPubKey: verifyKeyBase64, - }); - if (res.status === 403) return [false, "Unregistered client"] as const; - else if (!res.ok) return [false] as const; - - const { id, challenge }: SessionUpgradeResponse = await res.json(); + const trpc = useTRPC(); + let id, challenge; + try { + ({ id, challenge } = await trpc.auth.upgradeSession.mutate({ + encPubKey: encryptKeyBase64, + sigPubKey: verifyKeyBase64, + })); + } catch (e) { + if (e instanceof TRPCClientError && e.data?.code === "FORBIDDEN") { + return [false, "Unregistered client"] as const; + } + return [false] as const; + } const answer = await decryptChallenge(challenge, decryptKey); const answerSig = await signMessageRSA(answer, signKey); - res = await callPostApi("/api/auth/upgradeSession/verify", { - id, - answerSig: encodeToBase64(answerSig), - force, - }); - if (res.status === 409) return [false, "Already logged in"] as const; - else return [res.ok] as const; + try { + await trpc.auth.verifySessionUpgrade.mutate({ + id, + answerSig: encodeToBase64(answerSig), + force, + }); + } catch (e) { + if (e instanceof TRPCClientError && e.data?.code === "CONFLICT") { + return [false, "Already logged in"] as const; + } + return [false] as const; + } + + return [true] as const; }; export const requestLogout = async () => { - const res = await callPostApi("/api/auth/logout"); - return res.ok; + const trpc = useTRPC(); + try { + await trpc.auth.logout.mutate(); + return true; + } catch { + // TODO: Error Handling + return false; + } }; diff --git a/src/routes/(fullscreen)/auth/changePassword/service.ts b/src/routes/(fullscreen)/auth/changePassword/service.ts index 37380a4..699ec7f 100644 --- a/src/routes/(fullscreen)/auth/changePassword/service.ts +++ b/src/routes/(fullscreen)/auth/changePassword/service.ts @@ -1,10 +1,13 @@ -import { callPostApi } from "$lib/hooks"; -import type { PasswordChangeRequest } from "$lib/server/schemas"; +import { useTRPC } from "$trpc/client"; export const requestPasswordChange = async (oldPassword: string, newPassword: string) => { - const res = await callPostApi("/api/auth/changePassword", { - oldPassword, - newPassword, - }); - return res.ok; + const trpc = useTRPC(); + + try { + await trpc.auth.changePassword.mutate({ oldPassword, newPassword }); + return true; + } catch { + // TODO: Error Handling + return false; + } }; diff --git a/src/routes/(fullscreen)/auth/login/service.ts b/src/routes/(fullscreen)/auth/login/service.ts index 88613f1..0d57545 100644 --- a/src/routes/(fullscreen)/auth/login/service.ts +++ b/src/routes/(fullscreen)/auth/login/service.ts @@ -1,5 +1,4 @@ -import { callPostApi } from "$lib/hooks"; -import type { LoginRequest } from "$lib/server/schemas"; +import { useTRPC } from "$trpc/client"; export { requestLogout } from "$lib/services/auth"; export { requestDeletedFilesCleanup } from "$lib/services/file"; @@ -9,6 +8,13 @@ export { } from "$lib/services/key"; export const requestLogin = async (email: string, password: string) => { - const res = await callPostApi("/api/auth/login", { email, password }); - return res.ok; + const trpc = useTRPC(); + + try { + await trpc.auth.login.mutate({ email, password }); + return true; + } catch { + // TODO: Error Handling + return false; + } }; diff --git a/src/routes/api/auth/changePassword/+server.ts b/src/routes/api/auth/changePassword/+server.ts deleted file mode 100644 index 59129b8..0000000 --- a/src/routes/api/auth/changePassword/+server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { error, text } from "@sveltejs/kit"; -import { authorize } from "$lib/server/modules/auth"; -import { passwordChangeRequest } from "$lib/server/schemas"; -import { changePassword } from "$lib/server/services/auth"; -import type { RequestHandler } from "./$types"; - -export const POST: RequestHandler = async ({ locals, request }) => { - const { sessionId, userId } = await authorize(locals, "any"); - - const zodRes = passwordChangeRequest.safeParse(await request.json()); - if (!zodRes.success) error(400, "Invalid request body"); - const { oldPassword, newPassword } = zodRes.data; - - await changePassword(userId, sessionId, oldPassword, newPassword); - return text("Password changed", { headers: { "Content-Type": "text/plain" } }); -}; diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts deleted file mode 100644 index d748f6a..0000000 --- a/src/routes/api/auth/login/+server.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { error, text } from "@sveltejs/kit"; -import env from "$lib/server/loadenv"; -import { loginRequest } from "$lib/server/schemas"; -import { login } from "$lib/server/services/auth"; -import type { RequestHandler } from "./$types"; - -export const POST: RequestHandler = async ({ locals, request, cookies }) => { - const zodRes = loginRequest.safeParse(await request.json()); - if (!zodRes.success) error(400, "Invalid request body"); - const { email, password } = zodRes.data; - - const { sessionIdSigned } = await login(email, password, locals.ip, locals.userAgent); - cookies.set("sessionId", sessionIdSigned, { - path: "/", - maxAge: env.session.exp / 1000, - secure: true, - 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 deleted file mode 100644 index b5c1f11..0000000 --- a/src/routes/api/auth/logout/+server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { text } from "@sveltejs/kit"; -import { authorize } from "$lib/server/modules/auth"; -import { logout } from "$lib/server/services/auth"; -import type { RequestHandler } from "./$types"; - -export const POST: RequestHandler = async ({ locals, cookies }) => { - const { sessionId } = await authorize(locals, "any"); - - await logout(sessionId); - cookies.delete("sessionId", { path: "/" }); - - return text("Logged out", { headers: { "Content-Type": "text/plain" } }); -}; diff --git a/src/routes/api/auth/upgradeSession/+server.ts b/src/routes/api/auth/upgradeSession/+server.ts deleted file mode 100644 index fa0b6cf..0000000 --- a/src/routes/api/auth/upgradeSession/+server.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { error, json } from "@sveltejs/kit"; -import { authorize } from "$lib/server/modules/auth"; -import { - sessionUpgradeRequest, - sessionUpgradeResponse, - type SessionUpgradeResponse, -} from "$lib/server/schemas"; -import { createSessionUpgradeChallenge } from "$lib/server/services/auth"; -import type { RequestHandler } from "./$types"; - -export const POST: RequestHandler = async ({ locals, request }) => { - const { sessionId, userId } = await authorize(locals, "notClient"); - - const zodRes = sessionUpgradeRequest.safeParse(await request.json()); - if (!zodRes.success) error(400, "Invalid request body"); - const { encPubKey, sigPubKey } = zodRes.data; - - const { id, challenge } = await createSessionUpgradeChallenge( - sessionId, - userId, - locals.ip, - encPubKey, - sigPubKey, - ); - return json(sessionUpgradeResponse.parse({ id, challenge } satisfies SessionUpgradeResponse)); -}; diff --git a/src/routes/api/auth/upgradeSession/verify/+server.ts b/src/routes/api/auth/upgradeSession/verify/+server.ts deleted file mode 100644 index bbaedca..0000000 --- a/src/routes/api/auth/upgradeSession/verify/+server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { error, text } from "@sveltejs/kit"; -import { authorize } from "$lib/server/modules/auth"; -import { sessionUpgradeVerifyRequest } from "$lib/server/schemas"; -import { verifySessionUpgradeChallenge } from "$lib/server/services/auth"; -import type { RequestHandler } from "./$types"; - -export const POST: RequestHandler = async ({ locals, request }) => { - const { sessionId, userId } = await authorize(locals, "notClient"); - - const zodRes = sessionUpgradeVerifyRequest.safeParse(await request.json()); - if (!zodRes.success) error(400, "Invalid request body"); - const { id, answerSig, force } = zodRes.data; - - await verifySessionUpgradeChallenge(sessionId, userId, locals.ip, id, answerSig, force); - return text("Session upgraded", { headers: { "Content-Type": "text/plain" } }); -}; diff --git a/src/trpc/router.server.ts b/src/trpc/router.server.ts index 3d05e93..64d25c7 100644 --- a/src/trpc/router.server.ts +++ b/src/trpc/router.server.ts @@ -2,6 +2,7 @@ import type { RequestEvent } from "@sveltejs/kit"; import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import { createContext, router } from "./init.server"; import { + authRouter, categoryRouter, clientRouter, directoryRouter, @@ -12,6 +13,7 @@ import { } from "./routers"; export const appRouter = router({ + auth: authRouter, category: categoryRouter, client: clientRouter, directory: directoryRouter, diff --git a/src/trpc/routers/auth.ts b/src/trpc/routers/auth.ts new file mode 100644 index 0000000..e0f3a7f --- /dev/null +++ b/src/trpc/routers/auth.ts @@ -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; diff --git a/src/trpc/routers/index.ts b/src/trpc/routers/index.ts index c943728..ab5b6a0 100644 --- a/src/trpc/routers/index.ts +++ b/src/trpc/routers/index.ts @@ -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";