From b4b1f5cbecdac3b050d707c1656577681207d246 Mon Sep 17 00:00:00 2001 From: Thanakorn S Date: Tue, 26 May 2026 10:18:40 +0700 Subject: [PATCH] =?UTF-8?q?Security=20hardening:=20invited=20user=20onboar?= =?UTF-8?q?ding=20flow=20(C1=E2=80=93N7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/assets/utils/classes/UserManager.php | 194 +++++++++++++------- app/login/accept_invite.php | 158 ++++++++++++++++ app/login/api/engine/accept_invite.php | 90 +++++++++ app/login/api/engine/invited_onboarding.php | 137 ++++++++------ app/login/api/engine/onboarding.php | 11 ++ app/login/invited_onboarding.php | 40 +++- app/login/verify.php | 3 + app/setting/api/engine/manage_users.php | 79 ++++---- setup.php | 13 +- 9 files changed, 562 insertions(+), 163 deletions(-) create mode 100644 app/login/accept_invite.php create mode 100644 app/login/api/engine/accept_invite.php diff --git a/app/assets/utils/classes/UserManager.php b/app/assets/utils/classes/UserManager.php index 72bd38c..a4093b3 100644 --- a/app/assets/utils/classes/UserManager.php +++ b/app/assets/utils/classes/UserManager.php @@ -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; } } } diff --git a/app/login/accept_invite.php b/app/login/accept_invite.php new file mode 100644 index 0000000..522352d --- /dev/null +++ b/app/login/accept_invite.php @@ -0,0 +1,158 @@ + + +
+ +
+
+ +

Already Signed In

+

You are already signed in. Please sign out first before accepting an invitation.

