fix(security): require domain verification before authorization

CRITICAL SECURITY FIX: The authorization endpoint was bypassing domain
verification entirely, allowing anyone to authenticate as any domain.

Changes:
- Add domain verification check in GET /authorize before showing consent
- Add POST /authorize/verify-code endpoint for code validation
- Add verify_code.html and verification_error.html templates
- Add check_domain_verified() and store_verified_domain() functions
- Preserve OAuth parameters through verification flow

Flow for unverified domains:
1. GET /authorize -> Check DB for verified domain
2. If not verified: start 2FA (DNS + email) -> show code entry form
3. POST /authorize/verify-code -> validate code -> store verified
4. Show consent page
5. POST /authorize/consent -> issue authorization code

Verified domains skip directly to consent page.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-22 12:45:59 -07:00
parent 052d3ad3e1
commit 8dddc73826
4 changed files with 825 additions and 8 deletions

View File

@@ -5,6 +5,7 @@ Supports both IndieAuth flows per W3C specification:
- Authorization (response_type=code): Returns access token, code redeemed at token endpoint
"""
import logging
from datetime import datetime
from typing import Optional
from urllib.parse import urlencode
@@ -12,6 +13,7 @@ from fastapi import APIRouter, Depends, Form, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy import text
from gondulf.database.connection import Database
from gondulf.dependencies import get_code_storage, get_database, get_happ_parser, get_verification_service
@@ -20,6 +22,7 @@ from gondulf.services.happ_parser import HAppParser
from gondulf.storage import CodeStore
from gondulf.utils.validation import (
extract_domain_from_url,
mask_email,
normalize_client_id,
validate_redirect_uri,
)
@@ -43,6 +46,63 @@ class AuthenticationResponse(BaseModel):
me: str
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
"""
try:
engine = database.get_engine()
with engine.connect() as conn:
result = conn.execute(
text("SELECT verified FROM domains WHERE domain = :domain AND verified = 1"),
{"domain": domain}
)
row = result.fetchone()
return row is not None
except Exception as e:
logger.error(f"Failed to check domain verification: {e}")
return False
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)
"""
try:
engine = database.get_engine()
now = datetime.utcnow()
with engine.begin() as conn:
# Use INSERT OR REPLACE for SQLite
conn.execute(
text("""
INSERT OR REPLACE INTO domains
(domain, email, verification_code, verified, verified_at, two_factor)
VALUES (:domain, :email, '', 1, :verified_at, 1)
"""),
{
"domain": domain,
"email": email,
"verified_at": now
}
)
logger.info(f"Stored verified domain: {domain}")
except Exception as e:
logger.error(f"Failed to store verified domain: {e}")
raise
@router.get("/authorize")
async def authorize_get(
request: Request,
@@ -55,7 +115,8 @@ async def authorize_get(
scope: str | None = None,
me: str | None = None,
database: Database = Depends(get_database),
happ_parser: HAppParser = Depends(get_happ_parser)
happ_parser: HAppParser = Depends(get_happ_parser),
verification_service: DomainVerificationService = Depends(get_verification_service)
) -> HTMLResponse:
"""
Handle authorization request (GET).
@@ -79,9 +140,10 @@ async def authorize_get(
me: User identity URL
database: Database service
happ_parser: H-app parser for client metadata
verification_service: Domain verification service
Returns:
HTML response with consent form or error page
HTML response with consent form, verification form, or error page
"""
# Validate required parameters (pre-client validation)
if not client_id:
@@ -176,9 +238,9 @@ async def authorize_get(
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
# Validate me URL format
# Validate me URL format and extract domain
try:
extract_domain_from_url(me)
domain = extract_domain_from_url(me)
except ValueError:
error_params = {
"error": "invalid_request",
@@ -188,11 +250,71 @@ async def authorize_get(
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
# 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
# SECURITY FIX: Check if domain is verified before showing consent
is_verified = await check_domain_verified(database, domain)
if not is_verified:
logger.info(f"Domain {domain} not verified, starting verification")
# Start two-factor verification
result = verification_service.start_verification(domain, me)
if not result["success"]:
# Verification cannot start (DNS failed, no rel=me, etc)
error_message = result.get("error", "verification_failed")
# Map error codes to user-friendly messages
error_messages = {
"dns_verification_failed": "DNS verification failed. Please add the required TXT record.",
"email_discovery_failed": "Could not find an email address on your homepage. Please add a rel='me' link to your email.",
"invalid_email_format": "The email address discovered on your homepage is invalid.",
"email_send_failed": "Failed to send verification email. Please try again."
}
friendly_error = error_messages.get(error_message, error_message)
logger.warning(f"Verification start failed for domain={domain}: {error_message}")
return templates.TemplateResponse(
"verification_error.html",
{
"request": request,
"error": friendly_error,
"domain": domain,
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
"response_type": effective_response_type,
"state": state or "",
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope or "",
"me": me
},
status_code=200
)
# Verification started - show code entry form
logger.info(f"Verification code sent for domain={domain}")
return templates.TemplateResponse(
"verify_code.html",
{
"request": request,
"masked_email": result["email"],
"domain": domain,
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
"response_type": effective_response_type,
"state": state or "",
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope or "",
"me": me
}
)
# Domain is verified - fetch client metadata and show consent form
logger.info(f"Domain {domain} is verified, showing consent page")
# Fetch client metadata (h-app microformat)
client_metadata = None
try:
client_metadata = await happ_parser.fetch_and_parse(normalized_client_id)
@@ -219,6 +341,118 @@ async def authorize_get(
)
@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:
request: FastAPI request object
domain: Domain being verified
code: 6-digit verification code from email
client_id: Client application identifier
redirect_uri: Callback URI
response_type: "id" for authentication, "code" for authorization
state: Client state parameter
code_challenge: PKCE code challenge
code_challenge_method: PKCE method
scope: Requested scope
me: User identity URL
database: Database service
verification_service: Domain verification service
happ_parser: H-app parser for client metadata
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"]:
logger.warning(f"Verification code invalid for domain={domain}: {result.get('error')}")
# Get masked email for display
email = verification_service.code_storage.get(f"email_addr:{domain}")
masked = mask_email(email) if email else "unknown"
# Map error codes to user-friendly messages
error_messages = {
"invalid_code": "Invalid verification code. Please check and try again.",
"email_not_found": "Verification session expired. Please start over."
}
error_message = result.get("error", "invalid_code")
friendly_error = error_messages.get(error_message, error_message)
return templates.TemplateResponse(
"verify_code.html",
{
"request": request,
"error": friendly_error,
"masked_email": masked,
"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
email = result.get("email", "")
await store_verified_domain(database, domain, 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
}
)
@router.post("/authorize/consent")
async def authorize_consent(
request: Request,

View File

@@ -0,0 +1,40 @@
{% 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 or "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 %}

View File

@@ -0,0 +1,51 @@
{% 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 %}