esp32blockly/server/auth.js

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;