Files
Gondulf/docs/designs/authorization-verification-fix.md
Phil Skentelbery 9135edfe84 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>
2025-11-22 15:16:26 -07:00

510 lines
17 KiB
Markdown

# Design Fix: Authorization Endpoint Domain Verification
**Date**: 2025-11-22
**Architect**: Claude (Architect Agent)
**Status**: CRITICAL - Ready for Immediate Implementation
**Priority**: P0 - Security Fix
## Problem Statement
The authorization endpoint (`GET /authorize`) is bypassing domain verification entirely. This allows anyone to authenticate as any domain without proving ownership, which is a critical security vulnerability.
### Current Behavior (BROKEN)
```
1. GET /authorize?me=https://example.com/&... -> 200 OK (consent page shown)
2. POST /authorize/consent -> 302 redirect with code
```
### Expected Behavior (Per Design)
```
1. GET /authorize?me=https://example.com/&...
2. Check if domain is verified in database
3a. If NOT verified:
- Verify DNS TXT record for _gondulf.{domain}
- Fetch user's homepage
- Discover email from rel="me" link
- Send 6-digit verification code to email
- Show code entry form
4. POST /authorize/verify-code with code
5. Validate code -> Store verified domain in database
6. Show consent page
7. POST /authorize/consent -> 302 redirect with authorization code
```
## Root Cause
In `/src/gondulf/routers/authorization.py`, lines 191-193:
```python
# Check if domain is verified
# For Phase 2, we'll show consent form immediately (domain verification happens separately)
# In Phase 3, we'll check database for verified domains
```
The implementation shows the consent form directly without any verification checks. The `DomainVerificationService` exists and has the required methods, but they are never called in the authorization flow.
## Design Fix
### Overview
The fix requires modifying the `GET /authorize` endpoint to:
1. Extract domain from `me` parameter
2. Check if domain is already verified (in database)
3. If not verified, initiate verification and show code entry form
4. After verification, show consent page
Additionally, a new endpoint `POST /authorize/verify-code` must be implemented to handle code submission during the authorization flow.
### Modified Authorization Flow
#### Step 1: Modify `GET /authorize` (authorization.py)
**Location**: `/src/gondulf/routers/authorization.py`, `authorize_get` function
**After line 189** (after me URL validation), insert domain verification logic:
```python
# Extract domain from me URL
domain = extract_domain_from_url(me)
# Check if domain is already verified
verification_service = Depends(get_verification_service)
# NOTE: Need to add verification_service to function parameters
# Query database for verified domain
is_verified = await check_domain_verified(database, domain)
if not is_verified:
# Start two-factor verification
result = verification_service.start_verification(domain, me)
if not result["success"]:
# Verification cannot start (DNS failed, no rel=me, etc)
return templates.TemplateResponse(
"verification_error.html",
{
"request": request,
"error": result["error"],
"domain": domain,
# Pass through auth params for retry
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
"response_type": effective_response_type,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope,
"me": me
},
status_code=200
)
# Verification started - show code entry form
return templates.TemplateResponse(
"verify_code.html",
{
"request": request,
"masked_email": result["email"],
"domain": domain,
# Pass through auth params
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
"response_type": effective_response_type,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope,
"me": me,
"client_metadata": client_metadata
}
)
# Domain is verified - show consent form (existing code from line 205)
```
#### Step 2: Add Database Check Function
**Location**: Add to `/src/gondulf/routers/authorization.py` or `/src/gondulf/utils/validation.py`
```python
async def check_domain_verified(database: Database, domain: str) -> bool:
"""
Check if domain is verified in the database.
Args:
database: Database service
domain: Domain to check (e.g., "example.com")
Returns:
True if domain is verified, False otherwise
"""
async with database.get_session() as session:
result = await session.execute(
"SELECT verified FROM domains WHERE domain = ? AND verified = 1",
(domain,)
)
row = result.fetchone()
return row is not None
```
#### Step 3: Add New Endpoint `POST /authorize/verify-code`
**Location**: `/src/gondulf/routers/authorization.py`
```python
@router.post("/authorize/verify-code")
async def authorize_verify_code(
request: Request,
domain: str = Form(...),
code: str = Form(...),
client_id: str = Form(...),
redirect_uri: str = Form(...),
response_type: str = Form("id"),
state: str = Form(...),
code_challenge: str = Form(...),
code_challenge_method: str = Form(...),
scope: str = Form(""),
me: str = Form(...),
database: Database = Depends(get_database),
verification_service: DomainVerificationService = Depends(get_verification_service),
happ_parser: HAppParser = Depends(get_happ_parser)
) -> HTMLResponse:
"""
Handle verification code submission during authorization flow.
This endpoint is called when user submits the 6-digit email verification code.
On success, shows consent page. On failure, shows code entry form with error.
Args:
domain: Domain being verified
code: 6-digit verification code from email
client_id, redirect_uri, etc: Authorization parameters (passed through)
Returns:
HTML response: consent page on success, code form with error on failure
"""
logger.info(f"Verification code submission for domain={domain}")
# Verify the code
result = verification_service.verify_email_code(domain, code)
if not result["success"]:
# Code invalid - show form again with error
# Need to get masked email again
email = verification_service.code_storage.get(f"email_addr:{domain}")
masked_email = mask_email(email) if email else "unknown"
return templates.TemplateResponse(
"verify_code.html",
{
"request": request,
"error": result["error"],
"masked_email": masked_email,
"domain": domain,
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": response_type,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope,
"me": me
},
status_code=200
)
# Code valid - store verified domain in database
await store_verified_domain(database, domain, result.get("email", ""))
logger.info(f"Domain verified successfully: {domain}")
# Fetch client metadata for consent page
client_metadata = None
try:
client_metadata = await happ_parser.fetch_and_parse(client_id)
except Exception as e:
logger.warning(f"Failed to fetch client metadata: {e}")
# Show consent form
return templates.TemplateResponse(
"authorize.html",
{
"request": request,
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": response_type,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope,
"me": me,
"client_metadata": client_metadata
}
)
```
#### Step 4: Add Store Verified Domain Function
```python
async def store_verified_domain(database: Database, domain: str, email: str) -> None:
"""
Store verified domain in database.
Args:
database: Database service
domain: Verified domain
email: Email used for verification (for audit)
"""
from datetime import datetime
async with database.get_session() as session:
await session.execute(
"""
INSERT OR REPLACE INTO domains
(domain, verification_method, verified, verified_at, last_dns_check)
VALUES (?, 'two_factor', 1, ?, ?)
""",
(domain, datetime.utcnow(), datetime.utcnow())
)
await session.commit()
logger.info(f"Stored verified domain: {domain}")
```
#### Step 5: Create New Template `verify_code.html`
**Location**: `/src/gondulf/templates/verify_code.html`
```html
{% extends "base.html" %}
{% block title %}Verify Your Identity - Gondulf{% endblock %}
{% block content %}
<h1>Verify Your Identity</h1>
<p>To sign in as <strong>{{ domain }}</strong>, please enter the verification code sent to <strong>{{ masked_email }}</strong>.</p>
{% if error %}
<div class="error">
<p>{{ error }}</p>
</div>
{% endif %}
<form method="POST" action="/authorize/verify-code">
<!-- Pass through authorization parameters -->
<input type="hidden" name="domain" value="{{ domain }}">
<input type="hidden" name="client_id" value="{{ client_id }}">
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
<input type="hidden" name="response_type" value="{{ response_type }}">
<input type="hidden" name="state" value="{{ state }}">
<input type="hidden" name="code_challenge" value="{{ code_challenge }}">
<input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}">
<input type="hidden" name="scope" value="{{ scope }}">
<input type="hidden" name="me" value="{{ me }}">
<div class="form-group">
<label for="code">Verification Code:</label>
<input type="text"
id="code"
name="code"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
inputmode="numeric"
autocomplete="one-time-code"
required
autofocus>
</div>
<button type="submit">Verify</button>
</form>
<p class="help-text">
Did not receive a code? Check your spam folder.
<a href="/authorize?client_id={{ client_id }}&redirect_uri={{ redirect_uri }}&response_type={{ response_type }}&state={{ state }}&code_challenge={{ code_challenge }}&code_challenge_method={{ code_challenge_method }}&scope={{ scope }}&me={{ me }}">
Request a new code
</a>
</p>
{% endblock %}
```
#### Step 6: Create Error Template `verification_error.html`
**Location**: `/src/gondulf/templates/verification_error.html`
```html
{% extends "base.html" %}
{% block title %}Verification Failed - Gondulf{% endblock %}
{% block content %}
<h1>Verification Failed</h1>
<div class="error">
<p>{{ error }}</p>
</div>
{% if "DNS" in error %}
<div class="instructions">
<h2>How to Fix</h2>
<p>Add the following DNS TXT record to your domain:</p>
<code>
Type: TXT<br>
Name: _gondulf.{{ domain }}<br>
Value: gondulf-verify-domain
</code>
<p>DNS changes may take up to 24 hours to propagate.</p>
</div>
{% endif %}
{% if "email" in error.lower() or "rel" in error.lower() %}
<div class="instructions">
<h2>How to Fix</h2>
<p>Add a rel="me" link to your homepage pointing to your email:</p>
<code>&lt;link rel="me" href="mailto:you@example.com"&gt;</code>
<p>Or as an anchor tag:</p>
<code>&lt;a rel="me" href="mailto:you@example.com"&gt;Email me&lt;/a&gt;</code>
</div>
{% endif %}
<p>
<a href="/authorize?client_id={{ client_id }}&redirect_uri={{ redirect_uri }}&response_type={{ response_type }}&state={{ state }}&code_challenge={{ code_challenge }}&code_challenge_method={{ code_challenge_method }}&scope={{ scope }}&me={{ me }}">
Try Again
</a>
</p>
{% endblock %}
```
### Changes to Existing Files
#### `/src/gondulf/routers/authorization.py`
1. **Add import for `get_verification_service`** at line 17:
```python
from gondulf.dependencies import get_code_storage, get_database, get_happ_parser, get_verification_service
```
2. **Add `verification_service` parameter to `authorize_get`** function signature (around line 57):
```python
verification_service: DomainVerificationService = Depends(get_verification_service)
```
3. **Replace lines 191-219** (the comment and consent form display) with the verification logic from Step 1 above.
4. **Add the new `authorize_verify_code` endpoint** after the `authorize_consent` function.
5. **Add helper functions** `check_domain_verified` and `store_verified_domain`.
#### `/src/gondulf/utils/validation.py`
Add `mask_email` function if not already present:
```python
def mask_email(email: str) -> str:
"""Mask email for display: user@example.com -> u***@example.com"""
if not email or '@' not in email:
return email or "unknown"
local, domain = email.split('@', 1)
if len(local) <= 1:
return f"{local}***@{domain}"
return f"{local[0]}***@{domain}"
```
### Data Flow After Fix
```
User/Client Gondulf DNS/Email
| | |
|-- GET /authorize --------->| |
| |-- Check DB for verified domain |
| | (not found) |
| |-- Query DNS TXT record ---------->|
| |<-- TXT: gondulf-verify-domain ---|
| |-- Fetch homepage --------------->|
| |<-- HTML with rel=me mailto ------|
| |-- Send verification email ------>|
|<-- Show verify_code.html --| |
| | |
|-- POST /verify-code ------>| |
| (code: 123456) |-- Verify code (storage check) |
| |-- Store verified domain (DB) |
|<-- Show authorize.html ----| |
| | |
|-- POST /authorize/consent->| |
|<-- 302 redirect with code -| |
```
### Testing Requirements
The fix must include the following tests:
#### Unit Tests
- [ ] `test_authorize_unverified_domain_starts_verification`
- [ ] `test_authorize_verified_domain_shows_consent`
- [ ] `test_verify_code_valid_code_shows_consent`
- [ ] `test_verify_code_invalid_code_shows_error`
- [ ] `test_verify_code_expired_code_shows_error`
- [ ] `test_verify_code_stores_domain_on_success`
- [ ] `test_verification_dns_failure_shows_instructions`
- [ ] `test_verification_no_relme_shows_instructions`
#### Integration Tests
- [ ] `test_full_verification_flow_new_domain`
- [ ] `test_full_authorization_flow_verified_domain`
- [ ] `test_verification_code_retry_with_correct_code`
### Acceptance Criteria
The fix is complete when:
1. **Security**
- [ ] Unverified domains NEVER see the consent page directly
- [ ] DNS TXT record verification is performed for new domains
- [ ] Email verification via rel="me" is required for new domains
- [ ] Verified domains are stored in the database
- [ ] Subsequent authentications skip verification for stored domains
2. **Functionality**
- [ ] Code entry form displays with masked email
- [ ] Invalid codes show error with retry option
- [ ] Verification errors show clear instructions
- [ ] All authorization parameters preserved through verification flow
- [ ] State parameter passed through correctly
3. **Testing**
- [ ] All unit tests pass
- [ ] All integration tests pass
- [ ] Manual testing confirms the flow works end-to-end
## Implementation Order
1. Add `mask_email` to validation utils (if missing)
2. Create `verify_code.html` template
3. Create `verification_error.html` template
4. Add `check_domain_verified` function
5. Add `store_verified_domain` function
6. Modify `authorize_get` to include verification check
7. Add `authorize_verify_code` endpoint
8. Write and run tests
9. Manual end-to-end testing
## Estimated Effort
**Time**: 1-2 days
- Template creation: 0.25 days
- Authorization endpoint modification: 0.5 days
- New verify-code endpoint: 0.25 days
- Testing: 0.5 days
- Integration testing: 0.25 days
## Sign-off
**Design Status**: Ready for immediate implementation
**Architect**: Claude (Architect Agent)
**Date**: 2025-11-22
**DESIGN READY: Authorization Verification Fix - Please implement immediately**
This is a P0 security fix. Do not deploy to production until this is resolved.