diff --git a/AUTH_INPUT_POLICY.md b/AUTH_INPUT_POLICY.md new file mode 100644 index 0000000..c4775ef --- /dev/null +++ b/AUTH_INPUT_POLICY.md @@ -0,0 +1,51 @@ + # Registration and Login Input Policy + + This policy is for the website agent and describes the input rules enforced by + the API. The agent must validate inputs before submitting them and must never + silently change a user’s password. + + ## Registration + + Registration accepts these required fields: + + | Field | Rules | + | --- | --- | + | `name` | Required; 1–100 characters after trimming. | + | `username` | Required; 1–50 characters after trimming; must not contain whitespace. | + | `email` | Required; 1–254 characters after trimming; converted to lowercase; must not contain whitespace; must match a standard `name@domain.tld`-style format. | + | `password` | Required; 1–128 characters; whitespace is preserved. | + + The registration password must also: + + - Be at least 12 characters long. + - Contain at least one lowercase letter, one uppercase letter, one number, and one special character. + - Not contain the username or the email’s local part. + - Not be an obvious common password, including `password`, `password123`, `qwerty`, `letmein`, `welcome`, `admin`, `iloveyou`, `monkey`, `dragon`, or `abc123`. + - Not contain three identical characters in a row. + - Not contain obvious sequential patterns such as `1234`, `2345`, `6789`, or `abcd`. + - Not belong to a disposable or temporary email address. + + ## Login + + Login accepts only `email` and `password`: + + - Both fields are required and may contain 1–254 characters for email and 1–128 characters for password. + - Email is trimmed, converted to lowercase, and must not contain whitespace. + - Email must pass the same `name@domain.tld`-style format check used during registration. + - Password whitespace is preserved; the password must be submitted exactly as entered during registration. + - Registration-only password complexity checks and disposable-email checks are not repeated during login. + + The agent must not trim, lowercase, or otherwise transform the password. The + API removes control characters from input values; ordinary password whitespace + is intentionally retained. + + ## Invalid input and authentication failures + + - Do not submit a form until all applicable client-side rules pass. + - If the API returns HTTP `400`, show the validation message and let the user correct the input. + - If login returns HTTP `401`, show a generic “Invalid email or password” message. Do not reveal whether the email exists. + - Never log or expose passwords, password hashes, salts, or authentication tokens. + - Unexpected server failures must be shown as a generic failure message and retried only when appropriate; do not expose database or stack-trace details. + + The API remains the authoritative validator. Client-side validation improves the + user experience but must not replace server-side validation. diff --git a/app.js b/app.js index a04391b..73bf791 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,16 @@ const express = require('express'); +const cors = require('cors'); const app = express(); const dotenv = require('dotenv'); require('dotenv').config(); + +const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS || '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + +// Apply configured CORS origins to every endpoint, including preflight requests. +app.use(cors({ origin: allowedOrigins })); app.use(express.json()); // Data routes @@ -14,9 +23,11 @@ app.use('/post_data', postDataRoute); app.use('/delete_data', deleteDataRoute); // People routes -const CreatePersonRoute = require('./features/people/CREATE_people'); +const CreatePersonRoute = require('./features/people/REGISTER_people'); +const LoginPersonRoute = require('./features/people/LOGIN_people'); -app.use('/create_person', CreatePersonRoute); +app.use('/auth/register', CreatePersonRoute); +app.use('/auth/login', LoginPersonRoute); // Relation routes const CreateRelationRoute = require('./features/relations/CREATE_friendship'); @@ -26,4 +37,4 @@ app.use('/create_relationship', CreateRelationRoute) app.listen(process.env.PORT, () => { console.log(`Server draait op port ${process.env.PORT}`) -}) \ No newline at end of file +}) diff --git a/features/people/CREATE_people.js b/features/people/CREATE_people.js deleted file mode 100644 index 691f0d9..0000000 --- a/features/people/CREATE_people.js +++ /dev/null @@ -1,43 +0,0 @@ -const express = require('express'); -const router = express.Router(); -var driver = require('../../graph_db'); -const pool = require('../../db'); - - - -async function create_people(req, res) { - const { name, age, gender } = req.body; - try { - const { records } = await driver.executeQuery('Create (p:Person {name: $name, age: $age, gender: $gender})', { name: name, age: age, gender: gender }, { database: process.env.GRAPH_DB_NAME}); - } - catch (err) { - console.error('database query failed', { - message: err.message, - code: err.code, - detail: err.detail, - hint: err.hint, - stack: err.stack, - }); - res.status(500).send('Data was not pushed to graph database, request failed'); - } - try { - const { rows } = await pool.query('INSERT INTO people (name, age, gender) VALUES ($1, $2, $3)', [name, age, gender]); - console.log(rows[0]); - res.status(200).send('Data has been pushed to both databases'); - res.json(rows[0]); - } - catch (err) { - console.error('database query failed', { - message: err.message, - code: err.code, - detail: err.detail, - hint: err.hint, - stack: err.stack, - }); - res.status(500).send('Data was not pushed to database, request failed'); - } -}; - - -router.post('/', create_people); -module.exports = router; \ No newline at end of file diff --git a/features/people/LOGIN_people.js b/features/people/LOGIN_people.js new file mode 100644 index 0000000..ff9768b --- /dev/null +++ b/features/people/LOGIN_people.js @@ -0,0 +1,44 @@ +const express = require('express'); +const router = express.Router(); +const pool = require('../../db'); +const crypto = require('crypto'); +const { promisify } = require('util'); +const { sanitizeLoginInput } = require('../../middleware/sanitize_person_input'); + +const scrypt = promisify(crypto.scrypt); + +async function login(req, res) { + try { + const { email, password } = req.loginInput; + const { rows } = await pool.query( + 'SELECT id, password FROM people WHERE email = $1', + [email] + ); + + if (rows.length === 0) { + res.status(401).send('Invalid email or password'); + return; + } + + const [salt, storedKeyHex] = rows[0].password.split(':'); + const storedKey = Buffer.from(storedKeyHex || '', 'hex'); + const derivedKey = await scrypt(password, salt, 64); + + if ( + storedKey.length !== derivedKey.length || + !crypto.timingSafeEqual(storedKey, derivedKey) + ) { + res.status(401).send('Invalid email or password'); + return; + } + + res.status(200).json({ id: rows[0].id }); + } + catch (err) { + console.error('login failed', { message: err.message, code: err.code }); + res.status(500).send('Login failed, request could not be completed'); + } +}; + +router.post('/', sanitizeLoginInput, login); +module.exports = router; diff --git a/features/people/REGISTER_people.js b/features/people/REGISTER_people.js new file mode 100644 index 0000000..e04e16b --- /dev/null +++ b/features/people/REGISTER_people.js @@ -0,0 +1,88 @@ +const express = require('express'); +const router = express.Router(); +var driver = require('../../graph_db'); +const pool = require('../../db'); +var crypto = require('crypto'); +const { promisify } = require('util'); +const { sanitizePersonInput } = require('../../middleware/sanitize_person_input'); + +const scrypt = promisify(crypto.scrypt); + +async function insert_db( name, username, email, password ) { + const salt = crypto.randomBytes(16).toString('hex'); + const derivedKey = await scrypt(password, salt, 64); + const hashed_password = `${salt}:${derivedKey.toString('hex')}`; + + try { + const { rows } = await pool.query( + 'INSERT INTO people (name, username, email, password) VALUES ($1, $2, $3, $4) RETURNING id', + [name, username, email, hashed_password] + ); + + return rows[0].id; + } + catch (err) { + console.error('database query failed', { + message: err.message, + code: err.code, + detail: err.detail, + hint: err.hint, + stack: err.stack, + }); + throw err; + } +}; + +async function insert_graph_db( name, username, db_id ) { + try { + await driver.executeQuery( + 'CREATE (p:Person {db_id: $db_id, name: $name, username: $username})', + { db_id, name, username }, + { database: process.env.GRAPH_DB_NAME } + ); + } + catch (err) { + console.error('database query failed', { + message: err.message, + code: err.code, + detail: err.detail, + hint: err.hint, + stack: err.stack, + }); + throw err; + } +}; + + + + + +async function create_people(req, res) { + const person = req.person; + let db_id; + + try { + db_id = await insert_db( + person.name, + person.username, + person.email, + person.password + ); + } + catch (err) { + res.status(500).send('Data was not pushed to database, request failed'); + return; + } + + try { + await insert_graph_db(person.name, person.username, db_id); + res.status(201).json({ id: db_id }); + } + catch (err) { + res.status(500).send('Data was not pushed to graph database, request failed'); + } +}; + + +router.post('/', sanitizePersonInput, create_people); +module.exports = router; diff --git a/middleware/sanitize_person_input.js b/middleware/sanitize_person_input.js new file mode 100644 index 0000000..fbffd81 --- /dev/null +++ b/middleware/sanitize_person_input.js @@ -0,0 +1,123 @@ +const { isDisposableEmail } = require('fakeout'); + +const LIMITS = { + name: 100, + username: 50, + email: 254, + password: 128, +}; + +const MIN_PASSWORD_LENGTH = 12; +const COMMON_PASSWORDS = new Set([ + '123456789', 'password', 'password123', 'qwerty', + 'qwertyuiop', 'letmein', 'welcome', 'admin', + 'iloveyou', 'monkey', 'dragon', 'abc123', +]); + +function cleanText(value, { trim = true } = {}) { + if (typeof value !== 'string') { + return ''; + } + + const cleaned = value.replace(/[\u0000-\u001F\u007F]/g, ''); + return trim ? cleaned.trim() : cleaned; +} + +function cleanPersonInput(input) { + return { + name: cleanText(input.name), + username: cleanText(input.username), + email: cleanText(input.email).toLowerCase(), + // Password whitespace is preserved, but control characters are removed. + password: cleanText(input.password, { trim: false }), + }; +} + +function validateCommonFields(values, { requireName = true } = {}) { + const fields = requireName + ? ['name', 'username', 'email', 'password'] + : ['email', 'password']; + + for (const field of fields) { + if (!values[field] || values[field].length > LIMITS[field]) { + throw new Error(`${field} must be between 1 and ${LIMITS[field]} characters`); + } + } + + if (/\s/.test(values.username)) { + throw new Error('username must not contain spaces'); + } + + if (/\s/.test(values.email)) { + throw new Error('email must not contain spaces'); + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) { + throw new Error('email must be a valid email address'); + } +} + +function validateRegistrationInput(values) { + validateCommonFields(values); + + if (values.password.length < MIN_PASSWORD_LENGTH) { + throw new Error(`password must be at least ${MIN_PASSWORD_LENGTH} characters`); + } + + const passwordLower = values.password.toLowerCase(); + const normalizedPassword = passwordLower.replace(/[^a-z0-9]/g, ''); + const emailLocalPart = values.email.split('@')[0]; + if ( + COMMON_PASSWORDS.has(normalizedPassword) || + passwordLower.includes(values.username.toLowerCase()) || + passwordLower.includes(emailLocalPart) + ) { + throw new Error('password is too common or contains account details'); + } + + if (!/[a-z]/.test(values.password) || !/[A-Z]/.test(values.password)) { + throw new Error('password must contain lowercase and uppercase letters'); + } + + if (!/[0-9]/.test(values.password)) { + throw new Error('password must contain a number'); + } + + if (!/[^a-zA-Z0-9\s]/.test(values.password)) { + throw new Error('password must contain a special character'); + } + + if (/(.)\1\1/.test(values.password) || /(0123|1234|2345|3456|4567|5678|6789|abcd|bcde|cdef)/i.test(values.password)) { + throw new Error('password must not contain obvious repeated or sequential characters'); + } + + if (isDisposableEmail(values.email)) { + throw new Error('temporary email addresses are not allowed'); + } +} + +function sanitizePersonInput(req, res, next) { + try { + const values = cleanPersonInput(req.body || {}); + validateRegistrationInput(values); + req.person = values; + next(); + } + catch (err) { + res.status(400).send(err.message); + } +} + +function sanitizeLoginInput(req, res, next) { + try { + const values = cleanPersonInput(req.body || {}); + validateCommonFields(values, { requireName: false }); + req.loginInput = { email: values.email, password: values.password }; + next(); + } + catch (err) { + res.status(400).send(err.message); + } +} + +module.exports = { sanitizePersonInput, sanitizeLoginInput }; diff --git a/package-lock.json b/package-lock.json index dde037a..b35816d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "fakeout": "^1.0.65", "neo4j-driver": "^6.2.0", "pg": "^8.22.0", "router": "^2.2.0" @@ -279,6 +281,23 @@ "node": ">=6.6.0" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -434,6 +453,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fakeout": { + "version": "1.0.65", + "resolved": "https://registry.npmjs.org/fakeout/-/fakeout-1.0.65.tgz", + "integrity": "sha512-VaHtvOaBuSpetcCU243ex4bMII2SLiTq9FS6NyD23k0doFRIM0v3+a8FtrMlQf9s8XsXkMUClt6Oc5N9V73rSQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -892,6 +920,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index e46d1f6..ce1edf5 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,10 @@ "license": "ISC", "type": "commonjs", "dependencies": { + "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "fakeout": "^1.0.65", "neo4j-driver": "^6.2.0", "pg": "^8.22.0", "router": "^2.2.0"