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
+42 -37
View File
@@ -29,38 +29,41 @@
$result = $um->inviteUser($email, $role, $app_access);
if ($result['new_user']) {
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'] . rtrim($server_url, '/');
$invite_url = $base_url . '/login/invited_onboarding.php?token=' . $result['token'];
// Both new and existing users require explicit acceptance via email
$invite_url = rtrim($server_url, '/') . ($result['new_user']
? '/login/invited_onboarding.php?token=' . $result['token']
: '/login/accept_invite.php?token=' . $result['token']);
require_once '../../../assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => $company_id,
'to' => $result['email'],
'subject' => 'You have been invited to join the team',
'message' => implode("\n", [
"You have been invited to join the team.",
"",
"Click the button below to set up your account:",
"",
"<a href=\"{$invite_url}\" style=\"display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;\">Accept Invitation</a>",
"",
"Or copy and paste this link into your browser:",
"<a href=\"{$invite_url}\">{$invite_url}</a>",
"",
"This link will expire in 7 days.",
"",
"If you did not expect this invitation, you can ignore this email.",
]),
'channel_name' => 'WMS',
'key' => $pinkey,
]);
$answer['message'] = htmlspecialchars($result['email']) . ' has been invited. An email has been sent to complete their registration.';
} else {
$answer['message'] = htmlspecialchars($result['email']) . ' has been added to your company.';
}
$subject = $result['new_user']
? 'You have been invited to join the team'
: 'You have been invited to join a new company';
$body_intro = $result['new_user']
? 'You have been invited to join the team. Click the button below to set up your account:'
: 'You have been invited to join a new company. Click the button below to accept:';
require_once '../../../assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => $company_id,
'to' => $result['email'],
'subject' => $subject,
'message' => implode("\n", [
$body_intro,
"",
"<a href=\"{$invite_url}\" style=\"display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;\">Accept Invitation</a>",
"",
"Or copy and paste this link into your browser:",
"<a href=\"{$invite_url}\">{$invite_url}</a>",
"",
"This link will expire in 7 days.",
"",
"If you did not expect this invitation, you can ignore this email.",
]),
'channel_name' => 'WMS',
'key' => $pinkey,
]);
$answer['message'] = htmlspecialchars($result['email']) . ' has been invited. An email has been sent.';
$answer['success'] = 1;
@@ -85,9 +88,13 @@
$map_id = (int)($data['map_id'] ?? 0);
$result = $um->resendInvite($map_id);
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'] . rtrim($server_url, '/');
$invite_url = $base_url . '/login/invited_onboarding.php?token=' . $result['token'];
$invite_url = rtrim($server_url, '/') . ($result['is_new_user']
? '/login/invited_onboarding.php?token=' . $result['token']
: '/login/accept_invite.php?token=' . $result['token']);
$body_intro = $result['is_new_user']
? 'Your invitation link has been refreshed. Click below to set up your account:'
: 'Your invitation link has been refreshed. Click below to accept the invitation:';
require_once '../../../assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
@@ -96,9 +103,7 @@
'to' => $result['email'],
'subject' => 'Your invitation link has been resent',
'message' => implode("\n", [
"Your invitation link has been refreshed.",
"",
"Click the button below to set up your account:",
$body_intro,
"",
"<a href=\"{$invite_url}\" style=\"display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;\">Accept Invitation</a>",
"",