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:
35
src/gondulf/database/migrations/004_create_auth_sessions.sql
Normal file
35
src/gondulf/database/migrations/004_create_auth_sessions.sql
Normal 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');
|
||||
@@ -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');
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
444
src/gondulf/services/auth_session.py
Normal file
444
src/gondulf/services/auth_session.py
Normal 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
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user