+ Go to Dashboard +
+
+
+ + +prepare( + "SELECT m.map_id, m.role, m.invite_expires_at, + c.company_name, + u.email, u.name, u.surname + FROM company_map_user m + JOIN company_list c ON c.company_id = m.company_id + JOIN user u ON u.user_id = m.user_id + WHERE m.invite_token = :token + LIMIT 1" + ); + $sth->execute([':token' => $token]); + $row = $sth->fetch(PDO::FETCH_ASSOC); + + $invite_error = null; + if (!$row) { + $invite_error = 'invalid'; + } elseif ($row['invite_expires_at'] && strtotime($row['invite_expires_at']) <= time()) { + $invite_error = 'expired'; + } + + if ($invite_error) { + require '../include_header.php'; + $msg = $invite_error === 'expired' + ? ['icon' => 'ti-clock-x', 'title' => 'Invitation Expired', + 'body' => 'This invitation link has expired. Please contact the company administrator to resend your invitation.'] + : ['icon' => 'ti-user-x', 'title' => 'Invalid Invitation', + 'body' => 'This invitation link is invalid or has already been used.']; +?> + +
+ +
+
+ +

+

+ Back to Sign In +
+
+
+ + + + + + +
+ +
+ + + + +

You've been invited!

+

Accept the invitation to join .

+
+ +
+
+ +

+

You are invited as:

+

Account:

+
+
+ +
+ +
+ +
+ + + + + diff --git a/app/login/api/engine/accept_invite.php b/app/login/api/engine/accept_invite.php new file mode 100644 index 0000000..4a63c96 --- /dev/null +++ b/app/login/api/engine/accept_invite.php @@ -0,0 +1,90 @@ + 0, 'message' => '']; + +// ── Step 1: Session guard ───────────────────────────────────────────────────── +if (empty($_SESSION['accept_invite_token'])) { + $answer['message'] = 'Invalid session. Please use your invitation link.'; + http_response_code(403); + exit(json_encode($answer)); +} + +$token = $_SESSION['accept_invite_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)); + } +} + +try { + + // ── Step 3: Re-validate token ───────────────────────────────────────────── + $sth = $pdo1->prepare( + "SELECT map_id FROM company_map_user + WHERE invite_token = :token + AND invite_expires_at > NOW() + LIMIT 1" + ); + $sth->execute([':token' => $token]); + if (!$sth->fetchColumn()) { + $answer['message'] = 'Invitation has expired or already been used.'; + http_response_code(403); + exit(json_encode($answer)); + } + + // ── Step 4–5: Activate membership ──────────────────────────────────────── + $stmt = $pdo1->prepare( + "UPDATE company_map_user + SET invite_token = NULL, + invite_expires_at = NULL + WHERE invite_token = :token" + ); + $stmt->execute([':token' => $token]); + + if ($stmt->rowCount() !== 1) { + $answer['message'] = 'Invitation is no longer valid.'; + http_response_code(403); + exit(json_encode($answer)); + } + + // ── Step 6: Clear session keys ──────────────────────────────────────────── + unset($_SESSION['accept_invite_token']); + + $answer['success'] = 1; + $answer['message'] = 'Invitation accepted.'; + +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); + http_response_code(400); +} + +exit(json_encode($answer)); diff --git a/app/login/api/engine/invited_onboarding.php b/app/login/api/engine/invited_onboarding.php index ab262a4..d0889eb 100644 --- a/app/login/api/engine/invited_onboarding.php +++ b/app/login/api/engine/invited_onboarding.php @@ -12,15 +12,17 @@ * Full flow: * 1. Session guard — rejects if 'invited_user_id' is missing. * 2. CSRF check. - * 3. Re-validate token against DB (expiry + status='pending' + license='user'). - * 4. Validate and sanitise input fields. - * 5. Username format and uniqueness check. - * 6. Password match and strength check. - * 7. Hash password. + * 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. - * 9. UPDATE company_map_user: invite_token=NULL. - * 10. Return { success: 1 }. + * 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'; @@ -58,43 +60,26 @@ $data = json_decode($_POST['json'] ?? '{}', true) ?: []; try { - // ── Step 3: Re-validate token ───────────────────────────────────────────── - $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" - ); - $sth->execute([':uid' => $user_id, ':token' => $token]); - if (!$sth->fetchColumn()) { - $answer['message'] = 'Invitation has expired or already been used. Please request a new invitation.'; - http_response_code(403); - exit(json_encode($answer)); - } - // ── Step 4: Sanitise and validate input ─────────────────────────────────── - $name = trim($data['name'] ?? ''); - $surname = trim($data['surname'] ?? ''); + // 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'] ?? ''; + $password = $data['password'] ?? ''; + $confirm = $data['confirm_password'] ?? ''; if (!$name || !$surname || !$username || !$password || !$confirm) { throw new Exception('All fields are required.'); } - // ── Step 5: Username format and uniqueness ──────────────────────────────── - if (!preg_match('/^[a-z0-9_]+$/', $username)) { - throw new Exception('Username may only contain lowercase letters, numbers and underscores.'); + // ── 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.'); } - $sth = $pdo1->prepare("SELECT user_id FROM user WHERE username = :u AND user_id != :uid LIMIT 1"); - $sth->execute([':u' => $username, ':uid' => $user_id]); - if ($sth->fetchColumn()) { - throw new Exception('Username is already taken. Please choose another.'); + $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 ─────────────────────────────────── @@ -109,32 +94,77 @@ try { throw new Exception('Password is too weak. ' . $msg); } - // ── Step 7–8: Hash and activate account ────────────────────────────────── $hashed = password_hash($password, PASSWORD_BCRYPT); - $pdo1->prepare( + // ── 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 + SET name = :name, + surname = :surname, + username = :username, + password = :password, + status = 'active', + verify_token = NULL, + verify_expires_at = NULL WHERE user_id = :uid" - )->execute([ - ':name' => $name, - ':surname' => $surname, - ':username' => $username, - ':password' => $hashed, - ':uid' => $user_id, - ]); + ); + + 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 WHERE user_id = :uid" + "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']); @@ -142,6 +172,7 @@ try { $answer['message'] = 'Account setup complete.'; } catch (Exception $e) { + if ($pdo1->inTransaction()) $pdo1->rollBack(); $answer['message'] = $e->getMessage(); http_response_code(400); } diff --git a/app/login/api/engine/onboarding.php b/app/login/api/engine/onboarding.php index 84e4636..db7984a 100644 --- a/app/login/api/engine/onboarding.php +++ b/app/login/api/engine/onboarding.php @@ -68,6 +68,17 @@ if (empty($_SESSION['onboarding_user_id'])) { $user_id = (int)$_SESSION['onboarding_user_id']; +// ── Step 1b: License guard ──────────────────────────────────────────────────── +// Invited users (license='user') must use invited_onboarding.php, not this flow. +// If somehow an invited user's session reaches here, reject immediately. +$sth = $pdo1->prepare("SELECT license FROM user WHERE user_id = :uid LIMIT 1"); +$sth->execute([':uid' => $user_id]); +if ($sth->fetchColumn() !== 'owner') { + $answer['message'] = 'Invalid session.'; + http_response_code(403); + exit(json_encode($answer)); +} + // ── Step 2: CSRF check ──────────────────────────────────────────────────────── if ($_SERVER['REQUEST_METHOD'] === 'POST') { $csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; diff --git a/app/login/invited_onboarding.php b/app/login/invited_onboarding.php index 5b685b8..5a589b9 100644 --- a/app/login/invited_onboarding.php +++ b/app/login/invited_onboarding.php @@ -10,6 +10,34 @@ exit; } + // Reject if a user is already logged in — opening an invite link in an active + // session would bind a different account's identity into the current session. + if (!empty($_SESSION['login_company_id'])) { + require '../include_header.php'; +?> + +
+ +
+
+ +

