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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user