diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-03 06:05:33 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-04 23:26:34 +0900 |
| commit | dbcfe8a98e46e15fe951e5e98c68fd6ac8bde1b3 (patch) | |
| tree | 5780b2d0b0f32735477ff8f1f3308720d059ce67 /pkgs/server/src/routes/auth.ts | |
| parent | 20f159abf06599bc8902a445be21d4c085d82ede (diff) | |
| download | kioku-dbcfe8a98e46e15fe951e5e98c68fd6ac8bde1b3.tar.gz kioku-dbcfe8a98e46e15fe951e5e98c68fd6ac8bde1b3.tar.zst kioku-dbcfe8a98e46e15fe951e5e98c68fd6ac8bde1b3.zip | |
refactor(auth): introduce repository pattern for database access
Add repository types and implementations to abstract database operations,
improving testability and separation of concerns. The auth routes now use
dependency injection with UserRepository and RefreshTokenRepository
interfaces, making tests simpler by mocking interfaces instead of Drizzle
query builders.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'pkgs/server/src/routes/auth.ts')
| -rw-r--r-- | pkgs/server/src/routes/auth.ts | 351 |
1 files changed, 163 insertions, 188 deletions
diff --git a/pkgs/server/src/routes/auth.ts b/pkgs/server/src/routes/auth.ts index a2e6c8e..e1f7ebb 100644 --- a/pkgs/server/src/routes/auth.ts +++ b/pkgs/server/src/routes/auth.ts @@ -5,11 +5,15 @@ import { refreshTokenSchema, } from "@kioku/shared"; import * as argon2 from "argon2"; -import { and, eq, gt } from "drizzle-orm"; import { Hono } from "hono"; import { sign } from "hono/jwt"; -import { db, refreshTokens, users } from "../db"; import { Errors } from "../middleware"; +import { + type RefreshTokenRepository, + refreshTokenRepository, + type UserRepository, + userRepository, +} from "../repositories"; const JWT_SECRET = process.env.JWT_SECRET; if (!JWT_SECRET) { @@ -26,199 +30,170 @@ function hashToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } -const auth = new Hono(); - -auth.post("/register", async (c) => { - const body = await c.req.json(); - - const parsed = createUserSchema.safeParse(body); - if (!parsed.success) { - throw Errors.validationError(parsed.error.issues[0]?.message); - } - - const { username, password } = parsed.data; - - // Check if username already exists - const existingUser = await db - .select({ id: users.id }) - .from(users) - .where(eq(users.username, username)) - .limit(1); - - if (existingUser.length > 0) { - throw Errors.conflict("Username already exists", "USERNAME_EXISTS"); - } - - // Hash password with Argon2 - const passwordHash = await argon2.hash(password); - - // Create user - const [newUser] = await db - .insert(users) - .values({ - username, - passwordHash, - }) - .returning({ - id: users.id, - username: users.username, - createdAt: users.createdAt, - }); +export interface AuthDependencies { + userRepo: UserRepository; + refreshTokenRepo: RefreshTokenRepository; +} - return c.json({ user: newUser }, 201); -}); +export function createAuthRouter(deps: AuthDependencies) { + const { userRepo, refreshTokenRepo } = deps; + const auth = new Hono(); -auth.post("/login", async (c) => { - const body = await c.req.json(); - - const parsed = loginSchema.safeParse(body); - if (!parsed.success) { - throw Errors.validationError(parsed.error.issues[0]?.message); - } - - const { username, password } = parsed.data; - - // Find user by username - const [user] = await db - .select({ - id: users.id, - username: users.username, - passwordHash: users.passwordHash, - }) - .from(users) - .where(eq(users.username, username)) - .limit(1); - - if (!user) { - throw Errors.unauthorized( - "Invalid username or password", - "INVALID_CREDENTIALS", - ); - } - - // Verify password - const isPasswordValid = await argon2.verify(user.passwordHash, password); - if (!isPasswordValid) { - throw Errors.unauthorized( - "Invalid username or password", - "INVALID_CREDENTIALS", - ); - } - - // Generate JWT access token - const now = Math.floor(Date.now() / 1000); - const accessToken = await sign( - { - sub: user.id, - iat: now, - exp: now + ACCESS_TOKEN_EXPIRES_IN, - }, - JWT_SECRET, - ); - - // Generate refresh token - const refreshToken = generateRefreshToken(); - const tokenHash = hashToken(refreshToken); - const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); - - // Store refresh token in database - await db.insert(refreshTokens).values({ - userId: user.id, - tokenHash, - expiresAt, - }); + auth.post("/register", async (c) => { + const body = await c.req.json(); - return c.json({ - accessToken, - refreshToken, - user: { - id: user.id, - username: user.username, - }, + const parsed = createUserSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { username, password } = parsed.data; + + // Check if username already exists + const exists = await userRepo.existsByUsername(username); + if (exists) { + throw Errors.conflict("Username already exists", "USERNAME_EXISTS"); + } + + // Hash password with Argon2 + const passwordHash = await argon2.hash(password); + + // Create user + const newUser = await userRepo.create({ username, passwordHash }); + + return c.json({ user: newUser }, 201); }); -}); -auth.post("/refresh", async (c) => { - const body = await c.req.json(); - - const parsed = refreshTokenSchema.safeParse(body); - if (!parsed.success) { - throw Errors.validationError(parsed.error.issues[0]?.message); - } - - const { refreshToken } = parsed.data; - const tokenHash = hashToken(refreshToken); - - // Find valid refresh token - const [storedToken] = await db - .select({ - id: refreshTokens.id, - userId: refreshTokens.userId, - expiresAt: refreshTokens.expiresAt, - }) - .from(refreshTokens) - .where( - and( - eq(refreshTokens.tokenHash, tokenHash), - gt(refreshTokens.expiresAt, new Date()), - ), - ) - .limit(1); - - if (!storedToken) { - throw Errors.unauthorized( - "Invalid or expired refresh token", - "INVALID_REFRESH_TOKEN", + auth.post("/login", async (c) => { + const body = await c.req.json(); + + const parsed = loginSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { username, password } = parsed.data; + + // Find user by username + const user = await userRepo.findByUsername(username); + + if (!user) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } + + // Verify password + const isPasswordValid = await argon2.verify(user.passwordHash, password); + if (!isPasswordValid) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } + + // Generate JWT access token + const now = Math.floor(Date.now() / 1000); + const accessToken = await sign( + { + sub: user.id, + iat: now, + exp: now + ACCESS_TOKEN_EXPIRES_IN, + }, + JWT_SECRET, ); - } - - // Get user info - const [user] = await db - .select({ - id: users.id, - username: users.username, - }) - .from(users) - .where(eq(users.id, storedToken.userId)) - .limit(1); - - if (!user) { - throw Errors.unauthorized("User not found", "USER_NOT_FOUND"); - } - - // Delete old refresh token (rotation) - await db.delete(refreshTokens).where(eq(refreshTokens.id, storedToken.id)); - - // Generate new access token - const now = Math.floor(Date.now() / 1000); - const accessToken = await sign( - { - sub: user.id, - iat: now, - exp: now + ACCESS_TOKEN_EXPIRES_IN, - }, - JWT_SECRET, - ); - - // Generate new refresh token (rotation) - const newRefreshToken = generateRefreshToken(); - const newTokenHash = hashToken(newRefreshToken); - const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); - - await db.insert(refreshTokens).values({ - userId: user.id, - tokenHash: newTokenHash, - expiresAt, + + // Generate refresh token + const refreshToken = generateRefreshToken(); + const tokenHash = hashToken(refreshToken); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); + + // Store refresh token in database + await refreshTokenRepo.create({ + userId: user.id, + tokenHash, + expiresAt, + }); + + return c.json({ + accessToken, + refreshToken, + user: { + id: user.id, + username: user.username, + }, + }); }); - return c.json({ - accessToken, - refreshToken: newRefreshToken, - user: { - id: user.id, - username: user.username, - }, + auth.post("/refresh", async (c) => { + const body = await c.req.json(); + + const parsed = refreshTokenSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { refreshToken } = parsed.data; + const tokenHash = hashToken(refreshToken); + + // Find valid refresh token + const storedToken = await refreshTokenRepo.findValidToken(tokenHash); + + if (!storedToken) { + throw Errors.unauthorized( + "Invalid or expired refresh token", + "INVALID_REFRESH_TOKEN", + ); + } + + // Get user info + const user = await userRepo.findById(storedToken.userId); + + if (!user) { + throw Errors.unauthorized("User not found", "USER_NOT_FOUND"); + } + + // Delete old refresh token (rotation) + await refreshTokenRepo.deleteById(storedToken.id); + + // Generate new access token + const now = Math.floor(Date.now() / 1000); + const accessToken = await sign( + { + sub: user.id, + iat: now, + exp: now + ACCESS_TOKEN_EXPIRES_IN, + }, + JWT_SECRET, + ); + + // Generate new refresh token (rotation) + const newRefreshToken = generateRefreshToken(); + const newTokenHash = hashToken(newRefreshToken); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); + + await refreshTokenRepo.create({ + userId: user.id, + tokenHash: newTokenHash, + expiresAt, + }); + + return c.json({ + accessToken, + refreshToken: newRefreshToken, + user: { + id: user.id, + username: user.username, + }, + }); }); -}); -export { auth }; + return auth; +} + +// Default auth router with real repositories for production use +export const auth = createAuthRouter({ + userRepo: userRepository, + refreshTokenRepo: refreshTokenRepository, +}); |
