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:
@@ -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,
|
||||
|
||||
40
src/gondulf/templates/verification_error.html
Normal file
40
src/gondulf/templates/verification_error.html
Normal 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><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 %}
|
||||
51
src/gondulf/templates/verify_code.html
Normal file
51
src/gondulf/templates/verify_code.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user