Spaces:
Runtime error
Runtime error
| import { Router } from 'express'; | |
| import { z } from 'zod'; | |
| import { userCount, createUser, verifyCredentials, createSession, validateSession, deleteSession, } from '../services/auth.js'; | |
| export const authRouter = Router(); | |
| // Dashboard auth (#35). These routes are mounted BEFORE requireAuth, so | |
| // /status, /setup and /login are reachable without a session (bootstrap); | |
| // /logout and /me validate the token themselves. | |
| const credentialsSchema = z.object({ | |
| email: z.string().email('A valid email is required'), | |
| password: z.string().min(8, 'Password must be at least 8 characters'), | |
| }); | |
| // ββ Brute-force throttle ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Simple in-memory per-email limiter. A local single-user tool doesn't need a | |
| // distributed store; this just blunts online password guessing. | |
| const MAX_ATTEMPTS = 5; | |
| const LOCKOUT_MS = 15 * 60 * 1000; | |
| const attempts = new Map(); | |
| function isLockedOut(email) { | |
| const a = attempts.get(email.toLowerCase()); | |
| return !!a && a.lockedUntil > Date.now(); | |
| } | |
| function recordFailure(email) { | |
| const key = email.toLowerCase(); | |
| const a = attempts.get(key) ?? { count: 0, lockedUntil: 0 }; | |
| a.count++; | |
| if (a.count >= MAX_ATTEMPTS) { | |
| a.lockedUntil = Date.now() + LOCKOUT_MS; | |
| a.count = 0; | |
| } | |
| attempts.set(key, a); | |
| } | |
| function clearFailures(email) { | |
| attempts.delete(email.toLowerCase()); | |
| } | |
| function bearer(req) { | |
| return req.headers.authorization?.replace(/^Bearer\s+/i, '') | |
| ?? req.headers['x-dashboard-token']; | |
| } | |
| // Has the dashboard been set up yet, and is this caller authenticated? | |
| authRouter.get('/status', (req, res) => { | |
| const session = validateSession(bearer(req)); | |
| res.json({ | |
| needsSetup: userCount() === 0, | |
| authenticated: !!session, | |
| email: session?.email ?? null, | |
| }); | |
| }); | |
| // First-run account creation. Only allowed while there are zero users, so it | |
| // can't be used to add accounts once the dashboard is claimed. | |
| authRouter.post('/setup', (req, res) => { | |
| if (userCount() > 0) { | |
| res.status(409).json({ error: { message: 'Setup already completed. Use login instead.', type: 'setup_complete' } }); | |
| return; | |
| } | |
| const parsed = credentialsSchema.safeParse(req.body); | |
| if (!parsed.success) { | |
| res.status(400).json({ error: { message: parsed.error.errors.map(e => e.message).join(', ') } }); | |
| return; | |
| } | |
| const user = createUser(parsed.data.email, parsed.data.password); | |
| const token = createSession(user.userId); | |
| res.status(201).json({ token, email: user.email }); | |
| }); | |
| authRouter.post('/login', (req, res) => { | |
| const parsed = credentialsSchema.safeParse(req.body); | |
| if (!parsed.success) { | |
| res.status(400).json({ error: { message: parsed.error.errors.map(e => e.message).join(', ') } }); | |
| return; | |
| } | |
| const { email, password } = parsed.data; | |
| if (isLockedOut(email)) { | |
| res.status(429).json({ error: { message: 'Too many failed attempts. Try again later.', type: 'rate_limit_error' } }); | |
| return; | |
| } | |
| const user = verifyCredentials(email, password); | |
| if (!user) { | |
| recordFailure(email); | |
| // Same message whether the email exists or not β don't leak which. | |
| res.status(401).json({ error: { message: 'Invalid email or password', type: 'authentication_error' } }); | |
| return; | |
| } | |
| clearFailures(email); | |
| const token = createSession(user.userId); | |
| res.json({ token, email: user.email }); | |
| }); | |
| authRouter.post('/logout', (req, res) => { | |
| deleteSession(bearer(req)); | |
| res.json({ success: true }); | |
| }); | |
| authRouter.get('/me', (req, res) => { | |
| const session = validateSession(bearer(req)); | |
| if (!session) { | |
| res.status(401).json({ error: { message: 'Authentication required', type: 'authentication_error' } }); | |
| return; | |
| } | |
| res.json({ email: session.email }); | |
| }); | |
| //# sourceMappingURL=auth.js.map |