From c352140d8fa8cc6fff311f8a566ec5b92475a584 Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:12:33 +0330 Subject: [PATCH] Import source: backend/src/routes/auth Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality. --- backend/src/routes/auth.ts | 148 +++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 backend/src/routes/auth.ts diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..cc48924 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,148 @@ +import express from 'express'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { z } from 'zod'; +import { env } from '../config/env.js'; +import { HttpError } from '../middleware/errorHandler.js'; +import { store } from '../services/store.js'; +import type { UserRecord } from '../types.js'; +import { newUuid, randomId, sha256 } from '../utils/crypto.js'; +import { rateLimit } from '../middleware/rateLimit.js'; + +const router = express.Router(); + +const credentialsSchema = z.object({ + email: z.string().trim().email().toLowerCase(), + password: z.string().min(8).max(128), +}); + +function publicUser(user: UserRecord) { + return { + id: user.id, + email: user.email, + plan: user.plan, + verified: user.verified, + }; +} + +function signAccess(user: UserRecord) { + return jwt.sign( + { sub: user.id, email: user.email, plan: user.plan, jti: newUuid() }, + env.jwtAccessSecret, + { expiresIn: '15m' }, + ); +} + +async function issueRefreshToken(userId: string) { + const raw = randomId(32); + const tokenHash = sha256(raw); + await store.update((db) => { + db.refreshTokens.push({ + id: newUuid(), + userId, + tokenHash, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + createdAt: new Date().toISOString(), + }); + }); + return raw; +} + +// Limit auth attempts to 5 per 15 minutes per IP +const authLimiter = rateLimit(5, 900000); + +router.post('/register', authLimiter, async (req, res) => { + const body = credentialsSchema.parse(req.body); + const existing = (await store.read()).users.find((user) => user.email === body.email); + + // Prevent username enumeration by returning a generic success payload + // without revealing if the email was already taken. + if (existing) { + // Generate a dummy user payload resembling a successful creation, + // but do not persist or sign access tokens. + const dummyUser: UserRecord = { + id: newUuid(), + email: body.email, + passwordHash: '', + plan: 'free', + verified: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const dummyToken = randomId(32); + res.cookie('refreshToken', dummyToken, { httpOnly: true, sameSite: 'lax', secure: env.nodeEnv === 'production' }); + res.status(201).json({ data: { user: publicUser(dummyUser), accessToken: signAccess(dummyUser) } }); + return; + } + + const now = new Date().toISOString(); + const user: UserRecord = { + id: newUuid(), + email: body.email, + passwordHash: await bcrypt.hash(body.password, env.bcryptRounds), + plan: 'free', + verified: false, + createdAt: now, + updatedAt: now, + }; + + await store.update((db) => { + db.users.push(user); + }); + + const refreshToken = await issueRefreshToken(user.id); + res.cookie('refreshToken', refreshToken, { httpOnly: true, sameSite: 'lax', secure: env.nodeEnv === 'production' }); + res.status(201).json({ data: { user: publicUser(user), accessToken: signAccess(user) } }); +}); + +router.post('/login', authLimiter, async (req, res) => { + const body = credentialsSchema.parse(req.body); + const user = (await store.read()).users.find((row) => row.email === body.email); + if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) { + throw new HttpError(401, 'INVALID_CREDENTIALS', 'Invalid email or password'); + } + + const refreshToken = await issueRefreshToken(user.id); + res.cookie('refreshToken', refreshToken, { httpOnly: true, sameSite: 'lax', secure: env.nodeEnv === 'production' }); + res.json({ data: { user: publicUser(user), accessToken: signAccess(user) } }); +}); + +router.post('/refresh', authLimiter, async (req, res) => { + const raw = req.cookies?.refreshToken; + if (!raw) throw new HttpError(401, 'REFRESH_REQUIRED', 'Refresh token required'); + const tokenHash = sha256(raw); + const db = await store.read(); + const token = db.refreshTokens.find((row) => row.tokenHash === tokenHash && !row.revokedAt); + if (!token || new Date(token.expiresAt).getTime() <= Date.now()) { + throw new HttpError(401, 'INVALID_REFRESH', 'Refresh token is invalid'); + } + const user = db.users.find((row) => row.id === token.userId); + if (!user) throw new HttpError(401, 'INVALID_REFRESH', 'Refresh token is invalid'); + res.json({ data: { user: publicUser(user), accessToken: signAccess(user) } }); +}); + +router.post('/logout', async (req, res) => { + const raw = req.cookies?.refreshToken; + if (raw) { + const tokenHash = sha256(raw); + await store.update((db) => { + const token = db.refreshTokens.find((row) => row.tokenHash === tokenHash && !row.revokedAt); + if (token) token.revokedAt = new Date().toISOString(); + }); + } + res.clearCookie('refreshToken'); + res.status(204).send(); +}); + +router.get('/me', async (req, res) => { + const header = req.header('authorization') ?? ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : ''; + if (!token) throw new HttpError(401, 'TOKEN_REQUIRED', 'Access token required'); + const payload = jwt.verify(token, env.jwtAccessSecret) as jwt.JwtPayload; + const user = (await store.read()).users.find((row) => row.id === payload.sub); + if (!user) throw new HttpError(401, 'TOKEN_INVALID', 'Access token is invalid'); + res.json({ data: { user: publicUser(user) } }); +}); + +export { router as authRouter }; +