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>
This commit is contained in:
2025-11-22 15:16:26 -07:00
parent 9b50f359a6
commit 9135edfe84
17 changed files with 3457 additions and 529 deletions

View File

@@ -0,0 +1,35 @@
-- Migration 004: Create auth_sessions table for per-login authentication
--
-- This migration separates user authentication (per-login email verification)
-- from domain verification (one-time DNS check). See ADR-010 for details.
--
-- Key principle: Email code is AUTHENTICATION (every login), never cached.
-- Auth sessions table for temporary per-login authentication state
-- This table stores session data for the authorization flow
CREATE TABLE auth_sessions (
session_id TEXT PRIMARY KEY,
me TEXT NOT NULL,
email TEXT,
verification_code_hash TEXT,
code_verified INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
client_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
state TEXT,
code_challenge TEXT,
code_challenge_method TEXT,
scope TEXT,
response_type TEXT DEFAULT 'id',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
-- Index for expiration-based cleanup
CREATE INDEX idx_auth_sessions_expires ON auth_sessions(expires_at);
-- Index for looking up sessions by domain (for email discovery)
CREATE INDEX idx_auth_sessions_me ON auth_sessions(me);
-- Record this migration
INSERT INTO migrations (version, description) VALUES (4, 'Create auth_sessions table for per-login authentication - separates user authentication from domain verification');

View File

@@ -0,0 +1,12 @@
-- Migration 005: Add last_checked column to domains table
-- Enables cache expiration for DNS verification (separate from user authentication)
-- See ADR-010 for the domain verification vs user authentication distinction
-- Add last_checked column for DNS verification cache expiration
ALTER TABLE domains ADD COLUMN last_checked TIMESTAMP;
-- Update existing verified domains to set last_checked = verified_at
UPDATE domains SET last_checked = verified_at WHERE verified = 1;
-- Record this migration
INSERT INTO migrations (version, description) VALUES (5, 'Add last_checked column to domains table for DNS verification cache');

View File

@@ -5,6 +5,7 @@ from gondulf.config import Config
from gondulf.database.connection import Database
from gondulf.dns import DNSService
from gondulf.email import EmailService
from gondulf.services.auth_session import AuthSessionService
from gondulf.services.domain_verification import DomainVerificationService
from gondulf.services.happ_parser import HAppParser
from gondulf.services.html_fetcher import HTMLFetcherService
@@ -111,3 +112,17 @@ def get_token_service() -> TokenService:
token_length=32, # 256 bits
token_ttl=config.TOKEN_EXPIRY # From environment (default: 3600)
)
# Auth Session Service (for per-login authentication)
@lru_cache
def get_auth_session_service() -> AuthSessionService:
"""
Get AuthSessionService singleton.
Handles per-login authentication via email verification.
This is separate from domain verification (DNS check).
See ADR-010 for the architectural decision.
"""
database = get_database()
return AuthSessionService(database=database)

View File

