NAME auth - RSX authentication, user invitations, and multi-tenant architecture SYNOPSIS Modern authentication system with Slack-style invitations, multi-tenant support, and flexible session management for B2B SaaS applications DESCRIPTION The RSX authentication system provides a complete user management solution built around two fundamental concepts: 1. Login Identity (login_users table) - One email, one password, one identity - Global authentication credentials - Verified through invitation acceptance or signup 2. Site Membership (users table) - Multiple memberships per login identity - Each membership has its own role, name display - Invitation workflow manages membership lifecycle This separation enables: - One person can be a member of multiple organizations/sites - Each site membership has independent roles and profile data - Invitation-based onboarding with email verification - Works seamlessly for single-site or multi-tenant applications CORE ARCHITECTURE Database Tables: login_users id Login identity primary key email Authentication email (unique) password Hashed password is_activated Account activation status is_verified Email verification status last_login Last successful login timestamp created_at Account creation timestamp updated_at Last update timestamp users id Site membership primary key login_user_id FK to login_users (NULL until invite accepted) site_id FK to sites email Email for invitation (before login_user exists) first_name Display name (part 1) last_name Display name (part 2) phone Contact number role_id Site-specific role (1=OWNER, 2=ADMIN, 3=MEMBER) is_enabled Active membership flag invite_code Unique code for invitation URL invite_accepted_at NULL until user accepts (marks as active member) invite_expires_at Invitation expiration (default 7 days) created_at Invitation sent timestamp updated_at Last update timestamp deleted_at Soft delete timestamp sites id Site/tenant primary key name Organization name slug URL-safe identifier is_enabled Active site flag sessions session_token Cookie value (unique identifier) login_user_id FK to login_users (NULL for anonymous) site_id Current site context (NULL until selected) experience_id Authentication realm (default 0) active Session validity flag expires_at Session expiration timestamp updated_at Last activity timestamp Relationships: One login_user → Many users (site memberships) One site → Many users (members) Unique constraint: (login_user_id, site_id) Sessions reference login_user_id, not users.id Site selection sets session.site_id Key Principles: Invitation = Site Membership Record No separate invites table. The users record IS the invitation in a pending state (invite_accepted_at = NULL, login_user_id = NULL) Lazy Session Creation Sessions not created for every page view Only created when: login, Session::id() called, CSRF needed Anonymous browsing does not consume session resources Email Verification via Invitation Clicking invite link verifies email ownership New accounts created via invite are auto-verified No separate email verification flow needed EMAIL BINDING AND SECURITY Overview: RSX invitations are bound to specific email addresses for enterprise security. An invitation sent to john@company.com can ONLY be accepted by creating or logging into an account with that exact email address. Why Email Binding: This design reflects how enterprise SaaS applications work in practice: Security Model: - When IT admin invites "john@company.com", they expect THAT person to access the account - Cannot forward invite link to someone else to hijack access - Audit trail integrity: Actions tied to corporate identity - Compliance: User actions must be attributable to verified identity Deprovisioning: - When employee leaves and email is disabled, they're locked out - Works correctly with enterprise SSO and identity management - No backdoor access via personal email accounts Enterprise Control: - Users cannot escape corporate control by changing email - Admin controls which corporate identities have access - Prevents scenarios like changing from corporate@company.com to personal@gmail.com Email Matching Rules: 1. Invitation created with email = "user@company.com" 2. Invitation can be accepted by: - Creating NEW login account with email = "user@company.com" - Logging into EXISTING account with email = "user@company.com" 3. Invitation CANNOT be accepted by: - Different email address (even if same person) - User must logout and login/signup with correct email Email Mismatch Handling: When logged in user tries to accept invite for different email: System Shows: "Email Mismatch This invitation was sent to: user@company.com You are currently logged in as: other@example.com To accept this invitation, you must logout and login or create an account using the invited email address. [Logout and Continue] If you believe this is an error, contact your site administrator to request the invitation be resent to your current email address." Email Changes (Admin-Controlled): Users CANNOT change their login email address. Only administrators can update email addresses to maintain corporate identity control. Legitimate scenarios for admin email changes: - Company domain migration (company.com → newcompany.com) - Legal name changes (jsmith@co.com → jjones@co.com) - Typo corrections by admin - Organizational mergers and acquisitions When admin changes user email: 1. Update login_users.email (authentication identity) 2. Update all users.email records for that login_user_id 3. Log change in audit trail (future feature) 4. Notify user of email change (future feature) Email change process: - Admin-only UI for email management (future feature) - Bulk domain migration tool (future feature) - Audit log for compliance (future feature) Override for Non-Enterprise Apps: RSpade provides this as the default for 90% of enterprise SaaS use cases. Developers building consumer apps or flexible-identity systems can override this behavior since all authentication code is exposed in /rsx/app/login/ as template application code, not locked in the framework. INVITATION WORKFLOW Overview: RSX uses Slack-style invitations where site admins invite users by email. The invitation creates a pending users record bound to that email address. When accepted, the user either creates a login account or links their existing one, but ONLY if the email addresses match. Multi-Account Philosophy: RSX does NOT implement Google/Reddit-style multi-identity account switching (switching between different email addresses in same browser). Why no multi-identity switcher: - People naturally segment by device/browser (work computer vs home) - Original driver was multi-site access (one email, many sites) - That problem is solved by current multi-site architecture - Multi-identity switcher adds cognitive overhead - Rare real-world need (users use browser profiles instead) What RSX DOES support: - Multi-site: One email address can be a member of multiple sites - Site picker: User selects which site to access after login - Clean separation: Different logins use different browser sessions If users need multiple identities: Use different browsers/profiles, or logout/login. This is standard across most B2B SaaS products. Smart Invitation Acceptance: When user clicks an invitation link, RSX auto-detects their account state and shows the appropriate next action. Why auto-detect instead of "Create Account OR Login" choice: - Modern SaaS products (Slack, Teams, GitHub) auto-detect - System knows if account exists for invited email - Showing one correct action is less confusing than both options - Email enumeration is not a security concern in invite context (invitation itself proves we know the email address) Acceptance Flow States: 1. Not logged in + No account exists → Show "Create Account" button → Small text: "Already have account? Sign in instead" 2. Not logged in + Account exists → Show "Sign In to Accept Invitation" button → Small text: "Not you? Use different account" 3. Logged in + Email matches → Show "Accept Invitation" button → User continues immediately 4. Logged in + Email mismatch → Show email mismatch error → Logout and continue option 5. Already accepted → Show "Go to Dashboard" button Smart detection provides obvious next steps that match user's actual state, reducing confusion and support requests. 1. Admin Invites User Admin Action: - Opens "Add User" modal from user management page - Enters: email (required), first/last name (optional), role, phone - Submits form System Processing: 1. Verify email not already a member of this site 2. Create users record: - email (for invitation) - first_name, last_name, phone, role_id (from form) - login_user_id = NULL (not linked yet) - invite_code = generate_unique_code() - invite_accepted_at = NULL (pending) - invite_expires_at = now + 7 days (configurable) 3. TODO: Send invitation email to email address Subject: "You've been invited to join [Site Name]" Link: /accept-invite/{invite_code} Result: Pending membership created, invitation email sent 2. New User Accepts Invite (No Account) User Action: - Clicks /accept-invite/{code} from email - Not logged in, no existing account System Shows: "You've been invited to join [Site Name]! Create an account to get started: Email: [email from invite] (pre-filled, read-only) First Name: [from invite or blank] Last Name: [from invite or blank] Phone: [from invite or blank] Password: [________] Confirm Password: [________] [Create Account & Accept Invite] Already have an account? [Login instead]" Validation: - Email must match invitation email EXACTLY - Email field is pre-filled and read-only (cannot be changed) - Password minimum 8 characters - Confirm password must match On Account Creation: 1. Verify email matches invitation email 2. Create login_users record: - email (from invite, verified to match) - password (hashed) - is_activated = true (auto-activate on invite) - is_verified = true (email verified by invite click) 3. Link membership to login identity: - users.login_user_id = login_user.id - users.invite_accepted_at = now() 4. Log user in: - Session::set_user(login_user) - Session::set_site_id(users.site_id) 5. Redirect to /dashboard Result: New login_users record created with matching email, membership linked and accepted, user logged in and redirected to site 3. Existing User Accepts Invite (Has Account) 3A. User Logged In, Email Matches User Action: - Clicks /accept-invite/{code} from email - Already logged in with same email Validation: - Check logged in user email matches invitation email - If match → proceed with acceptance - If no match → show email mismatch error (see 3B below) System Shows: "Welcome back! You've been invited to join [Site Name]. Click to accept and get started. [Accept Invitation]" On Acceptance: 1. Verify email match one more time: - login_user.email === invitation.email 2. Link membership: - users.login_user_id = current login_user.id - users.invite_accepted_at = now() 3. Set site context: - Session::set_site_id(users.site_id) 4. Redirect to /dashboard Result: Membership linked and accepted, user continues to site 3B. User Logged In, Email Mismatch User Action: - Clicks /accept-invite/{code} from email - Logged in with DIFFERENT email Email Check: - Compare login_user.email with invitation.email - If mismatch → Block acceptance and show error System Shows: "Email Mismatch This invitation was sent to: [invite email] You are currently logged in as: [current email] To accept this invitation, you must be logged in with the invited email address. This security measure ensures invitations are accepted by the intended recipient. [Logout and Continue] If you believe this is an error, contact your site administrator to request the invitation be updated or resent to your current email address." On "Logout and Continue": 1. Session::logout() 2. Redirect back to /accept-invite/{code} 3. User sees "not logged in" flow (section 3C) 4. Can now create account or login with correct email Important: - No "accept anyway" option (security requirement) - Cannot bypass email matching - Admin must update invitation email if legitimately wrong - Prevents invite hijacking and unauthorized access Result: User must logout and use correct email to accept invitation 3C. User Not Logged In User Action: - Clicks /accept-invite/{code} from email - Not logged in System Shows: "You've been invited to join [Site Name]! Email: [invite email] (pre-filled) Password: [________] [Login & Accept Invite] Don't have an account? [Create one]" On Login: - Authenticate login_users with email/password - Check if email matches invite email - If match → Same as 3A flow - If mismatch → Same as 3B flow On "Create one": - Same as "New User Accepts Invite" flow (section 2) Result: User logs in or creates account, then follows appropriate flow 4. User Logs In (Regular Login Flow) User Action: - Navigates to /login - Enters email and password Authentication: 1. Find login_user by email 2. Verify password with password_verify() 3. If invalid, show error 4. If valid, create session: - Session::set_user(login_user) Site Selection Logic: 1. Query accepted memberships: - WHERE login_user_id = current user - AND invite_accepted_at IS NOT NULL - AND invite_expires_at > now() - AND deleted_at IS NULL 2. Count memberships: Zero sites: - Session::logout() - Error: "You do not have access to any sites. Contact your administrator." - Stays on login page One site: - Session::set_site_id(site_id) - Redirect to /dashboard Multiple sites: - Show site picker modal - User clicks site name - Session::set_site_id(selected_site_id) - Redirect to /dashboard First-Login Auto-Accept (Special Case): After authentication, before site selection, check for pending invites matching user's email: 1. Query pending invites: - WHERE email = login_user.email - AND login_user_id IS NULL - AND invite_accepted_at IS NULL - AND invite_expires_at > now() 2. If found: - Show acceptance modal: "You have a pending invitation to [Site Name]. Click to accept and get started." - On accept: - Link: invite.login_user_id = login_user.id - Mark: invite.invite_accepted_at = now() - Continue to normal site selection logic IMPORTANT: This only happens on first login after invite sent. Subsequent invites MUST be accepted via email link for security. Result: User authenticated and directed to appropriate site dashboard SIGNUP WORKFLOW Overview: RSpade supports three signup modes configured in rsx/resource/config/rsx.php: - anonymous: Anyone can create an account - invite_only: Only invited users can sign up - disabled: No new signups allowed (closed system) Configuration: File: rsx/resource/config/rsx.php return [ // Signup mode 'signup_mode' => 'invite_only', // 'anonymous', 'invite_only', 'disabled' // Verification requirements 'verification_required' => 'email', // 'email', 'sms', 'either', 'both' // Invitation expiration (days) 'invite_expiration_days' => 7, ]; Configuration Philosophy: These settings are provided as developer convenience for rapid testing. They allow quickly switching between behaviors during development. For production applications, developers should: 1. Determine which mode their application needs 2. Find the code using these config settings 3. Remove the switches and hardcode desired behavior 4. Customize the implementation to their exact needs This keeps code simple and prevents "configuration explosion" where every feature has multiple modes to maintain. Signup Mode: anonymous Behavior: - Public /signup page available - Anyone can create account without invitation - Account created immediately - Verification required based on verification_required setting - User automatically added to site_id = 1 with role MEMBER Use Cases: - Public SaaS applications - Marketing sites with trial signups - Community platforms - Any application where anyone can join Workflow: 1. User visits /signup 2. Fills form: email, password, first/last name (optional) 3. Submits form 4. System creates login_users record (is_verified = false) 5. System creates users record (site_id = 1, role_id = 3 MEMBER) 6. Users record immediately accepted (invite_accepted_at = now) 7. If verification required: - Send verification code via email/SMS - Show verification modal (PIN entry) - User enters code, account activated - Redirect to dashboard 8. If verification not required: - Log user in immediately - Redirect to dashboard Security Considerations: - Rate limit signup endpoint (prevent spam accounts) - CAPTCHA for anonymous signups (prevent bots) - Email verification strongly recommended - Monitor for abuse patterns Signup Mode: invite_only Behavior: - /signup page requires invite code in URL: /signup?code={invite_code} - Without valid invite code, redirects to login with error message - Users can only create accounts via invitation acceptance - Email must match invitation email (enforced) - Invitation validates email ownership Use Cases: - Private beta applications - Internal company tools - B2B applications with controlled onboarding - Default mode for RSpade starter template Workflow: See INVITATION WORKFLOW section above for complete details. Summary: 1. Admin invites user → users record created (pending state) 2. User receives email with /accept-invite/:code link 3. User clicks link, system validates: - Invitation exists - Not expired (invite_expires_at > now) - Not already accepted (invite_accepted_at IS NULL) 4. If not logged in: Show account creation form - Email pre-filled from invitation (read-only) - Email field cannot be changed (security) - User sets password 5. If logged in with matching email: Show accept button 6. If logged in with different email: Show email mismatch error 7. Account created/linked, invitation accepted, user logged in Email Matching Enforcement: - Signup form email field is read-only (uses invitation email) - Cannot create account with different email - If user tries to accept with wrong logged-in account: → Show error and require logout - Admin must update invitation if email legitimately wrong Benefits: - Email verified via invitation click - Controlled user growth - Know who invited each user - Prevent spam signups - Prevent invite hijacking (email matching requirement) Signup Mode: disabled Behavior: - No signup page available - /signup redirects to /login with error message - No invitation acceptance allowed - Accounts must be created manually by administrators Use Cases: - Closed systems (all users pre-created) - Applications in maintenance mode - Internal tools with fixed user list - Temporarily disable signups during issues Workflow: Admin must manually create accounts: 1. Admin creates login_users record directly 2. Admin creates users record for site membership 3. Admin provides credentials to user separately 4. User logs in with provided credentials Implementation: Routes /signup and /accept-invite return 403 or redirect with: "New account signups are currently disabled. Contact your administrator for assistance." Verification Methods Email Verification: 1. On signup, generate random 6-digit PIN 2. Store in verification table with expiration 3. Send email with PIN code 4. Show modal: "Enter verification code sent to your email" 5. User enters PIN 6. Verify PIN matches and not expired 7. Mark login_user.is_verified = true 8. Delete verification record 9. Complete login SMS Verification: Similar to email, but: - Require phone number during signup - Send PIN via SMS service (Twilio, etc.) - User enters PIN from text message Either Verification: - User chooses email OR SMS during signup - Provide both options in UI - Complete either one to verify account Both Verification: - Require BOTH email AND SMS verification - Must complete both before account activated - Higher security for sensitive applications Verification UI: RSpade includes a PIN verification modal component. See: /rsx/theme/components/verification_pin_modal.jqhtml Anonymous Signup Implementation Route: /signup (GET) Show signup form: - Email (required, validated) - Password (required, min 8 characters) - Confirm Password (must match) - First Name (optional) - Last Name (optional) - Phone (if SMS verification required) - Terms acceptance checkbox - "Already have an account? Login" link Route: /signup (POST) Process signup: 1. Validate input (email unique, password strength) 2. Check signup_mode = 'anonymous' (else error) 3. Create login_users record: - email - password (hashed) - is_activated = true - is_verified = false (until verification) 4. Create users record: - login_user_id - site_id = 1 (default site) - role_id = 3 (MEMBER) - email (same as login_users) - first_name, last_name, phone - invite_accepted_at = now() (not an invite, auto-accepted) 5. If verification required: - Generate PIN, store with expiration - Send via email/SMS - Show verification modal - Return (don't log in yet) 6. If no verification required: - Session::set_user(login_user) - Session::set_site_id(1) - Redirect to /dashboard Route: /verify-account (POST) Verify PIN code: 1. Get PIN from input 2. Find verification record for user 3. Check PIN matches and not expired 4. Mark login_user.is_verified = true 5. Delete verification record 6. Session::set_user(login_user) 7. Session::set_site_id(1) 8. Redirect to /dashboard API REFERENCE RsxAuth Facade: RsxAuth::check(): bool Returns true if user is logged in RsxAuth::user(): ?Login_User_Model Returns current login_user instance or null RsxAuth::id(): ?int Returns current login_user_id or null RsxAuth::session(): ?Session Returns current session instance or null RsxAuth::login(Login_User_Model $user): void Authenticate user and create session RsxAuth::logout(): void Clear session and log user out Session Methods: Session::init(): void Initialize session for current request Loads session by token + experience_id Called automatically by framework Session::set_user(Login_User_Model $user): void Set logged in user for session Updates session.login_user_id Persists to database Session::get_user(): ?Login_User_Model Get current logged in user Returns null if not logged in Session::get_user_id(): ?int Get current login_user_id Returns null if not logged in Session::set_site_id(int $site_id): void Set current site context for session Updates session.site_id Persists to database Session::get_site_id(): int Get current site_id Returns 0 if no site selected Session::get_site(): ?Site_Model Get current site model instance Returns null if no site selected Session::logout(): void Clear session and destroy token Redirects to login page Session::id(): string Get session token (creates session if needed) Use for anonymous tracking (e.g., file uploads) Experience Methods (Advanced): Session::set_experience_id(int $experience_id): void Set authentication realm for request Used for customer portals vs staff panels Default 0 for standard applications See MULTI-REALM AUTHENTICATION section Session::get_experience_id(): int Get current experience_id Default 0 Request Override Methods (Advanced): Session::set_request_site_id_override(int $site_id): void Force site_id for current request only Does NOT persist to session record Used for subdomain-based tenant enforcement See SUBDOMAIN-BASED MULTI-TENANT section Session::clear_request_site_id_override(): void Remove request-scoped site override Session::has_request_site_id_override(): bool Check if request override is active CONTROLLER PATTERNS Authentication Philosophy: RSX uses manual authentication checks rather than declarative attributes. This approach ensures developers explicitly handle auth at the code level, making permission logic visible and traceable in the actual method code. A code quality rule (PHP-AUTH-01) verifies that all endpoints have auth checks, either in the method body or in the controller's pre_dispatch(). Controller-Level Authentication (Recommended): Add auth check to pre_dispatch() to protect all endpoints in a controller: class My_Controller extends Rsx_Controller_Abstract { public static function pre_dispatch(Request $request, array $params = []) { if (!Session::is_logged_in()) { return response_unauthorized(); } return null; } #[Route('/dashboard')] public static function dashboard(Request $request, array $params = []) { // User guaranteed to be logged in (checked in pre_dispatch) $user = Session::get_user(); } #[Ajax_Endpoint] public static function save_data(Request $request, array $params = []) { // Also protected by pre_dispatch } } Method-Level Authentication: Add auth check at the start of individual methods: #[Ajax_Endpoint] public static function save_settings(Request $request, array $params = []) { if (!Session::is_logged_in()) { return response_unauthorized(); } // Process request } Response Helpers: response_unauthorized(?string $message = null) Returns context-aware unauthorized response: - Ajax: JSON {success: false, error_code: 'unauthorized'} - Web: Redirect to login or render 403 page response_not_found(?string $message = null) Returns context-aware not found response: - Ajax: JSON {success: false, error_code: 'not_found'} - Web: Render 404 page Permission Checks: Use Permission class helpers for role/permission-based access: if (!Permission::has_permission(User_Model::PERM_EDIT_DATA)) { return response_unauthorized('Insufficient permissions'); } if (!Permission::has_role(User_Model::ROLE_MANAGER)) { return response_unauthorized('Manager access required'); } Public Endpoints (Auth Exempt): For public endpoints, add @auth-exempt comment to class docblock: /** * Login controller * * @auth-exempt Login routes are public by design */ class Login_Controller extends Rsx_Controller_Abstract { #[Route('/login')] public static function index(Request $request, array $params = []) { // No auth check needed - marked exempt } } Or exempt individual methods: /** * @auth-exempt Public webhook endpoint */ #[Ajax_Endpoint] public static function webhook(Request $request, array $params = []) { // Process webhook } Code Quality Enforcement: The PHP-AUTH-01 rule in rsx:check verifies: - All #[Route], #[SPA], #[Ajax_Endpoint] methods have auth checks - Check can be in pre_dispatch() OR method body - @auth-exempt comment exempts from requirement Run: php artisan rsx:check SINGLE-SITE APPLICATIONS Overview: RSX is multi-tenant by default, but works perfectly for single-site applications. Simply use site_id = 1 for everything. Configuration: No special configuration needed. Architecture is identical. Behavior: User Invitation: - Admin invites user → users.site_id = 1 - User accepts → membership on site 1 Login Flow: - User logs in → System finds 1 site membership - Automatically sets session.site_id = 1 - No site picker shown (only one option) User Removal: - Remove user from site 1 → No site memberships - Next login → "No site access" error - User forced to logout Code Access: Current site ID: Session::get_site_id() → Always returns 1 Current site: Session::get_site() → Always returns site 1 Key Points: - Invitation workflow identical to multi-tenant - Site picker never shows (always exactly 1 site) - User management CRUD same as multi-tenant - No special single-site mode needed - Easy upgrade path to multi-tenant if needed MULTI-REALM AUTHENTICATION (ADVANCED) Concept: Experience = Authentication realm. One user can be logged into multiple experiences simultaneously using the same cookie, with different login_user_id per experience. Use Cases: - Customer portal (/portal/*) + Staff panel (/staff/*) - Public site + Admin area with separate logins - Multiple authentication contexts in one application Database Structure: sessions table has experience_id column (default 0) Same session_token can have multiple rows with different experience_id Implementation in Main.php: public static function pre_dispatch(Request $request, array $params = []) { $path = $request->path(); if (str_starts_with($path, 'staff/')) { Session::set_experience_id(1); // Staff realm } elseif (str_starts_with($path, 'portal/')) { Session::set_experience_id(2); // Customer realm } else { Session::set_experience_id(0); // Default realm } return null; } How It Works: 1. User visits /portal/dashboard → experience_id = 2 Session loads: WHERE token=X AND experience_id=2 User logged in as customer (login_user_id=123) 2. Same user visits /staff/admin → experience_id = 1 Session loads: WHERE token=X AND experience_id=1 User logged in as staff (login_user_id=456) 3. Both sessions active simultaneously with same cookie Experience Methods: Session::set_experience_id(int $id) Set realm for request Session::get_experience_id() Get current realm Important: Most applications use default experience_id = 0 Only implement multi-realm if you need separate authentication contexts within same application SUBDOMAIN-BASED MULTI-TENANT (ADVANCED) Concept: Each site/tenant has their own subdomain. Site is determined by subdomain, not user choice. Users cannot switch sites arbitrarily. URL Structure: acme.yourapp.com → site_id = 1 (Acme Corp) widget.yourapp.com → site_id = 2 (Widget Inc) Implementation in Main.php: public static function pre_dispatch(Request $request, array $params = []) { $host = $request->getHost(); $subdomain = explode('.', $host)[0]; $site = Site_Model::where('slug', $subdomain)->first(); if (!$site) { return response('Site not found', 404); } // Enforce site for current request (does NOT persist to session) Session::set_request_site_id_override($site->id); return null; } How It Works: 1. User visits acme.yourapp.com 2. Framework sets request override: site_id = 1 3. All code calling Session::get_site_id() gets site 1 4. Session record site_id remains null (not persisted) 5. Next request on widget.yourapp.com gets site 2 Benefits: - No site picker needed (determined by URL) - No session writes for site switching (performance) - Clear tenant isolation via DNS - Traditional "have account or don't" UX Traditional Login Flow: - User goes to acme.yourapp.com/login - Subdomain determines site_id = 1 - User logs in - System checks: Is user a member of site 1? - If yes: Allow login, redirect to dashboard - If no: Error "No access to this site" This is a future planned feature. Current implementation uses organization picker (Trello-style) by default. CONFIGURATION Configuration File: rsx/resource/config/rsx.php return [ // Invitation expiration (days) 'invite_expiration_days' => 7, // Session lifetime (minutes) 'session_lifetime' => 525600, // 365 days // Session cookie name 'session_cookie_name' => 'rsx_session', ]; Usage in Code: $days = config('rsx.invite_expiration_days', 7); $expires_at = now()->addDays($days); REQUIRED ROUTES The following routes must be implemented for complete authentication: Login/Logout: /login Show login form /login (POST) Process login /logout Logout user Invitation Acceptance: /accept-invite/:code Show acceptance page /accept-invite/:code/signup (POST) Create account from invite /accept-invite/:code/login (POST) Login and accept invite Site Selection (Multi-tenant): /select-site Show site picker (multiple sites) /select-site/:site_id (POST) Set site and redirect User Management (Admin): /settings/user-management List users + Add User button /settings/user-management/view/:id View user details Ajax Endpoints: Frontend_Settings_User_Management_Controller::add_user() Create invitation (modal form) Frontend_Settings_User_Management_Controller::datagrid_fetch() Load user list for DataGrid SESSION LIFECYCLE Session Creation: Sessions are created lazily, not for every page view. Created when: - User logs in (Session::set_user()) - Session::id() explicitly called (e.g., file upload tracking) - CSRF token requested (requires session) NOT created for: - Anonymous page views (public pages) - Static asset requests (CSS, JS, images) - Until explicitly needed by code Session Fields: session_token Unique identifier (stored in cookie) login_user_id FK to login_users (NULL for anonymous) site_id Current site context (NULL until selected) experience_id Authentication realm (default 0) active Session validity flag expires_at Session expiration (default 365 days) created_at Session creation timestamp updated_at Last activity timestamp Session States: Anonymous Session: - session_token exists - login_user_id = NULL - site_id = NULL - Used for: File uploads, form CSRF, tracking Authenticated Session (No Site): - session_token exists - login_user_id set - site_id = NULL - Occurs: After login, before site selection Authenticated Session (With Site): - session_token exists - login_user_id set - site_id set - Normal working state for logged in users Session Expiration: Default lifetime: 365 days (configurable) Updated on every request Expired sessions automatically cleaned up Important Notes: - Session cookie ≠ Logged in user - RsxAuth::check() only true if login_user_id set - Session persists across login/logout - Session token can outlive login status SECURITY CONSIDERATIONS Password Storage: - Use password_hash() with bcrypt (PHP default) - Never store plaintext passwords - Verify with password_verify() Email Verification: - Invitation click verifies email ownership - New accounts via invite auto-verified - is_verified = true on invitation acceptance Invitation Security: - Unique random invite codes (generate_unique_code()) - Expiration enforced (default 7 days) - First-login auto-accept only on first login - Subsequent invites require email link click - Email mismatch protection on acceptance Session Security: - Unique session tokens per session - Token stored in httponly cookie - Session expiration enforced - Active flag for forced logout CSRF Protection: - All POST routes require CSRF token - Token tied to session - Automatic validation by framework EXAMPLES Example 1: Check if User is Logged In if (RsxAuth::check()) { $user = RsxAuth::user(); echo "Welcome, {$user->email}!"; } else { echo "Please log in."; } Example 2: Get Current Site Membership $login_user_id = RsxAuth::id(); $site_id = Session::get_site_id(); $membership = User_Model::where('login_user_id', $login_user_id) ->where('site_id', $site_id) ->first(); if ($membership) { echo "Role: {$membership->role_id}"; echo "Name: {$membership->first_name} {$membership->last_name}"; } Example 3: Create User Invitation $user = new User_Model(); $user->site_id = Session::get_site_id(); $user->email = $request->input('email'); $user->first_name = $request->input('first_name'); $user->last_name = $request->input('last_name'); $user->role_id = $request->input('role_id'); $user->login_user_id = null; // Not linked yet $user->invite_code = Str::random(32); $user->invite_accepted_at = null; $user->invite_expires_at = now()->addDays( config('rsx.invite_expiration_days', 7) ); $user->save(); // TODO: Send invitation email // $url = url('/accept-invite/' . $user->invite_code); // Mail::to($user->email)->send(new InvitationEmail($url)); Example 4: Accept Invitation (Existing User) $invite = User_Model::where('invite_code', $code) ->whereNull('invite_accepted_at') ->where('invite_expires_at', '>', now()) ->firstOrFail(); $login_user = RsxAuth::user(); if ($login_user->email !== $invite->email) { return error('Email mismatch. This invite was sent to a different email.'); } $invite->login_user_id = $login_user->id; $invite->invite_accepted_at = now(); $invite->save(); Session::set_site_id($invite->site_id); return redirect('/dashboard'); Example 5: Site Selection After Login $login_user = RsxAuth::user(); $sites = User_Model::where('login_user_id', $login_user->id) ->whereNotNull('invite_accepted_at') ->where('invite_expires_at', '>', now()) ->with('site') ->get(); if ($sites->count() === 0) { RsxAuth::logout(); return error('No site access'); } if ($sites->count() === 1) { Session::set_site_id($sites[0]->site_id); return redirect('/dashboard'); } // Multiple sites - show picker return view('auth/site-picker', ['sites' => $sites]); Example 6: Protect Route with Custom Permission In rsx/permission.php: public static function is_owner(): bool|Response { if (!RsxAuth::check()) { return false; } $membership = User_Model::where('login_user_id', RsxAuth::id()) ->where('site_id', Session::get_site_id()) ->first(); return $membership && $membership->role_id === 1; } In controller: #[Route('/admin/delete-site')] #[Auth('Permission::is_owner()')] public static function delete_site(Request $request, array $params = []) { // Only site owners can access this } FUTURE PLANNED FEATURES The following features are documented here for completeness but not yet implemented. They represent the roadmap for authentication enhancements. Enterprise Domain Locking: Allow enterprise admins to "claim" email domains and control how corporate email addresses are used with the platform. Use Cases: - Corporate IT controls where company email addresses are used - Prevents employees from accepting invitations to external sites using corporate email - Centralized governance of corporate identity usage - Audit trail of all sites using company domain Domain Claiming: - Enterprise admin claims domain (e.g., @acmecorp.com) - Proves ownership via DNS TXT record or email verification - Sets policy for that domain across entire platform Policy Options: 1. Block External Sites - Employees cannot accept invitations to other sites - All @acmecorp.com addresses restricted to company's sites only - Invitations to external sites automatically rejected 2. Require Approval - External invitations go to approval queue - Enterprise admin reviews and approves/rejects - Approved invitations proceed normally 3. Allow But Notify - Employees can accept any invitation - Enterprise admin receives notification - Provides audit trail without blocking access Implementation Considerations: - Similar to Slack's "Workspace Claiming" feature - Similar to Microsoft's "Tenant Restrictions" - Standard enterprise feature in B2B SaaS - Balances employee flexibility with corporate control Future Enhancement: - SSO integration (SAML/OAuth) - Automatic provisioning/deprovisioning via SCIM - Group-based access control - Conditional access policies User Quota Enforcement: - Track user count per site (sites.user_count) - Prevent invitations when at limit - Different quota tiers per plan/subscription - Grace period for over-quota sites - Admin override for quota enforcement Email System Integration: - Send invitation emails with acceptance link - Send acceptance confirmation to admin - Send "User joined site" notification - Admin notification for email mismatch requests - Resend invitation functionality - Email template customization per site Invitation Management UI: - Resend invitation (generates new code) - Cancel/revoke pending invitations - View invitation status and history - Bulk invitation import (CSV) - Invitation acceptance tracking Subdomain-Based Tenant Isolation: - Traditional login flow (have account or don't) - No site picker (determined by subdomain) - Site-specific branding and theming - Custom domains per tenant - DNS-based site enforcement Advanced Permissions: - Granular permission system beyond roles - Custom permissions per site - Permission inheritance and grouping - Role-based access control (RBAC) framework - Permission caching for performance Account Management: - Password reset flow - Email change verification - Two-factor authentication (2FA) - Account recovery options - Login history and security audit log Session Management: - Active session listing - Force logout from all devices - Session activity monitoring - Concurrent session limits - IP-based session validation SEE ALSO session - RSX session management API reference model - Model system with relationships routing - Type-safe URL generation and route patterns permission - Custom permission implementation