diff --git a/package-lock.json b/package-lock.json index 724fa5c..2f1d9a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,12 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1050.0", "@aws-sdk/lib-storage": "^3.1050.0", + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "express-validator": "^7.1.0", + "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", "pg": "^8.11.3", "uuid": "^14.0.0" @@ -740,6 +742,15 @@ ], "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -819,6 +830,12 @@ "ieee754": "^1.1.4" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1021,6 +1038,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1475,12 +1501,103 @@ "node": ">=0.12.0" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1984,7 +2101,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/package.json b/package.json index d7c4e3b..21a47b8 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,12 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1050.0", "@aws-sdk/lib-storage": "^3.1050.0", + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "express-validator": "^7.1.0", + "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", "pg": "^8.11.3", "uuid": "^14.0.0" diff --git a/src/db-migrate.js b/src/db-migrate.js index c0fe75d..732a109 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -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'); } diff --git a/src/index.js b/src/index.js index 9321c63..bc63937 100644 --- a/src/index.js +++ b/src/index.js @@ -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')); diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 4b804f2..a5dbd79 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -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' }); + } }; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..2f57ca0 --- /dev/null +++ b/src/routes/auth.js @@ -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;