Files
rspade_system/app/RSpade/man/auth.txt
root 84ca3dfe42 Fix code quality violations and rename select input components
Move small tasks from wishlist to todo, update npm packages
Replace #[Auth] attributes with manual auth checks and code quality rule
Remove on_jqhtml_ready lifecycle method from framework
Complete ACL system with 100-based role indexing and /dev/acl tester
WIP: ACL system implementation with debug instrumentation
Convert rsx:check JS linting to RPC socket server
Clean up docs and fix $id→$sid in man pages, remove SSR/FPC feature
Reorganize wishlists: priority order, mark sublayouts complete, add email
Update model_fetch docs: mark MVP complete, fix enum docs, reorganize
Comprehensive documentation overhaul: clarity, compression, and critical rules
Convert Contacts/Projects CRUD to Model.fetch() and add fetch_or_null()
Add JS ORM relationship lazy-loading and fetch array handling
Add JS ORM relationship fetching and CRUD documentation
Fix ORM hydration and add IDE resolution for Base_* model stubs
Rename Json_Tree_Component to JS_Tree_Debug_Component and move to framework
Enhance JS ORM infrastructure and add Json_Tree class name badges

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:39:43 +00:00

1407 lines
51 KiB
Plaintext
Executable File

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