feat: Implement Phase 3 authentication module with IndieLogin support

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>
This commit is contained in:
2025-11-18 20:35:36 -07:00
parent a68fd570c7
commit d4f1bfb198
10 changed files with 2926 additions and 10 deletions

View File

@@ -0,0 +1,545 @@
# 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

View File

@@ -0,0 +1,631 @@
# Phase 3: Authentication Design
## Overview
This document provides a complete, implementation-ready design for Phase 3 of the StarPunk V1 implementation: Authentication with IndieLogin. The authentication module (`starpunk/auth.py`) implements session-based authentication for the admin interface using IndieLogin.com as a delegated authentication provider.
**Priority**: CRITICAL - Required for admin functionality
**Estimated Effort**: 4-6 hours
**Dependencies**: `starpunk/database.py`, `starpunk/utils.py`
**File**: `starpunk/auth.py`
## Design Principles
1. **Zero Password Storage** - No passwords stored or managed locally
2. **Delegated Authentication** - IndieLogin.com handles identity verification
3. **Session-Based** - HTTP-only secure cookies for session management
4. **CSRF Protection** - State tokens prevent cross-site request forgery
5. **Single Admin User** - Simplified authorization (V1 requirement)
6. **Standards Compliance** - Full IndieAuth/OAuth 2.0 compatibility
## Authentication Flow
### Overview
StarPunk uses IndieLogin.com for authentication, which implements the IndieAuth protocol. This allows users to authenticate using their personal website as their identity.
```
User → StarPunk → IndieLogin.com → User's Website → IndieLogin.com → StarPunk
```
### Detailed Flow
```mermaid
sequenceDiagram
participant U as User
participant S as StarPunk
participant I as IndieLogin.com
participant W as User's Website
U->>S: GET /admin/login
S->>U: Show login form
U->>S: POST /admin/login (me=https://alice.com)
S->>S: Generate state token
S->>S: Store state in database
S->>U: Redirect to IndieLogin
U->>I: GET /auth (with params)
I->>W: Verify identity (rel=me)
I->>U: Show verification options
U->>I: Authenticate
I->>U: Redirect to callback
U->>S: GET /auth/callback (code, state)
S->>S: Verify state token
S->>I: POST /auth (exchange code)
I->>S: Return verified identity
S->>S: Verify me == ADMIN_ME
S->>S: Create session
S->>U: Set cookie, redirect to /admin
```
## Module Structure
```python
"""
Authentication module for StarPunk
Implements IndieLogin authentication for admin access and session management.
All authentication is delegated to IndieLogin.com - no passwords are stored.
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
Classes:
AuthError: Base authentication exception
InvalidStateError: CSRF state validation failed
UnauthorizedError: User not authorized as admin
"""
# Standard library imports
import secrets
from datetime import datetime, timedelta
from functools import wraps
from typing import Optional, Dict, Any
from urllib.parse import urlencode, quote_plus
# Third-party imports
from flask import (
current_app, session, request, redirect,
url_for, abort, g
)
import httpx
# Local imports
from starpunk.database import get_db
class AuthError(Exception):
"""Base exception for authentication errors"""
pass
class InvalidStateError(AuthError):
"""CSRF state token validation failed"""
pass
class UnauthorizedError(AuthError):
"""User is not authorized as admin"""
pass
```
## Core Functions
### 1. initiate_login()
```python
def initiate_login(me: str) -> str:
"""
Initiate IndieLogin authentication flow
Generates a CSRF state token, stores it in the database, and returns
the authorization URL to redirect the user to IndieLogin.com.
Args:
me: The user's website URL (their identity)
Returns:
Authorization URL for redirect
Raises:
ValueError: If me is not a valid URL
AuthError: If state generation fails
Example:
>>> url = initiate_login("https://alice.example.com")
>>> # Redirect user to url
"""
# 1. Validate URL format
if not me.startswith(('http://', 'https://')):
raise ValueError(f"Invalid URL format: {me}")
# Normalize URL (remove trailing slash)
me = me.rstrip('/')
# 2. Generate state token (CSRF protection)
state = secrets.token_urlsafe(32)
# 3. Store state in database with expiry
db = get_db()
expires_at = datetime.utcnow() + timedelta(minutes=5)
try:
db.execute(
"INSERT INTO auth_state (state, expires_at) VALUES (?, ?)",
(state, expires_at)
)
db.commit()
except Exception as e:
raise AuthError(f"Failed to store state: {e}")
# 4. Build authorization URL
client_id = current_app.config['SITE_URL']
redirect_uri = f"{client_id}/auth/callback"
params = {
'me': me,
'client_id': client_id,
'redirect_uri': redirect_uri,
'state': state,
'response_type': 'code'
}
auth_url = f"https://indielogin.com/auth?{urlencode(params)}"
return auth_url
```
### 2. handle_callback()
```python
def handle_callback(code: str, state: str) -> Dict[str, Any]:
"""
Handle IndieLogin callback and verify identity
Validates the state token, exchanges the authorization code for
the verified identity, and checks if the user is authorized.
Args:
code: Authorization code from IndieLogin
state: State token for CSRF verification
Returns:
Dict with verified 'me' URL and any profile info
Raises:
InvalidStateError: If state doesn't match
UnauthorizedError: If user is not the admin
AuthError: If verification fails
"""
# 1. Verify state token
db = get_db()
# Get stored state and check expiry
row = db.execute(
"""
SELECT expires_at FROM auth_state
WHERE state = ? AND expires_at > datetime('now')
""",
(state,)
).fetchone()
if row is None:
raise InvalidStateError("Invalid or expired state token")
# Delete used state (one-time use)
db.execute("DELETE FROM auth_state WHERE state = ?", (state,))
db.commit()
# 2. Exchange code for verified identity
client_id = current_app.config['SITE_URL']
redirect_uri = f"{client_id}/auth/callback"
token_endpoint = "https://indielogin.com/auth"
try:
with httpx.Client(timeout=10.0) as client:
response = client.post(
token_endpoint,
data={
'code': code,
'client_id': client_id,
'redirect_uri': redirect_uri
},
headers={'Accept': 'application/json'}
)
response.raise_for_status()
data = response.json()
except Exception as e:
raise AuthError(f"Failed to verify identity: {e}")
# 3. Extract verified identity
me = data.get('me', '').rstrip('/')
if not me:
raise AuthError("No identity returned from IndieLogin")
# 4. Check authorization (admin only)
admin_me = current_app.config['ADMIN_ME'].rstrip('/')
if me != admin_me:
raise UnauthorizedError(
f"User {me} is not authorized. Only {admin_me} can access admin."
)
return {
'me': me,
'profile': data.get('profile', {})
}
```
### 3. create_session()
```python
def create_session(me: str) -> str:
"""
Create authenticated session
Generates a session token, stores it in the database, and returns
the token to be set as a secure cookie.
Args:
me: The verified user identity URL
Returns:
Session token for cookie
Example:
>>> token = create_session("https://alice.example.com")
>>> # Set cookie with token
"""
# 1. Generate session token
session_token = secrets.token_urlsafe(32)
# 2. Store in database
db = get_db()
created_at = datetime.utcnow()
expires_at = created_at + timedelta(days=30)
db.execute(
"""
INSERT INTO sessions
(session_token, me, created_at, expires_at, last_accessed)
VALUES (?, ?, ?, ?, ?)
""",
(session_token, me, created_at, expires_at, created_at)
)
db.commit()
return session_token
```
### 4. verify_session()
```python
def verify_session(session_token: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
Verify if session is valid
Checks the session token from cookie or parameter, verifies it
exists and hasn't expired, and updates last access time.
Args:
session_token: Token to verify (default: from cookie)
Returns:
Session data dict if valid, None if invalid
Example:
>>> session = verify_session()
>>> if session:
... print(f"Logged in as {session['me']}")
"""
# Get token from parameter or cookie
if session_token is None:
session_token = request.cookies.get('session_token')
if not session_token:
return None
# Query database
db = get_db()
row = db.execute(
"""
SELECT id, me, created_at, expires_at
FROM sessions
WHERE session_token = ? AND expires_at > datetime('now')
""",
(session_token,)
).fetchone()
if row is None:
return None
# Update last access time
db.execute(
"UPDATE sessions SET last_accessed = ? WHERE id = ?",
(datetime.utcnow(), row['id'])
)
db.commit()
return {
'id': row['id'],
'me': row['me'],
'created_at': row['created_at'],
'expires_at': row['expires_at']
}
```
### 5. destroy_session()
```python
def destroy_session(session_token: Optional[str] = None) -> None:
"""
Destroy session (logout)
Removes session from database. Token can be provided or taken
from cookie.
Args:
session_token: Token to destroy (default: from cookie)
"""
if session_token is None:
session_token = request.cookies.get('session_token')
if session_token:
db = get_db()
db.execute(
"DELETE FROM sessions WHERE session_token = ?",
(session_token,)
)
db.commit()
```
### 6. require_auth Decorator
```python
def require_auth(f):
"""
Decorator to protect routes requiring authentication
Verifies session and adds user info to g.user. Redirects to
login if not authenticated.
Example:
@app.route('/admin')
@require_auth
def admin_dashboard():
# g.user contains session info
return render_template('admin/dashboard.html')
"""
@wraps(f)
def decorated_function(*args, **kwargs):
session = verify_session()
if session is None:
# Store intended destination
session['next'] = request.url
return redirect(url_for('admin.login'))
# Add to Flask globals
g.user = session
return f(*args, **kwargs)
return decorated_function
```
## Helper Functions
### cleanup_expired_sessions()
```python
def cleanup_expired_sessions() -> int:
"""
Remove expired sessions and state tokens
Should be called periodically (e.g., daily cron job).
Returns:
Number of records deleted
"""
db = get_db()
# Delete expired sessions
result1 = db.execute(
"DELETE FROM sessions WHERE expires_at < datetime('now')"
)
# Delete expired state tokens
result2 = db.execute(
"DELETE FROM auth_state WHERE expires_at < datetime('now')"
)
db.commit()
return result1.rowcount + result2.rowcount
```
### extend_session()
```python
def extend_session(session_token: str, days: int = 30) -> None:
"""
Extend session expiry time
Used to keep active users logged in.
Args:
session_token: Token to extend
days: Number of days to extend
"""
db = get_db()
new_expiry = datetime.utcnow() + timedelta(days=days)
db.execute(
"UPDATE sessions SET expires_at = ? WHERE session_token = ?",
(new_expiry, session_token)
)
db.commit()
```
## Security Considerations
### Session Security
1. **Token Generation**: Use `secrets.token_urlsafe(32)` for cryptographically secure tokens
2. **Cookie Flags**:
- `HttpOnly`: Prevent JavaScript access
- `Secure`: HTTPS only (production)
- `SameSite=Lax`: CSRF protection
3. **Token Storage**: Never store raw tokens, consider hashing in future
4. **Expiry**: 30-day default, extendable on activity
### CSRF Protection
1. **State Tokens**: Random token for each auth attempt
2. **Single Use**: State deleted after verification
3. **Short Expiry**: 5-minute validity window
4. **Database Storage**: Prevents replay attacks
### Authorization
1. **Single Admin**: Only ADMIN_ME from config can authenticate
2. **URL Normalization**: Strip trailing slashes for comparison
3. **Strict Matching**: Exact match required (no wildcards)
## Integration Points
### Configuration Required
```python
# In .env file
SITE_URL=https://starpunk.example.com
ADMIN_ME=https://alice.example.com
SESSION_SECRET=<64-character-hex-string>
```
### Database Tables Used
- `sessions`: Store active sessions
- `auth_state`: Store CSRF state tokens
### Routes to Implement (Phase 4)
```python
# In starpunk/routes/admin.py
@app.route('/admin/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
me = request.form.get('me')
auth_url = initiate_login(me)
return redirect(auth_url)
return render_template('admin/login.html')
@app.route('/auth/callback')
def callback():
code = request.args.get('code')
state = request.args.get('state')
try:
user = handle_callback(code, state)
token = create_session(user['me'])
response = redirect(url_for('admin.dashboard'))
response.set_cookie(
'session_token',
token,
httponly=True,
secure=current_app.config['ENV'] == 'production',
samesite='Lax',
max_age=30*24*60*60 # 30 days
)
return response
except AuthError as e:
flash(str(e))
return redirect(url_for('admin.login'))
@app.route('/admin/logout')
@require_auth
def logout():
destroy_session()
response = redirect(url_for('public.index'))
response.delete_cookie('session_token')
return response
```
## Testing Strategy
### Test Categories
1. **Authentication Flow Tests**
- State token generation and storage
- Callback handling with valid code
- Invalid state rejection
- Expired state cleanup
2. **Session Management Tests**
- Session creation
- Session verification
- Session destruction
- Session expiry
3. **Authorization Tests**
- Admin user accepted
- Non-admin rejected
- URL normalization
4. **Security Tests**
- CSRF protection
- Token uniqueness
- Cookie security flags
### Example Tests
```python
def test_initiate_login(app, client):
"""Test login initiation"""
url = initiate_login("https://alice.example.com")
assert "indielogin.com" in url
assert "state=" in url
def test_require_auth_decorator(app, client):
"""Test auth decorator redirects"""
@require_auth
def protected():
return "Protected"
# Without session
response = protected()
assert response.status_code == 302
assert "/login" in response.location
```
## Acceptance Criteria
Phase 3 is complete when:
- [ ] All authentication functions implemented
- [ ] State token CSRF protection working
- [ ] Session management functional
- [ ] require_auth decorator protects routes
- [ ] Expired session cleanup implemented
- [ ] Integration with IndieLogin.com tested
- [ ] Security measures in place (cookie flags, etc.)
- [ ] Error handling comprehensive
- [ ] Test coverage >90%
- [ ] Documentation complete
## Next Steps
After Phase 3:
1. **Phase 4**: Web Routes and Templates
2. **Phase 5**: Micropub Implementation
3. **Phase 6**: RSS Feed Generation
Authentication provides the foundation for the admin interface and API security.