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:
Thanakorn S
2026-05-26 10:18:40 +07:00
co-authored by Claude Sonnet 4.6
parent 1904fea84c
commit b4b1f5cbec
9 changed files with 562 additions and 163 deletions
+130 -64
View File
@@ -180,19 +180,21 @@ class UserManager {
public function searchUsers(string $keyword): array {
if ($keyword === '') return [];
// Exact email match only — LIKE across the global user table leaks
// names and usernames of users belonging to other companies.
$sth = $this->pdo->prepare(
"SELECT u.user_id, u.username, u.name, u.surname, u.email
FROM user u
WHERE u.email LIKE :kw
WHERE u.email = :email
AND u.status = 'active'
AND u.user_id NOT IN (
SELECT user_id FROM company_map_user
WHERE company_id = :company_id
)
ORDER BY u.email ASC
LIMIT 10"
LIMIT 1"
);
$sth->execute([
':kw' => '%' . $keyword . '%',
':email' => $keyword,
':company_id' => $this->company_id,
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -234,26 +236,38 @@ class UserManager {
}
$sth = $this->pdo->prepare(
"SELECT map_id FROM company_map_user
"SELECT map_id, invite_token FROM company_map_user
WHERE company_id = :company_id AND user_id = :user_id
LIMIT 1"
);
$sth->execute([':company_id' => $this->company_id, ':user_id' => $target_user_id]);
if ($sth->fetch()) {
$existing = $sth->fetch(PDO::FETCH_ASSOC);
if ($existing) {
if ($existing['invite_token']) {
throw new Exception('An invitation is already pending for this user. Use Resend Invite to refresh it.');
}
throw new Exception('This user is already a member of your company.');
}
// Existing user must explicitly accept — generate token and send email
$invite_token = bin2hex(random_bytes(32));
$expires_at = date('Y-m-d H:i:s', strtotime('+7 days'));
$this->pdo->prepare(
"INSERT INTO company_map_user (company_id, user_id, role, app_access, created_at)
VALUES (:company_id, :user_id, :role, :app_access, NOW())"
"INSERT INTO company_map_user
(company_id, user_id, role, app_access, invite_token, invite_expires_at, created_at)
VALUES
(:company_id, :user_id, :role, :app_access, :token, :expires, NOW())"
)->execute([
':company_id' => $this->company_id,
':user_id' => $target_user_id,
':role' => $role,
':app_access' => $app_access,
':token' => $invite_token,
':expires' => $expires_at,
]);
return ['new_user' => false, 'email' => $target['email'], 'token' => null];
return ['new_user' => false, 'email' => $target['email'], 'token' => $invite_token];
}
// Email not in system — create a pending invited account
@@ -261,32 +275,43 @@ class UserManager {
$expires_at = date('Y-m-d H:i:s', strtotime('+7 days'));
$temp_username = 'invited_' . bin2hex(random_bytes(8));
$this->pdo->prepare(
"INSERT INTO user
(username, name, surname, email, password, status, license, default_company,
profile_picture, verify_token, verify_expires_at)
VALUES
(:username, '', '', :email, '', 'pending', 'user', :default_company,
'', :token, :expires)"
)->execute([
':username' => $temp_username,
':email' => $email,
':default_company' => $this->company_id,
':token' => $invite_token,
':expires' => $expires_at,
]);
$new_user_id = (int)$this->pdo->lastInsertId();
$this->pdo->beginTransaction();
try {
$this->pdo->prepare(
"INSERT INTO user
(username, name, surname, email, password, status, license, default_company,
profile_picture, verify_token, verify_expires_at)
VALUES
(:username, '', '', :email, '', 'pending', 'user', :default_company,
'', :token, :expires)"
)->execute([
':username' => $temp_username,
':email' => $email,
':default_company' => $this->company_id,
':token' => $invite_token,
':expires' => $expires_at,
]);
$new_user_id = (int)$this->pdo->lastInsertId();
$this->pdo->prepare(
"INSERT INTO company_map_user (company_id, user_id, role, app_access, invite_token, created_at)
VALUES (:company_id, :user_id, :role, :app_access, :token, NOW())"
)->execute([
':company_id' => $this->company_id,
':user_id' => $new_user_id,
':role' => $role,
':app_access' => $app_access,
':token' => $invite_token,
]);
$this->pdo->prepare(
"INSERT INTO company_map_user
(company_id, user_id, role, app_access, invite_token, invite_expires_at, created_at)
VALUES
(:company_id, :user_id, :role, :app_access, :token, :expires, NOW())"
)->execute([
':company_id' => $this->company_id,
':user_id' => $new_user_id,
':role' => $role,
':app_access' => $app_access,
':token' => $invite_token,
':expires' => $expires_at,
]);
$this->pdo->commit();
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
return ['new_user' => true, 'email' => $email, 'token' => $invite_token];
}
@@ -306,7 +331,7 @@ class UserManager {
if (!$map_id) throw new Exception('Invalid request.');
$sth = $this->pdo->prepare(
"SELECT u.user_id, u.email, u.status, u.license, m.invite_token
"SELECT u.user_id, u.email, u.status, u.license, m.invite_token, m.invite_resent_at
FROM company_map_user m
JOIN user u ON u.user_id = m.user_id
WHERE m.map_id = :map_id AND m.company_id = :company_id
@@ -315,25 +340,44 @@ class UserManager {
$sth->execute([':map_id' => $map_id, ':company_id' => $this->company_id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) throw new Exception('User not found.');
if ($row['license'] !== 'user') throw new Exception('Cannot resend invite to an owner account.');
if ($row['status'] !== 'pending') throw new Exception('User has already accepted the invitation.');
if (!$row['invite_token']) throw new Exception('No pending invitation found for this user.');
if (!$row) throw new Exception('User not found.');
if (!$row['invite_token']) throw new Exception('No pending invitation found for this user.');
$new_token = bin2hex(random_bytes(32));
$expires_at = date('Y-m-d H:i:s', strtotime('+7 days'));
// Rate limit — one resend per 60 seconds
if ($row['invite_resent_at'] &&
strtotime($row['invite_resent_at']) > time() - 60) {
throw new Exception('Please wait before resending the invitation.');
}
$this->pdo->prepare(
"UPDATE user SET verify_token = :token, verify_expires_at = :expires
WHERE user_id = :uid"
)->execute([':token' => $new_token, ':expires' => $expires_at, ':uid' => (int)$row['user_id']]);
$is_new_user = ($row['license'] === 'user' && $row['status'] === 'pending');
$new_token = bin2hex(random_bytes(32));
$expires_at = date('Y-m-d H:i:s', strtotime('+7 days'));
$this->pdo->prepare(
"UPDATE company_map_user SET invite_token = :token
WHERE map_id = :map_id AND company_id = :company_id"
)->execute([':token' => $new_token, ':map_id' => $map_id, ':company_id' => $this->company_id]);
$this->pdo->beginTransaction();
try {
// Brand-new pending accounts also need user.verify_token updated (used by invited_onboarding.php)
if ($is_new_user) {
$this->pdo->prepare(
"UPDATE user SET verify_token = :token, verify_expires_at = :expires
WHERE user_id = :uid"
)->execute([':token' => $new_token, ':expires' => $expires_at, ':uid' => (int)$row['user_id']]);
}
return ['email' => $row['email'], 'token' => $new_token];
$this->pdo->prepare(
"UPDATE company_map_user
SET invite_token = :token,
invite_expires_at = :expires,
invite_resent_at = NOW()
WHERE map_id = :map_id AND company_id = :company_id"
)->execute([':token' => $new_token, ':expires' => $expires_at, ':map_id' => $map_id, ':company_id' => $this->company_id]);
$this->pdo->commit();
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
return ['email' => $row['email'], 'token' => $new_token, 'is_new_user' => $is_new_user];
}
/**
@@ -437,21 +481,43 @@ class UserManager {
if ($row['role'] === 'owner') throw new Exception('The owner cannot be removed.');
if ((int)$row['user_id'] === $this->user_id) throw new Exception('You cannot remove yourself.');
$this->pdo->prepare(
"DELETE FROM company_map_user
WHERE map_id = :map_id AND company_id = :company_id"
)->execute([':map_id' => $map_id, ':company_id' => $this->company_id]);
$target_uid = (int)$row['user_id'];
// If this was a pending invited account that was never activated, delete
// the placeholder user row so the email is free for future invitations.
$sth = $this->pdo->prepare(
"SELECT license, status FROM user WHERE user_id = :uid LIMIT 1"
);
$sth->execute([':uid' => (int)$row['user_id']]);
$u = $sth->fetch(PDO::FETCH_ASSOC);
if ($u && $u['license'] === 'user' && $u['status'] === 'pending') {
$this->pdo->prepare("DELETE FROM user WHERE user_id = :uid")
->execute([':uid' => (int)$row['user_id']]);
$this->pdo->beginTransaction();
try {
// Lock the user row first — if invited_onboarding.php is activating this
// account at the same moment, one will wait rather than both proceeding
// with stale status data.
$sth = $this->pdo->prepare(
"SELECT license, status, default_company FROM user
WHERE user_id = :uid LIMIT 1 FOR UPDATE"
);
$sth->execute([':uid' => $target_uid]);
$u = $sth->fetch(PDO::FETCH_ASSOC);
$this->pdo->prepare(
"DELETE FROM company_map_user
WHERE map_id = :map_id AND company_id = :company_id"
)->execute([':map_id' => $map_id, ':company_id' => $this->company_id]);
if ($u) {
if ($u['license'] === 'user' && $u['status'] === 'pending') {
// Never activated — delete the placeholder row so the email is free
$this->pdo->prepare("DELETE FROM user WHERE user_id = :uid")
->execute([':uid' => $target_uid]);
} elseif ((int)$u['default_company'] === $this->company_id) {
// Active user removed from their default company — clear it so they
// are not left pointing at a company they no longer belong to
$this->pdo->prepare(
"UPDATE user SET default_company = 0 WHERE user_id = :uid"
)->execute([':uid' => $target_uid]);
}
}
$this->pdo->commit();
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
}