@@ -3,6 +3,12 @@
Supports both IndieAuth flows per W3C specification:
- Authentication (response_type=id): Returns user identity only, code redeemed at authorization endpoint
- Authorization (response_type=code): Returns access token, code redeemed at token endpoint
IMPORTANT: This implementation correctly separates:
- Domain verification (DNS TXT check) - one-time, can be cached
- User authentication (email code) - EVERY login, NEVER cached
See ADR-010 for the architectural decision behind this separation.
"""
import logging
from datetime import datetime
@@ -16,14 +22,34 @@ 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
from gondulf.services.domain_verification import DomainVerificationService
from gondulf.dependencies import (
get_auth_session_service,
get_code_storage,
get_database,
get_dns_service,
get_email_service,
get_happ_parser,
get_html_fetcher,
get_relme_parser,
)
from gondulf.dns import DNSService
from gondulf.email import EmailService
from gondulf.services.auth_session import (
AuthSessionService,
CodeVerificationError,
MaxAttemptsExceededError,
SessionExpiredError,
SessionNotFoundError,
)
from gondulf.services.happ_parser import HAppParser
from gondulf.services.html_fetcher import HTMLFetcherService
from gondulf.services.relme_parser import RelMeParser
from gondulf.storage import CodeStore
from gondulf.utils.validation import (
extract_domain_from_url,
mask_email,
normalize_client_id,
validate_email,
validate_redirect_uri,
)
@@ -35,6 +61,9 @@ templates = Jinja2Templates(directory="src/gondulf/templates")
# Valid response types per IndieAuth spec
VALID_RESPONSE_TYPES = {"id", "code"}
# Domain verification cache duration (24 hours)
DOMAIN_VERIFICATION_CACHE_HOURS = 24
class AuthenticationResponse(BaseModel):
"""
@@ -46,41 +75,81 @@ class AuthenticationResponse(BaseModel):
me: str
async def check_domain_verified(database: Database, domain: str) -> bool:
async def check_domain_dns_verified(database: Database, domain: str) -> bool:
"""
Check if domain is verified in the database.
Check if domain has valid DNS TXT record verification (cached).
This checks ONLY the DNS verification status, NOT user authentication.
DNS verification can be cached as it's about domain configuration,
not user identity.
Args:
database: Database service
domain: Domain to check (e.g., "example.com")
Returns:
True if domain is verified, False otherwise
True if domain has valid cached DNS verification, 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"),
text("""
SELECT verified, last_checked
FROM domains
WHERE domain = :domain AND verified = 1
"""),
{"domain": domain}
)
row = result.fetchone()
return row is not None
if row is None:
return False
# Check if verification is still fresh (within cache window)
last_checked = row[1]
if isinstance(last_checked, str):
last_checked = datetime.fromisoformat(last_checked)
if last_checked:
hours_since_check = (datetime.utcnow() - last_checked).total_seconds() / 3600
if hours_since_check > DOMAIN_VERIFICATION_CACHE_HOURS:
logger.info(f"Domain {domain} DNS verification cache expired")
return False
return True
except Exception as e:
logger.error(f"Failed to check domain verification: {e}")
logger.error(f"Failed to check domain DNS verification: {e}")
return False
async def store_verified_domain(database: Database, domain: str, email: str) -> None:
async def verify_domain_dns(
database: Database,
dns_service: DNSService,
domain: str
) -> bool:
"""
Store verified domain in database.
Verify domain DNS TXT record and update cache.
This performs the actual DNS lookup and caches the result.
Args:
database: Database service
domain: Verified domain
email: Email used for verification (for audit)
dns_service: DNS service for TXT lookup
domain: Domain to verify
Returns:
True if DNS verification successful, False otherwise
"""
try:
# Check DNS TXT record
dns_verified = dns_service.verify_txt_record(domain, "gondulf-verify-domain")
if not dns_verified:
logger.warning(f"DNS verification failed for domain={domain}")
return False
# Update cache in database
engine = database.get_engine()
now = datetime.utcnow()
with engine.begin() as conn:
@@ -88,19 +157,56 @@ async def store_verified_domain(database: Database, domain: str, email: str) ->
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, email, verification_code, verified, verified_at, last_checked, two_factor)
VALUES (:domain, '', '', 1, :now, :now, 0)
"""),
{
"domain": domain,
"email": email,
"verified_at": now
}
{"domain": domain, "now": now}
)
logger.info(f"Stored verified domain: {domain}")
logger.info(f"Domain DNS verification successful and cached: {domain}")
return True
except Exception as e:
logger.error(f"Failed to store verified domain: {e}")
raise
logger.error(f"DNS verification error for domain={domain}: {e}")
return False
async def discover_email_from_profile(
me_url: str,
html_fetcher: HTMLFetcherService,
relme_parser: RelMeParser
) -> str | None:
"""
Discover email address from user's profile page via rel=me links.
Args:
me_url: User's identity URL
html_fetcher: HTML fetcher service
relme_parser: rel=me parser
Returns:
Email address if found, None otherwise
"""
try:
html = html_fetcher.fetch(me_url)
if not html:
logger.warning(f"Failed to fetch HTML from {me_url}")
return None
email = relme_parser.find_email(html)
if not email:
logger.warning(f"No email found in rel=me links at {me_url}")
return None
if not validate_email(email):
logger.warning(f"Invalid email format discovered: {email}")
return None
return email
except Exception as e:
logger.error(f"Email discovery error for {me_url}: {e}")
return None
@router.get("/authorize")
@@ -115,18 +221,22 @@ async def authorize_get(
scope: str | None = None,
me: str | None = None,
database: Database = Depends(get_database),
happ_parser: HAppParser = Depends(get_happ_parser),
verification_service: DomainVerificationService = Depends(get_verification_service)
dns_service: DNSService = Depends(get_dns_service),
html_fetcher: HTMLFetcherService = Depends(get_html_fetcher),
relme_parser: RelMeParser = Depends(get_relme_parser),
email_service: EmailService = Depends(get_email_service),
auth_session_service: AuthSessionService = Depends(get_auth_session_service),
happ_parser: HAppParser = Depends(get_happ_parser)
) -> HTMLResponse:
"""
Handle authorization request (GET).
Validates client_id, redirect_uri, and required parameters.
Shows consent form if domain is verified, or verification form if not.
Supports two IndieAuth flows per W3C specification:
- response_type=id (default): Authentication only, returns user identity
- response_type=code: Authorization, returns access token
Flow:
1. Validate OAuth parameters
2. Check domain DNS verification (cached OK)
3. Discover email from rel=me on user's homepage
4. Send verification code to email (ALWAYS - this is authentication)
5. Create auth session and show code entry form
Args:
request: FastAPI request object
@@ -139,11 +249,15 @@ async def authorize_get(
scope: Requested scope (only meaningful for response_type=code)
me: User identity URL
database: Database service
dns_service: DNS service for domain verification
html_fetcher: HTML fetcher for profile discovery
relme_parser: rel=me parser for email extraction
email_service: Email service for sending codes
auth_session_service: Auth session service for tracking login state
happ_parser: H-app parser for client metadata
verification_service: Domain verification service
Returns:
HTML response with consent form, verification form, or error page
HTML response with code entry form or error page
"""
# Validate required parameters (pre-client validation)
if not client_id:
@@ -250,35 +364,20 @@ async def authorize_get(
redirect_url = f"{redirect_uri}?{urlencode(error_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
# SECURITY FIX: Check if domain is verified before showing consent
is_verified = await check_domain_verified(database, domain)
# STEP 1: Domain DNS Verification (can be cached)
dns_verified = await check_domain_dns_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}")
if not dns_verified:
# Try fresh DNS verification
dns_verified = await verify_domain_dns(database, dns_service, domain)
if not dns_verified:
logger.warning(f"Domain {domain} not DNS verified")
return templates.TemplateResponse(
"verification_error.html",
{
"request": request,
"error": friendly_error,
"error": "DNS verification failed. Please add the required TXT record.",
"domain": domain,
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
@@ -292,14 +391,18 @@ async def authorize_get(
status_code=200
)
# Verification started - show code entry form
logger.info(f"Verification code sent for domain={domain}")
logger.info(f"Domain {domain} DNS verified (cached or fresh)")
# STEP 2: Discover email from profile (rel=me)
email = await discover_email_from_profile(me, html_fetcher, relme_parser)
if not email:
logger.warning(f"Could not discover email for {me}")
return templates.TemplateResponse(
"verify_code.html",
"verification_error.html",
{
"request": request,
"masked_email": result["email"],
"error": "Could not find an email address on your homepage. Please add a rel='me' link to your email.",
"domain": domain,
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
@@ -309,25 +412,59 @@ async def authorize_get(
"code_challenge_method": code_challenge_method,
"scope": scope or "",
"me": me
}
},
status_code=200
)
# Domain is verified - fetch client metadata and show consent form
logger.info(f"Domain {domain} is verified, showing consent page")
client_metadata = None
# STEP 3: Create auth session and send verification code
# THIS IS ALWAYS REQUIRED - email code is authentication, not domain verification
try:
client_metadata = await happ_parser.fetch_and_parse(normalized_client_id)
logger.info(f"Fetched client metadata for {normalized_client_id}: {client_metadata.name}")
except Exception as e:
logger.warning(f"Failed to fetch client metadata for {normalized_client_id}: {e}")
# Continue without metadata - will show client_id instead
session_result = auth_session_service.create_session(
me=me,
email=email,
client_id=normalized_client_id,
redirect_uri=redirect_uri,
state=state or "",
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
scope=scope or "",
response_type=effective_response_type
)
# Show consent form
# Send verification code via email
verification_code = session_result["verification_code"]
email_service.send_verification_code(email, verification_code, domain)
logger.info(f"Verification code sent for {me} (session: {session_result['session_id'][:8]}...)")
except Exception as e:
logger.error(f"Failed to start authentication: {e}")
return templates.TemplateResponse(
"verification_error.html",
{
"request": request,
"error": "Failed to send verification email. Please try again.",
"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
)
# STEP 4: Show code entry form
return templates.TemplateResponse(
"authorize.html",
"verify_code.html",
{
"request": request,
"masked_email": mask_email(email),
"session_id": session_result["session_id"],
"domain": domain,
"client_id": normalized_client_id,
"redirect_uri": redirect_uri,
"response_type": effective_response_type,
@@ -335,8 +472,7 @@ async def authorize_get(
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope or "",
"me": me,
"client_metadata": client_metadata
"me": me
}
)
@@ -344,18 +480,9 @@ async def authorize_get(
@router.post("/authorize/verify-code")
async def authorize_verify_code(
request: Request,
domain: str = Form(...),
session_id: 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),
auth_session_service: AuthSessionService = Depends(get_auth_session_service),
happ_parser: HAppParser = Depends(get_happ_parser)
) -> HTMLResponse:
"""
@@ -366,149 +493,224 @@ async def authorize_verify_code(
Args:
request: FastAPI request object
domain: Domain being verified
session_id: Auth session identifier
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
auth_session_service: Auth session 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}")
logger.info(f"Verification code submission for session={session_id[:8]}...")
# Verify the code
result = verification_service.verify_email_code(domain, code)
try:
# Verify the code - this is the authentication step
session = auth_session_service.verify_code(session_id, code)
if not result["success"]:
logger.warning(f"Verification code invalid for domain={domain}: {result.get('error')}")
logger.info(f"Code verified successfully for session={session_id[:8]}...")
# 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)
# Fetch client metadata for consent page
client_metadata = None
try:
client_metadata = await happ_parser.fetch_and_parse(session["client_id"])
except Exception as e:
logger.warning(f"Failed to fetch client metadata: {e}")
# Show consent form
return templates.TemplateResponse(
"verify_code.html",
"authorize.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
"session_id": session_id,
"client_id": session["client_id"],
"redirect_uri": session["redirect_uri"],
"response_type": session["response_type"],
"state": session["state"],
"code_challenge": session["code_challenge"],
"code_challenge_method": session["code_challenge_method"],
"scope": session["scope"],
"me": session["me"],
"client_metadata": client_metadata
}
)
# Code valid - store verified domain in database
email = result.get("email", "")
await store_verified_domain(database, domain, email)
except SessionNotFoundError:
logger.warning(f"Session not found: {session_id[:8]}...")
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Session not found or expired. Please start over.",
"error_code": "invalid_request"
},
status_code=400
)
logger.info(f"Domain verified successfully: {domain}")
except SessionExpiredError:
logger.warning(f"Session expired: {session_id[:8]}...")
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Session expired. Please start over.",
"error_code": "invalid_request"
},
status_code=400
)
# 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}")
except MaxAttemptsExceededError:
logger.warning(f"Max attempts exceeded for session: {session_id[:8]}...")
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Too many incorrect code attempts. Please start over.",
"error_code": "access_denied"
},
status_code=400
)
# 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
}
)
except CodeVerificationError:
logger.warning(f"Invalid code for session: {session_id[:8]}...")
# Get session to show code entry form again
try:
session = auth_session_service.get_session(session_id)
return templates.TemplateResponse(
"verify_code.html",
{
"request": request,
"error": "Invalid verification code. Please check and try again.",
"masked_email": mask_email(session["email"]),
"session_id": session_id,
"domain": extract_domain_from_url(session["me"]),
"client_id": session["client_id"],
"redirect_uri": session["redirect_uri"],
"response_type": session["response_type"],
"state": session["state"],
"code_challenge": session["code_challenge"],
"code_challenge_method": session["code_challenge_method"],
"scope": session["scope"],
"me": session["me"]
},
status_code=200
)
except Exception:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Session not found or expired. Please start over.",
"error_code": "invalid_request"
},
status_code=400
)
@router.post("/authorize/consent")
async def authorize_consent(
request: Request,
client_id: str = Form(...),
redirect_uri: str = Form(...),
response_type: str = Form("id"), # Default to "id" for authentication flow
state: str = Form(...),
code_challenge: str = Form(...),
code_challenge_method: str = Form(...),
scope: str = Form(...),
me: str = Form(...),
verification_service: DomainVerificationService = Depends(get_verification_service)
session_id: str = Form(...),
auth_session_service: AuthSessionService = Depends(get_auth_session_service),
code_storage: CodeStore = Depends(get_code_storage)
) -> RedirectResponse:
"""
Handle authorization consent (POST).
Creates authorization code and redirects to client callback.
Validates that the session is authenticated, then creates authorization
code and redirects to client callback.
Args:
request: FastAPI request object
client_id: Client application identifier
redirect_uri: Callback URI
response_type: "id" for authentication, "code" for authorization
state: Client state
code_challenge: PKCE challenge
code_challenge_method: PKCE method
scope: Requested scope
me: User identity
verification_service: Domain verification service
session_id: Auth session identifier
auth_session_service: Auth session service
code_storage: Code storage for authorization codes
Returns:
Redirect to client callback with authorization code
"""
logger.info(f"Authorization consent granted for client_id={client_id} response_type={response_type}")
logger.info(f"Authorization consent for session={session_id[:8]}...")
# Create authorization code with response_type metadata
authorization_code = verification_service.create_authorization_code(
client_id=client_id,
redirect_uri=redirect_uri,
state=state,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
scope=scope,
me=me,
response_type=response_type
)
try:
# Get and validate session
session = auth_session_service.get_session(session_id)
# Build redirect URL with authorization code
redirect_params = {
"code": authorization_code,
"state": state
}
redirect_url = f"{redirect_uri}?{urlencode(redirect_params)}"
# Verify session has been authenticated
if not session.get("code_verified"):
logger.warning(f"Session {session_id[:8]}... not authenticated")
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Session not authenticated. Please start over.",
"error_code": "access_denied"
},
status_code=400
)
logger.info(f"Redirecting to {redirect_uri} with authorization code")
return RedirectResponse(url=redirect_url, status_code=302)
# Create authorization code
import secrets
import time
authorization_code = secrets.token_urlsafe(32)
# Store authorization code with metadata
metadata = {
"client_id": session["client_id"],
"redirect_uri": session["redirect_uri"],
"state": session["state"],
"code_challenge": session["code_challenge"],
"code_challenge_method": session["code_challenge_method"],
"scope": session["scope"],
"me": session["me"],
"response_type": session["response_type"],
"created_at": int(time.time()),
"expires_at": int(time.time()) + 600,
"used": False
}
storage_key = f"authz:{authorization_code}"
code_storage.store(storage_key, metadata)
# Clean up auth session
auth_session_service.delete_session(session_id)
# Build redirect URL with authorization code
redirect_params = {
"code": authorization_code,
"state": session["state"]
}
redirect_url = f"{session['redirect_uri']}?{urlencode(redirect_params)}"
logger.info(
f"Authorization code created for client_id={session['client_id']} "
f"response_type={session['response_type']}"
)
return RedirectResponse(url=redirect_url, status_code=302)
except SessionNotFoundError:
logger.warning(f"Session not found for consent: {session_id[:8]}...")
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Session not found or expired. Please start over.",
"error_code": "invalid_request"
},
status_code=400
)
except SessionExpiredError:
logger.warning(f"Session expired for consent: {session_id[:8]}...")
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error": "Session expired. Please start over.",
"error_code": "invalid_request"
},
status_code=400
)
@router.post("/authorize")

View File

@@ -0,0 +1,444 @@
"""
Auth session service for per-login user authentication.
This service handles the authentication state for each authorization attempt.
Key distinction from domain verification:
- Domain verification (DNS TXT): One-time check, can be cached
- User authentication (email code): EVERY login, NEVER cached
See ADR-010 for the architectural decision behind this separation.
"""
import hashlib
import logging
import secrets
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import text
from gondulf.database.connection import Database
logger = logging.getLogger("gondulf.auth_session")
# Session configuration
SESSION_TTL_MINUTES = 10 # Email verification window
MAX_CODE_ATTEMPTS = 3 # Maximum incorrect code attempts
class AuthSessionError(Exception):
"""Base exception for auth session errors."""
pass
class SessionNotFoundError(AuthSessionError):
"""Raised when session does not exist or has expired."""
pass
class SessionExpiredError(AuthSessionError):
"""Raised when session has expired."""
pass
class CodeVerificationError(AuthSessionError):
"""Raised when code verification fails."""
pass
class MaxAttemptsExceededError(AuthSessionError):
"""Raised when max code attempts exceeded."""
pass
class AuthSessionService:
"""
Service for managing per-login authentication sessions.
Each authorization attempt creates a new session. The session tracks:
- The email verification code (hashed)
- Whether the code has been verified
- All OAuth parameters for the flow
Sessions are temporary and expire after SESSION_TTL_MINUTES.
"""
def __init__(self, database: Database) -> None:
"""
Initialize auth session service.
Args:
database: Database service for persistence
"""
self.database = database
logger.debug("AuthSessionService initialized")
def _generate_session_id(self) -> str:
"""
Generate cryptographically secure session ID.
Returns:
URL-safe session identifier
"""
return secrets.token_urlsafe(32)
def _generate_verification_code(self) -> str:
"""
Generate 6-digit numeric verification code.
Returns:
6-digit numeric code as string
"""
return f"{secrets.randbelow(1000000):06d}"
def _hash_code(self, code: str) -> str:
"""
Hash verification code for storage.
Args:
code: Plain text verification code
Returns:
SHA-256 hash of code
"""
return hashlib.sha256(code.encode()).hexdigest()
def create_session(
self,
me: str,
email: str,
client_id: str,
redirect_uri: str,
state: str,
code_challenge: str,
code_challenge_method: str,
scope: str,
response_type: str = "id"
) -> dict[str, Any]:
"""
Create a new authentication session.
This is called when an authorization request comes in and the user
needs to authenticate via email code.
Args:
me: User's identity URL
email: Email address for verification code
client_id: OAuth client identifier
redirect_uri: OAuth redirect URI
state: OAuth state parameter
code_challenge: PKCE code challenge
code_challenge_method: PKCE method (S256)
scope: Requested OAuth scope
response_type: OAuth response type (id or code)
Returns:
Dict containing:
- session_id: Unique session identifier
- verification_code: 6-digit code to send via email
- expires_at: Session expiration timestamp
"""
session_id = self._generate_session_id()
verification_code = self._generate_verification_code()
code_hash = self._hash_code(verification_code)
expires_at = datetime.utcnow() + timedelta(minutes=SESSION_TTL_MINUTES)
try:
engine = self.database.get_engine()
with engine.begin() as conn:
conn.execute(
text("""
INSERT INTO auth_sessions (
session_id, me, email, verification_code_hash,
code_verified, attempts, client_id, redirect_uri,
state, code_challenge, code_challenge_method,
scope, response_type, expires_at
) VALUES (
:session_id, :me, :email, :code_hash,
0, 0, :client_id, :redirect_uri,
:state, :code_challenge, :code_challenge_method,
:scope, :response_type, :expires_at
)
"""),
{
"session_id": session_id,
"me": me,
"email": email,
"code_hash": code_hash,
"client_id": client_id,
"redirect_uri": redirect_uri,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"scope": scope,
"response_type": response_type,
"expires_at": expires_at
}
)
logger.info(f"Auth session created: {session_id[:8]}... for {me}")
return {
"session_id": session_id,
"verification_code": verification_code,
"expires_at": expires_at
}
except Exception as e:
logger.error(f"Failed to create auth session: {e}")
raise AuthSessionError(f"Failed to create session: {e}") from e
def get_session(self, session_id: str) -> dict[str, Any]:
"""
Retrieve session by ID.
Args:
session_id: Session identifier
Returns:
Dict with session data
Raises:
SessionNotFoundError: If session doesn't exist
SessionExpiredError: If session has expired
"""
try:
engine = self.database.get_engine()
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT session_id, me, email, code_verified, attempts,
client_id, redirect_uri, state, code_challenge,
code_challenge_method, scope, response_type,
created_at, expires_at
FROM auth_sessions
WHERE session_id = :session_id
"""),
{"session_id": session_id}
)
row = result.fetchone()
if row is None:
raise SessionNotFoundError(f"Session not found: {session_id[:8]}...")
# Check expiration
expires_at = row[13]
if isinstance(expires_at, str):
expires_at = datetime.fromisoformat(expires_at)
if datetime.utcnow() > expires_at:
# Clean up expired session
self.delete_session(session_id)
raise SessionExpiredError(f"Session expired: {session_id[:8]}...")
return {
"session_id": row[0],
"me": row[1],
"email": row[2],
"code_verified": bool(row[3]),
"attempts": row[4],
"client_id": row[5],
"redirect_uri": row[6],
"state": row[7],
"code_challenge": row[8],
"code_challenge_method": row[9],
"scope": row[10],
"response_type": row[11],
"created_at": row[12],
"expires_at": row[13]
}
except (SessionNotFoundError, SessionExpiredError):
raise
except Exception as e:
logger.error(f"Failed to get session: {e}")
raise AuthSessionError(f"Failed to get session: {e}") from e
def verify_code(self, session_id: str, code: str) -> dict[str, Any]:
"""
Verify email code for session.
This is the critical authentication step. The code must match
what was sent to the user's email.
Args:
session_id: Session identifier
code: 6-digit verification code from user
Returns:
Dict with session data on success
Raises:
SessionNotFoundError: If session doesn't exist
SessionExpiredError: If session has expired
MaxAttemptsExceededError: If max attempts exceeded
CodeVerificationError: If code is invalid
"""
try:
engine = self.database.get_engine()
# First, get the session and check it's valid
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT session_id, me, email, verification_code_hash,
code_verified, attempts, client_id, redirect_uri,
state, code_challenge, code_challenge_method,
scope, response_type, expires_at
FROM auth_sessions
WHERE session_id = :session_id
"""),
{"session_id": session_id}
)
row = result.fetchone()
if row is None:
raise SessionNotFoundError(f"Session not found: {session_id[:8]}...")
# Check expiration
expires_at = row[13]
if isinstance(expires_at, str):
expires_at = datetime.fromisoformat(expires_at)
if datetime.utcnow() > expires_at:
self.delete_session(session_id)
raise SessionExpiredError(f"Session expired: {session_id[:8]}...")
# Check if already verified
if row[4]: # code_verified
return {
"session_id": row[0],
"me": row[1],
"email": row[2],
"code_verified": True,
"client_id": row[6],
"redirect_uri": row[7],
"state": row[8],
"code_challenge": row[9],
"code_challenge_method": row[10],
"scope": row[11],
"response_type": row[12]
}
# Check attempts
attempts = row[5]
if attempts >= MAX_CODE_ATTEMPTS:
self.delete_session(session_id)
raise MaxAttemptsExceededError(
f"Max verification attempts exceeded for session: {session_id[:8]}..."
)
stored_hash = row[3]
submitted_hash = self._hash_code(code)
# Verify code using constant-time comparison
if not secrets.compare_digest(stored_hash, submitted_hash):
# Increment attempts
with engine.begin() as conn:
conn.execute(
text("""
UPDATE auth_sessions
SET attempts = attempts + 1
WHERE session_id = :session_id
"""),
{"session_id": session_id}
)
logger.warning(
f"Invalid code attempt {attempts + 1}/{MAX_CODE_ATTEMPTS} "
f"for session: {session_id[:8]}..."
)
raise CodeVerificationError("Invalid verification code")
# Code valid - mark session as verified
with engine.begin() as conn:
conn.execute(
text("""
UPDATE auth_sessions
SET code_verified = 1
WHERE session_id = :session_id
"""),
{"session_id": session_id}
)
logger.info(f"Code verified successfully for session: {session_id[:8]}...")
return {
"session_id": row[0],
"me": row[1],
"email": row[2],
"code_verified": True,
"client_id": row[6],
"redirect_uri": row[7],
"state": row[8],
"code_challenge": row[9],
"code_challenge_method": row[10],
"scope": row[11],
"response_type": row[12]
}
except (SessionNotFoundError, SessionExpiredError,
MaxAttemptsExceededError, CodeVerificationError):
raise
except Exception as e:
logger.error(f"Failed to verify code: {e}")
raise AuthSessionError(f"Failed to verify code: {e}") from e
def is_session_verified(self, session_id: str) -> bool:
"""
Check if session has been verified.
Args:
session_id: Session identifier
Returns:
True if session exists and code has been verified
"""
try:
session = self.get_session(session_id)
return session.get("code_verified", False)
except (SessionNotFoundError, SessionExpiredError):
return False
def delete_session(self, session_id: str) -> None:
"""
Delete a session.
Args:
session_id: Session identifier to delete
"""
try:
engine = self.database.get_engine()
with engine.begin() as conn:
conn.execute(
text("DELETE FROM auth_sessions WHERE session_id = :session_id"),
{"session_id": session_id}
)
logger.debug(f"Session deleted: {session_id[:8]}...")
except Exception as e:
logger.error(f"Failed to delete session: {e}")
def cleanup_expired_sessions(self) -> int:
"""
Clean up all expired sessions.
This should be called periodically (e.g., by a cron job).
Returns:
Number of sessions deleted
"""
try:
engine = self.database.get_engine()
now = datetime.utcnow()
with engine.begin() as conn:
result = conn.execute(
text("DELETE FROM auth_sessions WHERE expires_at < :now"),
{"now": now}
)
count = result.rowcount
if count > 0:
logger.info(f"Cleaned up {count} expired auth sessions")
return count
except Exception as e:
logger.error(f"Failed to cleanup expired sessions: {e}")
return 0

View File

@@ -34,14 +34,8 @@
{% endif %}
<form method="POST" action="/authorize/consent">
<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 }}">
<!-- Session ID contains all authorization state and proves authentication -->
<input type="hidden" name="session_id" value="{{ session_id }}">
<button type="submit">Authorize</button>
</form>
{% endblock %}

View File

@@ -14,16 +14,8 @@
{% 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 }}">
<!-- Session ID contains all authorization state -->
<input type="hidden" name="session_id" value="{{ session_id }}">
<div class="form-group">
<label for="code">Verification Code:</label>