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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user