Files
wms-app/app/login/api/engine/invited_onboarding.php
T
Thanakorn SandClaude Sonnet 4.6 b4b1f5cbec 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>
2026-05-26 10:18:40 +07:00

181 lines
7.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* invited_onboarding.php — Complete account setup for an invited user.
*
* Called by: invited_onboarding.php page AJAX after the user fills in
* their name, username, and password.
*
* The invited user was created with license='user', status='pending', and
* default_company = the inviting company. This endpoint activates the account
* so they can log in using the inviting company's SMTP for OTP delivery.
*
* Full flow:
* 1. Session guard — rejects if 'invited_user_id' is missing.
* 2. CSRF check.
* 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. 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';
require_once '../../../config.php';
require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/PasswordManager.php';
header('Content-Type: application/json; charset=utf-8');
$answer = ['success' => 0, 'message' => ''];
// ── Step 1: Session guard ─────────────────────────────────────────────────────
if (empty($_SESSION['invited_user_id']) || empty($_SESSION['invited_token'])) {
$answer['message'] = 'Invalid session. Please use your invitation link.';
http_response_code(403);
exit(json_encode($answer));
}
$user_id = (int)$_SESSION['invited_user_id'];
$token = $_SESSION['invited_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));
}
}
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
try {
// ── Step 4: Sanitise and validate input ───────────────────────────────────
// 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'] ?? '';
if (!$name || !$surname || !$username || !$password || !$confirm) {
throw new Exception('All fields are required.');
}
// ── 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.');
}
$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 ───────────────────────────────────
if ($password !== $confirm) {
throw new Exception('Passwords do not match.');
}
$pm = new PasswordManager($pdo1, $include_url);
$result = $pm->checkStrength($password, [$name, $surname, $username]);
if ($result['score'] < PasswordManager::MIN_SCORE) {
$msg = $result['warning'] ?: ($result['suggestions'][0] ?? 'Please choose a stronger password.');
throw new Exception('Password is too weak. ' . $msg);
}
$hashed = password_hash($password, PASSWORD_BCRYPT);
// ── 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
WHERE user_id = :uid"
);
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, 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']);
$answer['success'] = 1;
$answer['message'] = 'Account setup complete.';
} catch (Exception $e) {
if ($pdo1->inTransaction()) $pdo1->rollBack();
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));