feat: registration and login with JWT auth

- users table: email, password_hash (bcrypt), role, is_active
- POST /auth/register — checks blocklist, hashes password, returns JWT
- POST /auth/login — verifies password, returns JWT
- Auth middleware: accepts env tokens (dev) OR valid JWTs
- end-user role → 403 Insufficient permissions on all /api/* routes
- JWT_SECRET + JWT_EXPIRES_IN env vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 13:04:17 +02:00
parent 5f79e76b67
commit 217aab7dcd
6 changed files with 246 additions and 6 deletions

View File

@@ -335,6 +335,29 @@ async function migrate() {
await query(`CREATE INDEX IF NOT EXISTS blocklist_phone_idx ON blocklist (phone) WHERE phone IS NOT NULL`);
await query(`CREATE INDEX IF NOT EXISTS blocklist_ip_idx ON blocklist (ip) WHERE ip IS NOT NULL`);
// users
await query(`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'end-user'
CHECK (role IN ('end-user', 'admin')),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await query(`CREATE UNIQUE INDEX IF NOT EXISTS users_email_idx ON users (lower(email))`);
await query(`
DROP TRIGGER IF EXISTS users_updated_at ON users;
CREATE TRIGGER users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
`);
console.log('Migration complete');
}

View File

@@ -22,6 +22,9 @@ app.get('/health', async (req, res) => {
res.json({ status: 'ok', db });
});
// Public routes
app.use('/auth', require('./routes/auth'));
// Routes — protected by Bearer token
app.use('/api', auth, require('./routes/index'));
app.use('/api/pictures', auth, require('./routes/pictures'));

View File

@@ -1,12 +1,24 @@
const TOKENS = (process.env.API_TOKENS || '').split(',').map(t => t.trim()).filter(Boolean);
const jwt = require('jsonwebtoken');
const ENV_TOKENS = (process.env.API_TOKENS || '').split(',').map(t => t.trim()).filter(Boolean);
module.exports = function auth(req, res, next) {
const header = req.headers['authorization'] || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token || !TOKENS.includes(token)) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (!token) return res.status(401).json({ error: 'Unauthorized' });
next();
// Dev/admin tokens aus Env — keine Rollenprüfung
if (ENV_TOKENS.includes(token)) return next();
// JWT verifizieren
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
if (payload.role === 'end-user')
return res.status(403).json({ error: 'Insufficient permissions' });
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};

84
src/routes/auth.js Normal file
View File

@@ -0,0 +1,84 @@
const router = require('express').Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { query } = require('../db');
function signToken(user) {
return jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
}
// POST /auth/register
router.post('/register', async (req, res, next) => {
try {
const { email, password } = req.body;
if (!email || !password)
return res.status(400).json({ error: 'email and password are required' });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
return res.status(400).json({ error: 'Invalid email format' });
if (password.length < 8)
return res.status(400).json({ error: 'Password must be at least 8 characters' });
const normalizedEmail = email.toLowerCase().trim();
// Blocklist prüfen
const blockCheck = await query(
`SELECT id FROM blocklist WHERE is_blocked = true AND lower(email) = $1 LIMIT 1`,
[normalizedEmail]
);
if (blockCheck.rows.length)
return res.status(403).json({ error: 'Registration not allowed' });
// Bereits registriert?
const existing = await query(
`SELECT id FROM users WHERE lower(email) = $1`, [normalizedEmail]
);
if (existing.rows.length)
return res.status(409).json({ error: 'Email already registered' });
const password_hash = await bcrypt.hash(password, 12);
const result = await query(
`INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email, role`,
[normalizedEmail, password_hash]
);
const user = result.rows[0];
res.status(201).json({ user, token: signToken(user) });
} catch (err) { next(err); }
});
// POST /auth/login
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body;
if (!email || !password)
return res.status(400).json({ error: 'email and password are required' });
const result = await query(
`SELECT id, email, role, password_hash, is_active FROM users WHERE lower(email) = $1`,
[email.toLowerCase().trim()]
);
if (!result.rows.length)
return res.status(401).json({ error: 'Invalid credentials' });
const user = result.rows[0];
if (!user.is_active)
return res.status(403).json({ error: 'Account deactivated' });
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid)
return res.status(401).json({ error: 'Invalid credentials' });
const { password_hash: _, is_active: __, ...safeUser } = user;
res.json({ user: safeUser, token: signToken(safeUser) });
} catch (err) { next(err); }
});
module.exports = router;