110 lines
3.6 KiB
JavaScript
110 lines
3.6 KiB
JavaScript
import { Router } from 'express';
|
|
import bcrypt from 'bcryptjs';
|
|
import { randomBytes } from 'node:crypto';
|
|
import {
|
|
findUserByUsername,
|
|
findUserById,
|
|
createUser,
|
|
createSession,
|
|
findSession,
|
|
deleteSession,
|
|
} from './db.js';
|
|
|
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
const BCRYPT_ROUNDS = 8;
|
|
|
|
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,64}$/;
|
|
|
|
function newToken() {
|
|
return randomBytes(24).toString('hex');
|
|
}
|
|
|
|
function sanitizeUser(u) {
|
|
return { id: u.id, username: u.username, role: u.role };
|
|
}
|
|
|
|
export async function validateToken(token) {
|
|
if (!token || typeof token !== 'string') return null;
|
|
const s = await findSession(token);
|
|
if (!s) return null;
|
|
return { id: s.user_id, username: s.username, role: s.role };
|
|
}
|
|
|
|
export function requireAuth(req, res, next) {
|
|
const h = req.get('authorization') || '';
|
|
const m = /^Bearer\s+(.+)$/i.exec(h);
|
|
if (!m) return res.status(401).json({ error: 'Not authenticated' });
|
|
validateToken(m[1])
|
|
.then((user) => {
|
|
if (!user) return res.status(401).json({ error: 'Invalid or expired session' });
|
|
req.user = user;
|
|
req.token = m[1];
|
|
next();
|
|
})
|
|
.catch((err) => res.status(500).json({ error: err.message }));
|
|
}
|
|
|
|
export function requireTeacher(req, res, next) {
|
|
requireAuth(req, res, () => {
|
|
if (req.user.role !== 'teacher') return res.status(403).json({ error: 'Teacher only' });
|
|
next();
|
|
});
|
|
}
|
|
|
|
const router = Router();
|
|
|
|
router.post('/register', async (req, res) => {
|
|
const { username, password } = req.body || {};
|
|
if (!username || !password) return res.status(400).json({ error: 'username and password required' });
|
|
if (!USERNAME_RE.test(username)) return res.status(400).json({ error: 'username must be 3-64 chars, letters/digits/._-' });
|
|
if (password.length < 4) return res.status(400).json({ error: 'password must be at least 4 characters' });
|
|
|
|
try {
|
|
const existing = await findUserByUsername(username);
|
|
if (existing) return res.status(409).json({ error: 'username already taken' });
|
|
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
const user = await createUser({ username, passwordHash: hash, role: 'student' });
|
|
const token = newToken();
|
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
|
await createSession({ token, userId: user.id, expiresAt });
|
|
res.json({ token, user: sanitizeUser(user) });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/login', async (req, res) => {
|
|
const { username, password } = req.body || {};
|
|
if (!username || !password) return res.status(400).json({ error: 'username and password required' });
|
|
|
|
try {
|
|
const user = await findUserByUsername(username);
|
|
if (!user) return res.status(401).json({ error: 'Invalid username or password' });
|
|
const ok = await bcrypt.compare(password, user.password_hash);
|
|
if (!ok) return res.status(401).json({ error: 'Invalid username or password' });
|
|
const token = newToken();
|
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
|
await createSession({ token, userId: user.id, expiresAt });
|
|
res.json({ token, user: sanitizeUser(user) });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/logout', async (req, res) => {
|
|
const h = req.get('authorization') || '';
|
|
const m = /^Bearer\s+(.+)$/i.exec(h);
|
|
if (m) {
|
|
try { await deleteSession(m[1]); } catch { /* ignore */ }
|
|
}
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.get('/me', requireAuth, async (req, res) => {
|
|
const fresh = await findUserById(req.user.id);
|
|
if (!fresh) return res.status(401).json({ error: 'User no longer exists' });
|
|
res.json({ user: sanitizeUser(fresh) });
|
|
});
|
|
|
|
export default router;
|