Import source: backend/src/routes/auth

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:12:33 +03:30
parent d2ec380225
commit c352140d8f
+148
View File
@@ -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 };