Security hardening: invited user onboarding flow (C1–N7)

This commit is contained in:
Thanakorn S
2026-05-26 10:18:40 +07:00
parent 0815ae3292
commit 9e200d31fe
9 changed files with 562 additions and 163 deletions
+84 -53
View File
@@ -12,15 +12,17 @@
* Full flow:
* 1. Session guard — rejects if 'invited_user_id' is missing.
* 2. CSRF check.
* 3. Re-validate token against DB (expiry + status='pending' + license='user').
* 4. Validate and sanitise input fields.
* 5. Username format and uniqueness check.
* 6. Password match and strength check.
* 7. Hash password.
* 3. Validate and sanitise input fields (before acquiring DB locks).
* 4. Username format check.
* 5. Password match and strength check.
* 6. Hash password.
* 7. BEGIN TRANSACTION — SELECT FOR UPDATE to atomically re-validate token
* (expiry + status='pending' + license='user').
* 8. UPDATE user: name, surname, username, password, status='active',
* verify_token=NULL, verify_expires_at=NULL.
* 9. UPDATE company_map_user: invite_token=NULL.
* 10. Return { success: 1 }.
* verify_token=NULL, verify_expires_at=NULL. Catches SQLSTATE 23000
* (duplicate username). Checks rowCount()=1.
* 9. UPDATE company_map_user: invite_token=NULL, invite_expires_at=NULL.
* 10. COMMIT. Return { success: 1 }.
*/
require_once '../../../session.php';
@@ -58,43 +60,26 @@ $data = json_decode($_POST['json'] ?? '{}', true) ?: [];
try {
// ── Step 3: Re-validate token ─────────────────────────────────────────────
$sth = $pdo1->prepare(
"SELECT user_id FROM user
WHERE user_id = :uid
AND verify_token = :token
AND status = 'pending'
AND license = 'user'
AND verify_expires_at > NOW()
LIMIT 1"
);
$sth->execute([':uid' => $user_id, ':token' => $token]);
if (!$sth->fetchColumn()) {
$answer['message'] = 'Invitation has expired or already been used. Please request a new invitation.';
http_response_code(403);
exit(json_encode($answer));
}
// ── Step 4: Sanitise and validate input ───────────────────────────────────
$name = trim($data['name'] ?? '');
$surname = trim($data['surname'] ?? '');
// Done before the transaction so validation errors don't acquire DB locks.
$name = trim($data['name'] ?? '');
$surname = trim($data['surname'] ?? '');
$username = strtolower(trim($data['username'] ?? ''));
$password = $data['password'] ?? '';
$confirm = $data['confirm_password'] ?? '';
$password = $data['password'] ?? '';
$confirm = $data['confirm_password'] ?? '';
if (!$name || !$surname || !$username || !$password || !$confirm) {
throw new Exception('All fields are required.');
}
// ── Step 5: Username format and uniqueness ────────────────────────────────
if (!preg_match('/^[a-z0-9_]+$/', $username)) {
throw new Exception('Username may only contain lowercase letters, numbers and underscores.');
// ── Step 5: Username format, length, and reserved names ──────────────────
if (!preg_match('/^[a-z0-9_]{3,32}$/', $username)) {
throw new Exception('Username must be 3–32 characters and may only contain lowercase letters, numbers and underscores.');
}
$sth = $pdo1->prepare("SELECT user_id FROM user WHERE username = :u AND user_id != :uid LIMIT 1");
$sth->execute([':u' => $username, ':uid' => $user_id]);
if ($sth->fetchColumn()) {
throw new Exception('Username is already taken. Please choose another.');
$reserved = ['admin', 'owner', 'support', 'root', 'system', 'superuser', 'administrator'];
if (in_array($username, $reserved, true)) {
throw new Exception('That username is reserved. Please choose another.');
}
// ── Step 6: Password match and strength ───────────────────────────────────
@@ -109,32 +94,77 @@ try {
throw new Exception('Password is too weak. ' . $msg);
}
// ── Step 7–8: Hash and activate account ──────────────────────────────────
$hashed = password_hash($password, PASSWORD_BCRYPT);
$pdo1->prepare(
// ── Steps 3 + 7–9: Atomic token re-validation and activation ─────────────
// SELECT FOR UPDATE locks the row so a concurrent resendInvite or removeUser
// cannot mutate the token between our check and the UPDATE.
$pdo1->beginTransaction();
$sth = $pdo1->prepare(
"SELECT user_id FROM user
WHERE user_id = :uid
AND verify_token = :token
AND status = 'pending'
AND license = 'user'
AND verify_expires_at > NOW()
LIMIT 1
FOR UPDATE"
);
$sth->execute([':uid' => $user_id, ':token' => $token]);
if (!$sth->fetchColumn()) {
$pdo1->rollBack();
$answer['message'] = 'Invitation has expired or already been used. Please request a new invitation.';
http_response_code(403);
exit(json_encode($answer));
}
// ── Step 7–8: Activate account ────────────────────────────────────────────
$stmt = $pdo1->prepare(
"UPDATE user
SET name = :name,
surname = :surname,
username = :username,
password = :password,
status = 'active',
verify_token = NULL,
verify_expires_at = NULL
SET name = :name,
surname = :surname,
username = :username,
password = :password,
status = 'active',
verify_token = NULL,
verify_expires_at = NULL
WHERE user_id = :uid"
)->execute([
':name' => $name,
':surname' => $surname,
':username' => $username,
':password' => $hashed,
':uid' => $user_id,
]);
);
try {
$stmt->execute([
':name' => $name,
':surname' => $surname,
':username' => $username,
':password' => $hashed,
':uid' => $user_id,
]);
} catch (PDOException $e) {
$pdo1->rollBack();
// SQLSTATE 23000 = unique constraint violation (duplicate username)
if ($e->getCode() === '23000') {
throw new Exception('Username is already taken. Please choose another.');
}
throw $e;
}
if ($stmt->rowCount() !== 1) {
$pdo1->rollBack();
$answer['message'] = 'Invitation is no longer valid.';
http_response_code(403);
exit(json_encode($answer));
}
// ── Step 9: Clear invite token from company_map_user ─────────────────────
$pdo1->prepare(
"UPDATE company_map_user SET invite_token = NULL WHERE user_id = :uid"
"UPDATE company_map_user
SET invite_token = NULL, invite_expires_at = NULL
WHERE user_id = :uid"
)->execute([':uid' => $user_id]);
$pdo1->commit();
// ── Step 10: Clear session invite keys ────────────────────────────────────
unset($_SESSION['invited_user_id'], $_SESSION['invited_token']);
@@ -142,6 +172,7 @@ try {
$answer['message'] = 'Account setup complete.';
} catch (Exception $e) {
if ($pdo1->inTransaction()) $pdo1->rollBack();
$answer['message'] = $e->getMessage();
http_response_code(400);
}