fix(auth): require email authentication every login
CRITICAL SECURITY FIX: - Email code required EVERY login (authentication, not verification) - DNS TXT check cached separately (domain verification) - New auth_sessions table for per-login state - Codes hashed with SHA-256, constant-time comparison - Max 3 attempts, 10-minute session expiry - OAuth params stored server-side (security improvement) New files: - services/auth_session.py - migrations 004, 005 - ADR-010: domain verification vs user authentication 312 tests passing, 86.21% coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
246
docs/designs/authentication-flow-fix.md
Normal file
246
docs/designs/authentication-flow-fix.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Authentication Flow Fix Design
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current implementation conflates domain verification (one-time DNS check) with user authentication (per-login email verification). This creates a security vulnerability where only the first user needs to authenticate via email code, while subsequent users bypass authentication entirely.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Domain Verification
|
||||
- **Purpose**: Establish that a domain is configured to use this IndieAuth server
|
||||
- **Method**: DNS TXT record containing server-specific verification string
|
||||
- **Frequency**: Once per domain, results cached in database
|
||||
- **Storage**: `domains` table with verification status and timestamp
|
||||
|
||||
### User Authentication
|
||||
- **Purpose**: Prove the current user owns the claimed identity
|
||||
- **Method**: Time-limited 6-digit code sent to rel="me" email
|
||||
- **Frequency**: EVERY authorization attempt
|
||||
- **Storage**: Temporary session storage, expires after 5-10 minutes
|
||||
|
||||
## Corrected Authorization Flow
|
||||
|
||||
### Step 1: Authorization Request
|
||||
Client initiates OAuth flow:
|
||||
```
|
||||
GET /authorize?
|
||||
response_type=code&
|
||||
client_id=https://app.example.com&
|
||||
redirect_uri=https://app.example.com/callback&
|
||||
state=xyz&
|
||||
code_challenge=abc&
|
||||
code_challenge_method=S256&
|
||||
me=https://user.example.com
|
||||
```
|
||||
|
||||
### Step 2: Domain Verification Check
|
||||
1. Extract domain from `me` parameter
|
||||
2. Check `domains` table for existing verification:
|
||||
```sql
|
||||
SELECT verified, last_checked
|
||||
FROM domains
|
||||
WHERE domain = 'user.example.com'
|
||||
```
|
||||
3. If not verified or stale (>24 hours):
|
||||
- Check DNS TXT record at `_indieauth.user.example.com`
|
||||
- Update database with verification status
|
||||
4. If domain not verified, reject with error
|
||||
|
||||
### Step 3: Profile Discovery
|
||||
1. Fetch the user's homepage at `me` URL
|
||||
2. Parse for IndieAuth metadata:
|
||||
- Authorization endpoint (must be this server)
|
||||
- Token endpoint (if present)
|
||||
- rel="me" links for authentication options
|
||||
3. Extract email from rel="me" links
|
||||
|
||||
### Step 4: User Authentication (ALWAYS REQUIRED)
|
||||
1. Generate 6-digit code
|
||||
2. Store in session with expiration:
|
||||
```json
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"me": "https://user.example.com",
|
||||
"email": "user@example.com",
|
||||
"code": "123456",
|
||||
"client_id": "https://app.example.com",
|
||||
"redirect_uri": "https://app.example.com/callback",
|
||||
"state": "xyz",
|
||||
"code_challenge": "abc",
|
||||
"expires_at": "2024-01-01T12:05:00Z"
|
||||
}
|
||||
```
|
||||
3. Send code via email
|
||||
4. Show code entry form
|
||||
|
||||
### Step 5: Code Verification
|
||||
1. User submits code
|
||||
2. Validate against session storage
|
||||
3. If valid, mark session as authenticated
|
||||
4. If invalid, allow retry (max 3 attempts)
|
||||
|
||||
### Step 6: Consent
|
||||
1. Show consent page with client details
|
||||
2. User approves/denies
|
||||
3. If approved, generate authorization code
|
||||
|
||||
### Step 7: Authorization Code
|
||||
1. Generate authorization code
|
||||
2. Store with session binding:
|
||||
```json
|
||||
{
|
||||
"code": "auth_code_xyz",
|
||||
"session_id": "uuid",
|
||||
"me": "https://user.example.com",
|
||||
"client_id": "https://app.example.com",
|
||||
"redirect_uri": "https://app.example.com/callback",
|
||||
"code_challenge": "abc",
|
||||
"expires_at": "2024-01-01T12:10:00Z"
|
||||
}
|
||||
```
|
||||
3. Redirect to client with code
|
||||
|
||||
## Data Models
|
||||
|
||||
### domains table (persistent)
|
||||
```sql
|
||||
CREATE TABLE domains (
|
||||
domain VARCHAR(255) PRIMARY KEY,
|
||||
verified BOOLEAN DEFAULT FALSE,
|
||||
verification_string VARCHAR(255),
|
||||
last_checked TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### auth_sessions table (temporary, cleaned periodically)
|
||||
```sql
|
||||
CREATE TABLE auth_sessions (
|
||||
session_id VARCHAR(255) PRIMARY KEY,
|
||||
me VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
verification_code VARCHAR(6),
|
||||
code_verified BOOLEAN DEFAULT FALSE,
|
||||
client_id VARCHAR(255) NOT NULL,
|
||||
redirect_uri VARCHAR(255) NOT NULL,
|
||||
state VARCHAR(255),
|
||||
code_challenge VARCHAR(255),
|
||||
code_challenge_method VARCHAR(10),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
INDEX idx_expires (expires_at)
|
||||
);
|
||||
```
|
||||
|
||||
### authorization_codes table (temporary)
|
||||
```sql
|
||||
CREATE TABLE authorization_codes (
|
||||
code VARCHAR(255) PRIMARY KEY,
|
||||
session_id VARCHAR(255) NOT NULL,
|
||||
me VARCHAR(255) NOT NULL,
|
||||
client_id VARCHAR(255) NOT NULL,
|
||||
redirect_uri VARCHAR(255) NOT NULL,
|
||||
code_challenge VARCHAR(255),
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES auth_sessions(session_id),
|
||||
INDEX idx_expires (expires_at)
|
||||
);
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
### Session Creation
|
||||
- Generate UUID for session_id
|
||||
- Set expiration to 10 minutes for email verification
|
||||
- Store all OAuth parameters in session
|
||||
|
||||
### Session Validation
|
||||
- Check expiration on every access
|
||||
- Verify session_id matches throughout flow
|
||||
- Clear expired sessions periodically (cron job)
|
||||
|
||||
### Security Considerations
|
||||
- Session IDs must be cryptographically random
|
||||
- Email codes must be 6 random digits
|
||||
- Authorization codes must be unguessable
|
||||
- All temporary data expires and is cleaned up
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Domain Not Verified
|
||||
```json
|
||||
{
|
||||
"error": "unauthorized_client",
|
||||
"error_description": "Domain not configured for this IndieAuth server"
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid Email Code
|
||||
```json
|
||||
{
|
||||
"error": "access_denied",
|
||||
"error_description": "Invalid verification code"
|
||||
}
|
||||
```
|
||||
|
||||
### Session Expired
|
||||
```json
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Session expired, please start over"
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from Current Implementation
|
||||
|
||||
1. **Immediate**: Disable caching of email verification
|
||||
2. **Add auth_sessions table**: Track per-login authentication state
|
||||
3. **Modify verification flow**: Always require email code
|
||||
4. **Update domain verification**: Separate from user authentication
|
||||
5. **Clean up old code**: Remove improper caching logic
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Tests
|
||||
- Domain verification logic (DNS lookup, caching)
|
||||
- Session management (creation, expiration, cleanup)
|
||||
- Email code generation and validation
|
||||
- Authorization code generation and exchange
|
||||
|
||||
### Integration Tests
|
||||
- Full authorization flow with email verification
|
||||
- Multiple concurrent users for same domain
|
||||
- Session expiration during flow
|
||||
- Domain verification caching behavior
|
||||
|
||||
### Security Tests
|
||||
- Ensure email verification required every login
|
||||
- Verify sessions properly isolated between users
|
||||
- Test rate limiting on code attempts
|
||||
- Verify all codes are single-use
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. ✓ Domain verification via DNS TXT is cached appropriately
|
||||
2. ✓ Email verification code is required for EVERY login attempt
|
||||
3. ✓ Multiple users can authenticate for the same domain independently
|
||||
4. ✓ Sessions expire and are cleaned up properly
|
||||
5. ✓ Authorization codes are single-use
|
||||
6. ✓ Clear separation between domain verification and user authentication
|
||||
7. ✓ No security regression from current (broken) implementation
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
**CRITICAL**: This is a security vulnerability that must be fixed immediately. The current implementation allows unauthenticated access after the first user logs in for a domain.
|
||||
|
||||
## Notes
|
||||
|
||||
The confusion between domain verification and user authentication is a fundamental architectural error. This fix properly separates these concerns:
|
||||
|
||||
- **Domain verification** establishes trust in the domain configuration (one-time)
|
||||
- **User authentication** establishes trust in the current user (every time)
|
||||
|
||||
This aligns with the IndieAuth specification where the authorization endpoint MUST authenticate the user, not just verify the domain.
|
||||
Reference in New Issue
Block a user