Add CORS and move registration policies to middleware
This commit is contained in:
@@ -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.
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
Generated
+37
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user