Security hardening: invited user onboarding flow (C1–N7)
- C1: verify.php now filters license='owner' — invite tokens no longer accepted - C1: onboarding API rejects non-owner sessions - C2: Existing-user invite requires explicit acceptance via accept_invite.php - C2: New accept_invite.php page and API engine added - C2: inviteUser() generates token + expiry for existing users; resendInvite() handles active users - C3: session_regenerate_id(true) before writing invite session keys on both invite pages - C4: invited_onboarding API wraps activation in transaction with SELECT FOR UPDATE; rowCount check added; SQLSTATE 23000 caught cleanly - C5: inviteUser() and resendInvite() two-table writes wrapped in transactions - M2: removeUser() wrapped in transaction with FOR UPDATE; clears default_company on active user removal - M4: Logged-in user guard added to invited_onboarding.php and accept_invite.php - M5: manage_users.php uses $server_url instead of HTTP_HOST for invite URLs - M6: Username regex enforces 3-32 chars; reserved name blocklist added - N5: searchUsers() changed from LIKE fuzzy search to exact email match only - N7: resendInvite() rate-limited to once per 60s via invite_resent_at column - Schema: company_map_user gains invite_expires_at and invite_resent_at columns Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
1904fea84c
commit
b4b1f5cbec
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
/**
|
||||
* accept_invite.php — Accept a company invitation for an existing user.
|
||||
*
|
||||
* Called by accept_invite.php page AJAX after the user clicks Accept.
|
||||
* The user already has an active account; this just clears the invite_token
|
||||
* on their company_map_user row, making them a full member.
|
||||
*
|
||||
* Full flow:
|
||||
* 1. Session guard — rejects if accept_invite_token is missing.
|
||||
* 2. CSRF check.
|
||||
* 3. Re-validate token against DB (not expired, invite_token still set).
|
||||
* 4. Clear invite_token and invite_expires_at from company_map_user.
|
||||
* 5. Verify exactly one row was updated.
|
||||
* 6. Clear session keys.
|
||||
* 7. Return { success: 1 }.
|
||||
*/
|
||||
|
||||
require_once '../../../session.php';
|
||||
require_once '../../../config.php';
|
||||
require_once '../../../preset.php';
|
||||
define('UNAUTHENTICATED_ROUTE', true);
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$answer = ['success' => 0, 'message' => ''];
|
||||
|
||||
// ── Step 1: Session guard ─────────────────────────────────────────────────────
|
||||
if (empty($_SESSION['accept_invite_token'])) {
|
||||
$answer['message'] = 'Invalid session. Please use your invitation link.';
|
||||
http_response_code(403);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
$token = $_SESSION['accept_invite_token'];
|
||||
|
||||
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
|
||||
http_response_code(403);
|
||||
$answer['message'] = 'Invalid request.';
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// ── Step 3: Re-validate token ─────────────────────────────────────────────
|
||||
$sth = $pdo1->prepare(
|
||||
"SELECT map_id FROM company_map_user
|
||||
WHERE invite_token = :token
|
||||
AND invite_expires_at > NOW()
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':token' => $token]);
|
||||
if (!$sth->fetchColumn()) {
|
||||
$answer['message'] = 'Invitation has expired or already been used.';
|
||||
http_response_code(403);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Step 4–5: Activate membership ────────────────────────────────────────
|
||||
$stmt = $pdo1->prepare(
|
||||
"UPDATE company_map_user
|
||||
SET invite_token = NULL,
|
||||
invite_expires_at = NULL
|
||||
WHERE invite_token = :token"
|
||||
);
|
||||
$stmt->execute([':token' => $token]);
|
||||
|
||||
if ($stmt->rowCount() !== 1) {
|
||||
$answer['message'] = 'Invitation is no longer valid.';
|
||||
http_response_code(403);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Step 6: Clear session keys ────────────────────────────────────────────
|
||||
unset($_SESSION['accept_invite_token']);
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Invitation accepted.';
|
||||
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,17 @@ if (empty($_SESSION['onboarding_user_id'])) {
|
||||
|
||||
$user_id = (int)$_SESSION['onboarding_user_id'];
|
||||
|
||||
// ── Step 1b: License guard ────────────────────────────────────────────────────
|
||||
// Invited users (license='user') must use invited_onboarding.php, not this flow.
|
||||
// If somehow an invited user's session reaches here, reject immediately.
|
||||
$sth = $pdo1->prepare("SELECT license FROM user WHERE user_id = :uid LIMIT 1");
|
||||
$sth->execute([':uid' => $user_id]);
|
||||
if ($sth->fetchColumn() !== 'owner') {
|
||||
$answer['message'] = 'Invalid session.';
|
||||
http_response_code(403);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
|
||||
Reference in New Issue
Block a user