Already Signed In

+

You are already signed in. Please sign out first before accepting an invitation.

+ Go to Dashboard +
+
+
+ + +prepare( "SELECT u.user_id, u.email, u.verify_expires_at, c.company_name @@ -65,12 +93,12 @@ exit; } + // Regenerate session ID before binding invite identity to prevent session fixation + session_regenerate_id(true); + $_SESSION['invited_user_id'] = (int)$row['user_id']; $_SESSION['invited_token'] = $token; - - if (empty($_SESSION['csrf_token'])) { - $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); - } + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $company_name = htmlspecialchars($row['company_name']); $invite_email = htmlspecialchars($row['email']); @@ -107,8 +135,8 @@
- -
Used to log in. Cannot be changed later.
+ +
3–32 characters. Used to log in. Cannot be changed later.
diff --git a/app/login/verify.php b/app/login/verify.php index 604787d..bcebaf5 100644 --- a/app/login/verify.php +++ b/app/login/verify.php @@ -15,10 +15,13 @@ } // ── Look up token ───────────────────────────────────────────── + // license='owner' guard: invited users also have verify_token set, but they + // must use invited_onboarding.php — never this flow. $sth = $pdo1->prepare(" SELECT user_id, name, status, verify_expires_at FROM user WHERE verify_token = :token + AND license = 'owner' LIMIT 1 "); $sth->execute([':token' => $token]); diff --git a/app/setting/api/engine/manage_users.php b/app/setting/api/engine/manage_users.php index 2683f11..11a8302 100644 --- a/app/setting/api/engine/manage_users.php +++ b/app/setting/api/engine/manage_users.php @@ -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:", - "", - "Accept Invitation", - "", - "Or copy and paste this link into your browser:", - "{$invite_url}", - "", - "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, + "", + "Accept Invitation", + "", + "Or copy and paste this link into your browser:", + "{$invite_url}", + "", + "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, "", "Accept Invitation", "", diff --git a/setup.php b/setup.php index c23ab6b..93a25fa 100644 --- a/setup.php +++ b/setup.php @@ -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,