Implement complete authentication system following ADR-010 and Phase 3 design specs. This is a MINOR version increment (0.3.0 -> 0.4.0) as it adds new functionality. Authentication Features: - IndieLogin authentication flow via indielogin.com - Secure session management with SHA-256 token hashing - CSRF protection with single-use state tokens - Session lifecycle (create, verify, destroy) - require_auth decorator for protected routes - Automatic cleanup of expired sessions - IP address and user agent tracking Security Measures: - Cryptographically secure token generation (secrets module) - Token hashing for storage (never plaintext) - SQL injection prevention (prepared statements) - Single-use CSRF state tokens - 30-day session expiry with activity refresh - Comprehensive security logging Implementation Details: - starpunk/auth.py: 406 lines, 6 core functions, 4 helpers, 4 exceptions - tests/test_auth.py: 648 lines, 37 tests, 96% coverage - Database schema updates for sessions and auth_state tables - URL validation utility added to utils.py Test Coverage: - 37 authentication tests - 96% code coverage (exceeds 90% target) - All security features tested - Edge cases and error paths covered Documentation: - Implementation report in docs/reports/ - Updated CHANGELOG.md with detailed changes - Version incremented to 0.4.0 - ADR-010 and Phase 3 design docs included Follows project standards: - Black code formatting (88 char lines) - Flake8 linting (no errors) - Python coding standards - Type hints on all functions - Comprehensive docstrings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
545 lines
15 KiB
Markdown
545 lines
15 KiB
Markdown
# Phase 3: Authentication Implementation Design
|
|
|
|
**Version**: 1.0
|
|
**Date**: 2025-11-18
|
|
**Architect**: StarPunk Architect Agent
|
|
**Target Module**: `starpunk/auth.py`
|
|
**Estimated LOC**: 250-300 lines
|
|
**Dependencies**: `database.py`, `utils.py`, `httpx`, `secrets`
|
|
|
|
## Executive Summary
|
|
|
|
This document provides the complete implementation design for Phase 3: Authentication. The module implements IndieLogin-based authentication with secure session management, CSRF protection, and single-admin authorization. This is a CRITICAL PATH component required before Phase 4 (Web Interface).
|
|
|
|
## Design Goals
|
|
|
|
1. **Zero Password Complexity** - No local password storage or management
|
|
2. **Industry-Standard Security** - Token hashing, CSRF protection, secure cookies
|
|
3. **Minimal Code** - Single module, ~300 lines total
|
|
4. **Full Test Coverage** - Target 90%+ coverage with security focus
|
|
5. **Production Ready** - Proper error handling, logging, session management
|
|
|
|
## Module Structure
|
|
|
|
### File: `starpunk/auth.py`
|
|
|
|
```python
|
|
"""
|
|
Authentication module for StarPunk
|
|
|
|
Implements IndieLogin authentication for admin access using indielogin.com
|
|
as a delegated authentication provider. No passwords are stored locally.
|
|
|
|
Security features:
|
|
- CSRF protection via state tokens
|
|
- Secure session tokens (cryptographically random)
|
|
- Token hashing in database (SHA-256)
|
|
- HttpOnly, Secure, SameSite cookies
|
|
- Single-admin authorization
|
|
- Automatic session cleanup
|
|
|
|
Functions:
|
|
initiate_login: Start IndieLogin authentication flow
|
|
handle_callback: Process IndieLogin callback
|
|
create_session: Create authenticated session
|
|
verify_session: Check if session is valid
|
|
destroy_session: Logout and cleanup
|
|
require_auth: Decorator for protected routes
|
|
|
|
Exceptions:
|
|
AuthError: Base authentication exception
|
|
InvalidStateError: CSRF state validation failed
|
|
UnauthorizedError: User not authorized as admin
|
|
IndieLoginError: External service error
|
|
"""
|
|
```
|
|
|
|
## Database Schema Updates
|
|
|
|
Add to `starpunk/database.py`:
|
|
|
|
```sql
|
|
-- Session management
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_token_hash TEXT UNIQUE NOT NULL, -- SHA-256 hash
|
|
me TEXT NOT NULL, -- User's IndieWeb URL
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
last_used_at TIMESTAMP,
|
|
user_agent TEXT, -- For session info
|
|
ip_address TEXT -- For security audit
|
|
);
|
|
|
|
CREATE INDEX idx_sessions_token_hash ON sessions(session_token_hash);
|
|
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
|
CREATE INDEX idx_sessions_me ON sessions(me);
|
|
|
|
-- CSRF state tokens
|
|
CREATE TABLE IF NOT EXISTS auth_state (
|
|
state TEXT PRIMARY KEY,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
redirect_uri TEXT
|
|
);
|
|
|
|
CREATE INDEX idx_auth_state_expires_at ON auth_state(expires_at);
|
|
```
|
|
|
|
## Core Functions Design
|
|
|
|
### 1. `initiate_login(me_url: str) -> str`
|
|
|
|
**Purpose**: Start the IndieLogin authentication flow
|
|
|
|
**Implementation**:
|
|
```python
|
|
def initiate_login(me_url: str) -> str:
|
|
"""
|
|
Initiate IndieLogin authentication flow
|
|
|
|
Args:
|
|
me_url: User's IndieWeb identity URL
|
|
|
|
Returns:
|
|
Redirect URL to IndieLogin.com
|
|
|
|
Raises:
|
|
ValueError: Invalid me_url format
|
|
"""
|
|
# Validate URL format
|
|
if not is_valid_url(me_url):
|
|
raise ValueError("Invalid URL format")
|
|
|
|
# Generate CSRF state token
|
|
state = secrets.token_urlsafe(32)
|
|
|
|
# Store state in database (5-minute expiry)
|
|
db = get_db()
|
|
expires_at = datetime.utcnow() + timedelta(minutes=5)
|
|
|
|
db.execute("""
|
|
INSERT INTO auth_state (state, expires_at, redirect_uri)
|
|
VALUES (?, ?, ?)
|
|
""", (state, expires_at, f"{SITE_URL}/auth/callback"))
|
|
db.commit()
|
|
|
|
# Build IndieLogin URL
|
|
params = {
|
|
'me': me_url,
|
|
'client_id': current_app.config['SITE_URL'],
|
|
'redirect_uri': f"{current_app.config['SITE_URL']}/auth/callback",
|
|
'state': state,
|
|
'response_type': 'code'
|
|
}
|
|
|
|
auth_url = f"https://indielogin.com/auth?{urlencode(params)}"
|
|
|
|
# Log authentication attempt
|
|
current_app.logger.info(f"Auth initiated for {me_url}")
|
|
|
|
return auth_url
|
|
```
|
|
|
|
### 2. `handle_callback(code: str, state: str) -> Optional[str]`
|
|
|
|
**Purpose**: Process IndieLogin callback and create session
|
|
|
|
**Implementation**:
|
|
```python
|
|
def handle_callback(code: str, state: str) -> Optional[str]:
|
|
"""
|
|
Handle IndieLogin callback
|
|
|
|
Args:
|
|
code: Authorization code from IndieLogin
|
|
state: CSRF state token
|
|
|
|
Returns:
|
|
Session token if successful, None otherwise
|
|
|
|
Raises:
|
|
InvalidStateError: State token validation failed
|
|
UnauthorizedError: User not authorized as admin
|
|
IndieLoginError: Code exchange failed
|
|
"""
|
|
# Verify state token (CSRF protection)
|
|
if not _verify_state_token(state):
|
|
raise InvalidStateError("Invalid or expired state token")
|
|
|
|
# Exchange code for identity
|
|
try:
|
|
response = httpx.post('https://indielogin.com/auth',
|
|
data={
|
|
'code': code,
|
|
'client_id': current_app.config['SITE_URL'],
|
|
'redirect_uri': f"{current_app.config['SITE_URL']}/auth/callback"
|
|
},
|
|
timeout=10.0
|
|
)
|
|
response.raise_for_status()
|
|
except httpx.RequestError as e:
|
|
raise IndieLoginError(f"Failed to verify code: {e}")
|
|
|
|
# Parse response
|
|
data = response.json()
|
|
me = data.get('me')
|
|
|
|
if not me:
|
|
raise IndieLoginError("No identity returned from IndieLogin")
|
|
|
|
# Verify this is the admin user
|
|
if me != current_app.config['ADMIN_ME']:
|
|
current_app.logger.warning(f"Unauthorized login attempt: {me}")
|
|
raise UnauthorizedError(f"User {me} is not authorized")
|
|
|
|
# Create session
|
|
session_token = create_session(me)
|
|
|
|
return session_token
|
|
```
|
|
|
|
### 3. `create_session(me: str) -> str`
|
|
|
|
**Purpose**: Create a new authenticated session
|
|
|
|
**Implementation**:
|
|
```python
|
|
def create_session(me: str) -> str:
|
|
"""
|
|
Create authenticated session
|
|
|
|
Args:
|
|
me: Verified user identity URL
|
|
|
|
Returns:
|
|
Session token (plaintext, to be sent as cookie)
|
|
"""
|
|
# Generate secure token
|
|
session_token = secrets.token_urlsafe(32)
|
|
token_hash = _hash_token(session_token)
|
|
|
|
# Calculate expiry (30 days)
|
|
expires_at = datetime.utcnow() + timedelta(days=30)
|
|
|
|
# Get request metadata
|
|
user_agent = request.headers.get('User-Agent', '')[:200]
|
|
ip_address = request.remote_addr
|
|
|
|
# Store in database
|
|
db = get_db()
|
|
db.execute("""
|
|
INSERT INTO sessions
|
|
(session_token_hash, me, expires_at, user_agent, ip_address)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
""", (token_hash, me, expires_at, user_agent, ip_address))
|
|
db.commit()
|
|
|
|
# Cleanup expired sessions
|
|
_cleanup_expired_sessions()
|
|
|
|
# Log session creation
|
|
current_app.logger.info(f"Session created for {me}")
|
|
|
|
return session_token
|
|
```
|
|
|
|
### 4. `verify_session(token: str) -> Optional[Dict[str, Any]]`
|
|
|
|
**Purpose**: Validate session token and return user info
|
|
|
|
**Implementation**:
|
|
```python
|
|
def verify_session(token: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Verify session token
|
|
|
|
Args:
|
|
token: Session token from cookie
|
|
|
|
Returns:
|
|
Session info dict if valid, None otherwise
|
|
"""
|
|
if not token:
|
|
return None
|
|
|
|
token_hash = _hash_token(token)
|
|
|
|
db = get_db()
|
|
session = db.execute("""
|
|
SELECT id, me, created_at, expires_at, last_used_at
|
|
FROM sessions
|
|
WHERE session_token_hash = ?
|
|
AND expires_at > datetime('now')
|
|
""", (token_hash,)).fetchone()
|
|
|
|
if not session:
|
|
return None
|
|
|
|
# Update last_used_at for activity tracking
|
|
db.execute("""
|
|
UPDATE sessions
|
|
SET last_used_at = datetime('now')
|
|
WHERE id = ?
|
|
""", (session['id'],))
|
|
db.commit()
|
|
|
|
return {
|
|
'me': session['me'],
|
|
'created_at': session['created_at'],
|
|
'expires_at': session['expires_at']
|
|
}
|
|
```
|
|
|
|
### 5. `require_auth` Decorator
|
|
|
|
**Purpose**: Protect routes that require authentication
|
|
|
|
**Implementation**:
|
|
```python
|
|
def require_auth(f):
|
|
"""
|
|
Decorator to require authentication for a route
|
|
|
|
Usage:
|
|
@app.route('/admin')
|
|
@require_auth
|
|
def admin_dashboard():
|
|
return render_template('admin/dashboard.html')
|
|
"""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
# Get session token from cookie
|
|
session_token = request.cookies.get('session')
|
|
|
|
# Verify session
|
|
session_info = verify_session(session_token)
|
|
|
|
if not session_info:
|
|
# Store intended destination
|
|
session['next'] = request.url
|
|
return redirect(url_for('auth.login'))
|
|
|
|
# Store user info in g for use in views
|
|
g.user = session_info
|
|
g.me = session_info['me']
|
|
|
|
return f(*args, **kwargs)
|
|
|
|
return decorated_function
|
|
```
|
|
|
|
## Helper Functions
|
|
|
|
### `_hash_token(token: str) -> str`
|
|
```python
|
|
def _hash_token(token: str) -> str:
|
|
"""Hash token using SHA-256"""
|
|
import hashlib
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
```
|
|
|
|
### `_verify_state_token(state: str) -> bool`
|
|
```python
|
|
def _verify_state_token(state: str) -> bool:
|
|
"""Verify and consume CSRF state token"""
|
|
db = get_db()
|
|
|
|
# Check if state exists and not expired
|
|
result = db.execute("""
|
|
SELECT 1 FROM auth_state
|
|
WHERE state = ? AND expires_at > datetime('now')
|
|
""", (state,)).fetchone()
|
|
|
|
if not result:
|
|
return False
|
|
|
|
# Delete state (single-use)
|
|
db.execute("DELETE FROM auth_state WHERE state = ?", (state,))
|
|
db.commit()
|
|
|
|
return True
|
|
```
|
|
|
|
### `_cleanup_expired_sessions() -> None`
|
|
```python
|
|
def _cleanup_expired_sessions() -> None:
|
|
"""Remove expired sessions and state tokens"""
|
|
db = get_db()
|
|
|
|
# Delete expired sessions
|
|
db.execute("""
|
|
DELETE FROM sessions
|
|
WHERE expires_at <= datetime('now')
|
|
""")
|
|
|
|
# Delete expired state tokens
|
|
db.execute("""
|
|
DELETE FROM auth_state
|
|
WHERE expires_at <= datetime('now')
|
|
""")
|
|
|
|
db.commit()
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
### Custom Exceptions
|
|
```python
|
|
class AuthError(Exception):
|
|
"""Base exception for authentication errors"""
|
|
pass
|
|
|
|
class InvalidStateError(AuthError):
|
|
"""CSRF state validation failed"""
|
|
pass
|
|
|
|
class UnauthorizedError(AuthError):
|
|
"""User not authorized as admin"""
|
|
pass
|
|
|
|
class IndieLoginError(AuthError):
|
|
"""IndieLogin service error"""
|
|
pass
|
|
```
|
|
|
|
### Error Responses
|
|
- **Invalid state**: Return 400 with "Invalid authentication state"
|
|
- **Unauthorized**: Return 403 with "Access denied"
|
|
- **IndieLogin error**: Return 502 with "Authentication service error"
|
|
- **Session expired**: Redirect to login with flash message
|
|
|
|
## Security Considerations
|
|
|
|
### Token Security
|
|
1. **Generation**: Use `secrets.token_urlsafe(32)` (256 bits entropy)
|
|
2. **Storage**: Store SHA-256 hash, never plaintext
|
|
3. **Transmission**: HttpOnly, Secure, SameSite=Lax cookies
|
|
4. **Rotation**: New token on each login
|
|
|
|
### CSRF Protection
|
|
1. **State tokens**: Random, single-use, 5-minute expiry
|
|
2. **Validation**: Check state before code exchange
|
|
3. **Cleanup**: Delete after use
|
|
|
|
### Session Security
|
|
1. **Expiry**: 30 days with activity refresh
|
|
2. **Invalidation**: Explicit logout deletes session
|
|
3. **Metadata**: Store IP and user agent for audit
|
|
4. **Cleanup**: Periodic removal of expired sessions
|
|
|
|
## Testing Requirements
|
|
|
|
### Unit Tests (`tests/test_auth.py`)
|
|
|
|
1. **Authentication Flow**
|
|
- Test successful login flow
|
|
- Test invalid me_url rejection
|
|
- Test state token generation
|
|
- Test state token expiry
|
|
|
|
2. **Callback Handling**
|
|
- Test successful callback
|
|
- Test invalid state rejection
|
|
- Test unauthorized user rejection
|
|
- Test IndieLogin error handling
|
|
|
|
3. **Session Management**
|
|
- Test session creation
|
|
- Test session verification
|
|
- Test session expiry
|
|
- Test session destruction
|
|
|
|
4. **Security Tests**
|
|
- Test token hashing
|
|
- Test CSRF protection
|
|
- Test SQL injection prevention
|
|
- Test path traversal attempts
|
|
|
|
5. **Decorator Tests**
|
|
- Test require_auth with valid session
|
|
- Test require_auth with expired session
|
|
- Test require_auth with no session
|
|
|
|
### Integration Tests
|
|
- Mock IndieLogin.com responses
|
|
- Test full authentication flow
|
|
- Test error scenarios
|
|
- Test session persistence
|
|
|
|
## Configuration Requirements
|
|
|
|
Required environment variables:
|
|
```bash
|
|
# .env
|
|
SITE_URL=https://starpunk.example.com
|
|
ADMIN_ME=https://yoursite.com
|
|
SESSION_SECRET=<random-32-byte-hex>
|
|
INDIELOGIN_URL=https://indielogin.com # Optional override
|
|
```
|
|
|
|
## Implementation Checklist
|
|
|
|
- [ ] Create `starpunk/auth.py` module
|
|
- [ ] Add session tables to `database.py`
|
|
- [ ] Implement `initiate_login` function
|
|
- [ ] Implement `handle_callback` function
|
|
- [ ] Implement `create_session` function
|
|
- [ ] Implement `verify_session` function
|
|
- [ ] Implement `destroy_session` function
|
|
- [ ] Create `require_auth` decorator
|
|
- [ ] Add helper functions
|
|
- [ ] Add exception classes
|
|
- [ ] Write unit tests (90% coverage target)
|
|
- [ ] Write integration tests
|
|
- [ ] Add security logging
|
|
- [ ] Update configuration documentation
|
|
- [ ] Security audit checklist
|
|
|
|
## Acceptance Criteria
|
|
|
|
1. **Functional Requirements**
|
|
- Admin can login via IndieLogin
|
|
- Only configured admin can authenticate
|
|
- Sessions persist across server restarts
|
|
- Logout destroys session
|
|
- Protected routes require authentication
|
|
|
|
2. **Security Requirements**
|
|
- All tokens properly hashed
|
|
- CSRF protection working
|
|
- No SQL injection vulnerabilities
|
|
- Sessions expire after 30 days
|
|
- Failed logins are logged
|
|
|
|
3. **Performance Requirements**
|
|
- Login completes in < 3 seconds
|
|
- Session verification < 10ms
|
|
- Cleanup doesn't block requests
|
|
|
|
4. **Quality Requirements**
|
|
- 90%+ test coverage
|
|
- All functions documented
|
|
- Security best practices followed
|
|
- Error messages are helpful
|
|
|
|
## Next Steps
|
|
|
|
After Phase 3 completion:
|
|
1. **Phase 4**: Web Interface (public and admin routes)
|
|
2. **Phase 5**: RSS Feed generation
|
|
3. **Phase 6**: Micropub endpoint
|
|
|
|
## References
|
|
|
|
- [ADR-005: IndieLogin Authentication](/home/phil/Projects/starpunk/docs/decisions/ADR-005-indielogin-authentication.md)
|
|
- [ADR-010: Authentication Module Design](/home/phil/Projects/starpunk/docs/decisions/ADR-010-authentication-module-design.md)
|
|
- [IndieAuth Specification](https://indieauth.spec.indieweb.org/)
|
|
- [IndieLogin API Documentation](https://indielogin.com/api)
|
|
- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|
|
|
|
---
|
|
|
|
**Document Version**: 1.0
|
|
**Last Updated**: 2025-11-18
|
|
**Status**: Ready for Implementation |