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
+10 -3
View File
@@ -46,7 +46,8 @@ function run(PDO $pdo, string $sql, string $label): void {
$pdo->exec($sql);
ok($label);
} catch (PDOException $e) {
if (str_contains($e->getMessage(), 'already exists')) {
if (str_contains($e->getMessage(), 'already exists') ||
str_contains($e->getMessage(), 'Duplicate column name')) {
skip($label . ' (already exists)');
} else {
fail($label . ': ' . $e->getMessage());
@@ -148,14 +149,20 @@ CREATE TABLE IF NOT EXISTS `company_map_user` (
`user_id` int(11) DEFAULT NULL,
`role` varchar(15) NOT NULL DEFAULT 'user',
`app_access` varchar(15) NOT NULL DEFAULT 'wms',
`invite_token` varchar(64) DEFAULT NULL,
`created_at` datetime DEFAULT NULL,
`invite_token` varchar(64) DEFAULT NULL,
`invite_expires_at` datetime DEFAULT NULL,
`invite_resent_at` datetime DEFAULT NULL,
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`map_id`),
KEY `user_id` (`user_id`),
KEY `company_id` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'company_map_user');
// Column added for explicit-consent invite flow (existing users)
run($pdo, "ALTER TABLE `company_map_user` ADD COLUMN `invite_expires_at` DATETIME DEFAULT NULL AFTER `invite_token`", 'company_map_user.invite_expires_at');
run($pdo, "ALTER TABLE `company_map_user` ADD COLUMN `invite_resent_at` DATETIME DEFAULT NULL AFTER `invite_expires_at`", 'company_map_user.invite_resent_at');
run($pdo, "
CREATE TABLE IF NOT EXISTS `company_setting` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,