SplitMySub implements a passwordless authentication system using magic links, built on Rails 8's authentication framework. The system prioritizes security and user experience by eliminating passwords and using time-limited, single-use tokens sent via email.
-
Authentication Concern (
app/controllers/concerns/authentication.rb)- Provides session management and authentication helpers
- Handles session resumption and authentication requirements
- Manages authentication redirects and return URLs
-
Magic Links Model (
app/models/magic_link.rb)- Generates secure, time-limited authentication tokens
- Manages token lifecycle (creation, validation, expiration, cleanup)
- Provides token validation and usage tracking
-
User Model (
app/models/user.rb)- Core user entity with email normalization
- Manages user sessions and associations
- Handles user preferences and profile data
-
Session Model (
app/models/session.rb)- Lightweight session tracking
- Associates sessions with users
- Enables session management across devices
-
Sessions Controller (
app/controllers/sessions_controller.rb)- Handles login flow and magic link generation
- Processes magic link verification
- Manages session creation and termination
-
Magic Link Mailer (
app/mailers/magic_link_mailer.rb)- Sends magic link emails to users
- Formats authentication emails with expiration info
sequenceDiagram
participant User as User
participant Browser as Browser
participant App as SplitMySub App
participant DB as Database
participant Email as Email Service
participant Mailer as MagicLinkMailer
User->>Browser: Access protected resource
Browser->>App: Request resource
App->>App: Check authentication (require_authentication)
App->>App: resume_session fails
App->>Browser: Redirect to login (/login)
Browser->>App: GET /login
App->>Browser: Render login form
User->>Browser: Enter email address
Browser->>App: POST /sessions/magic_link
App->>App: Rate limit check (10 requests/3 minutes)
App->>DB: Find user by email
DB->>App: Return user (if exists)
App->>DB: Create MagicLink (30 min expiry)
DB->>App: Return magic link token
App->>Mailer: Send magic link email
Mailer->>Email: Deliver email with magic link
Email->>User: Magic link email
App->>Browser: Redirect to login with success message
User->>Browser: Click magic link in email
Browser->>App: GET /sessions/verify_magic_link?token=...
App->>DB: Find valid magic link by token
DB->>App: Return magic link (if valid)
App->>App: Validate magic link (not used, not expired)
App->>DB: Mark magic link as used
App->>DB: Create new session
App->>Browser: Set signed session cookie
App->>Browser: Redirect to intended resource
Browser->>App: Request original resource
App->>App: resume_session succeeds
App->>Browser: Serve protected resource
flowchart TD
A[Request] --> B{Session Cookie Present?}
B -->|No| C[No Authentication]
B -->|Yes| D[Extract session_id from signed cookie]
D --> E[Query Session model]
E --> F{Session exists?}
F -->|No| C
F -->|Yes| G[Set Current.session]
G --> H[Authentication Success]
C --> I[Redirect to Login]
H --> J[Continue to Protected Resource]
stateDiagram-v2
[*] --> Created: generate_secure_token()
Created --> Active: expires_at > now && !used
Active --> Used: use!() called
Active --> Expired: expires_at <= now
Used --> [*]: cleanup_used()
Expired --> [*]: cleanup_expired()
note right of Created
Token: 32 bytes urlsafe_base64
Expires: 30 minutes from creation
Used: false
end note
note right of Active
valid_for_use?() returns true
Can be used for authentication
end note
note right of Used
Single-use token consumed
Cannot be reused
end note
note right of Expired
Time-based expiration
Cannot be used
end note
- Secure Generation: Uses
SecureRandom.urlsafe_base64(32)for cryptographically secure tokens - Single Use: Tokens are marked as used after successful authentication
- Time-Limited: Default 30-minute expiration with configurable duration
- Uniqueness: Database-level uniqueness constraint on tokens
- Magic Link Requests: 10 requests per 3 minutes per IP address
- Rails 8 Native: Uses built-in rate limiting features
- Graceful Degradation: Redirects with user-friendly error messages
- Signed Cookies: Session IDs are cryptographically signed
- HttpOnly: Cookies are not accessible to JavaScript
- SameSite: Lax same-site policy for CSRF protection
- Permanent: Long-lived sessions with secure storage
- Timing Attack Protection: Same response for existing/non-existing emails
- Secure URLs: Magic links use HTTPS in production
- Expiration Display: Users see exact expiration time in emails
- Parameterized Queries: All database queries use parameter binding
- Validation: Comprehensive model validations
- Constraints: Database-level uniqueness constraints
sequenceDiagram
participant User as New User
participant Browser as Browser
participant App as SplitMySub App
participant DB as Database
participant Email as Email Service
User->>Browser: Access login page
Browser->>App: GET /login
App->>Browser: Render login form
User->>Browser: Enter email address (new user)
Browser->>App: POST /sessions/magic_link
App->>DB: Find user by email
DB->>App: User not found
App->>Browser: "If account exists, magic link sent" message
Note over User,Email: User must register through invitation<br/>or admin creates account
User->>Browser: Receive invitation email
Browser->>App: GET /invitations/:token
App->>Browser: Render invitation form
User->>Browser: Fill user details (name, email)
Browser->>App: POST /invitations/:token
App->>DB: Create user account
App->>DB: Accept invitation
App->>Browser: Redirect to email verification
User->>Browser: Click magic link
Browser->>App: GET /sessions/verify_magic_link
App->>DB: Validate magic link
App->>DB: Create session
App->>Browser: Set session cookie
App->>Browser: Redirect to project dashboard
sequenceDiagram
participant User as Existing User
participant Browser as Browser
participant App as SplitMySub App
participant DB as Database
participant Email as Email Service
User->>Browser: Access protected resource
Browser->>App: Request without session
App->>Browser: Redirect to login
User->>Browser: Enter email address
Browser->>App: POST /sessions/magic_link
App->>DB: Find user by email
DB->>App: Return existing user
App->>DB: Create magic link
App->>Email: Send magic link
Email->>User: Magic link email
App->>Browser: Success message
User->>Browser: Click magic link
Browser->>App: GET /sessions/verify_magic_link
App->>DB: Validate and use magic link
App->>DB: Create new session
App->>Browser: Set session cookie
App->>Browser: Redirect to intended resource
def start_new_session_for(user)
user.sessions.create!(
user_agent: request.user_agent,
ip_address: request.remote_ip
).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = {
value: session.id,
httponly: true,
same_site: :lax
}
end
enddef resume_session
Current.session ||= find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
enddef terminate_session
Current.session.destroy
cookies.delete(:session_id)
endThe authentication system integrates seamlessly with the invitation flow:
- Invitation with Email Verification: When users accept invitations, they must verify their email through magic links
- Session Context: The system maintains invitation context during authentication
- Secure Handoff: Magic link verification completes invitation acceptance automatically
sequenceDiagram
participant User as User
participant Browser as Browser
participant App as SplitMySub App
participant DB as Database
participant Email as Email Service
User->>Browser: Click invitation link
Browser->>App: GET /invitations/:token
App->>DB: Find invitation
App->>Browser: Render invitation form
User->>Browser: Fill details and submit
Browser->>App: POST /invitations/:token
App->>DB: Create user account
App->>DB: Generate magic link
App->>Email: Send verification email
App->>Browser: Store pending_invitation_token in session
App->>Browser: Redirect to email verification page
User->>Browser: Click magic link
Browser->>App: GET /sessions/verify_magic_link
App->>DB: Validate magic link
App->>App: Check session[:pending_invitation_token]
App->>DB: Complete invitation acceptance
App->>DB: Create user session
App->>Browser: Set session cookie
App->>Browser: Redirect to project dashboard
- Invalid Token: Redirects to login with error message
- Expired Token: Redirects to login with expiration message
- Used Token: Redirects to login with reuse prevention message
- Rate Limited: Redirects with rate limit message
- Invalid Session: Clears session and redirects to login
- Expired Session: Automatic cleanup and re-authentication
- Missing Session: Redirects to login with return URL
- Expired Magic Links:
MagicLink.cleanup_expiredremoves expired tokens - Used Magic Links:
MagicLink.cleanup_usedremoves used tokens after 24 hours - Orphaned Sessions: Manual cleanup of unused sessions
- Rate Limit Violations: Logged for security monitoring
- Authentication Failures: Logged for audit trails
- Session Activity: Tracked for security analysis
- Expiration: 30 minutes (configurable)
- Token Length: 32 bytes (urlsafe_base64)
- Rate Limit: 10 requests per 3 minutes
- Cookie Duration: Permanent (long-lived)
- Security: HttpOnly, SameSite=Lax
- Signing: Cryptographically signed session IDs
- Sender: noreply@splitmysubscription.xyz
- Template: HTML and text versions
- Delivery: Immediate delivery for authentication