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>
510 lines
17 KiB
Markdown
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><link rel="me" href="mailto:you@example.com"></code>
|
|
<p>Or as an anchor tag:</p>
|
|
<code><a rel="me" href="mailto:you@example.com">Email me</a></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.
|