implement challenge verification flow with altcha integration and add challenge page

This commit is contained in:
Eduardo Silva
2026-03-18 08:56:48 -03:00
parent 0bd4136b5f
commit 5c5375cb9a
10 changed files with 325 additions and 2 deletions

View File

@@ -10,6 +10,7 @@ from auth_gateway.services.session_service import SessionService
from auth_gateway.settings import settings
from auth_gateway.storage.sqlite import SQLiteStorage
from auth_gateway.web.auth_routes import router as auth_router
from auth_gateway.web.challenge_routes import router as challenge_router
from auth_gateway.web.login_routes import router as login_router
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
@@ -70,12 +71,15 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_handler)
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
is_challenge = request.url.path == "/challenge"
script_src = "'self'" if is_challenge else "'none'"
worker_src = "worker-src 'self' blob:; " if is_challenge else ""
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; script-src 'none'; style-src 'self'; "
"img-src 'self' data:; frame-ancestors 'none'"
f"default-src 'self'; script-src {script_src}; style-src 'self'; "
f"img-src 'self' data:; {worker_src}frame-ancestors 'none'"
)
return response
@@ -94,6 +98,7 @@ async def access_log(request: Request, call_next):
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
app.include_router(auth_router)
app.include_router(challenge_router)
app.include_router(login_router)

View File

@@ -0,0 +1,69 @@
import base64
import hashlib
import hmac
import json
import os
import time
ALTCHA_MAX_NUMBER = 1_000_000
CHALLENGE_TTL_SECONDS = 300 # 5 minutes
CHALLENGE_COOKIE_NAME = "auth_gateway_challenge"
def generate_altcha_challenge(hmac_key: str) -> dict:
salt = os.urandom(12).hex()
number = int.from_bytes(os.urandom(4), "big") % ALTCHA_MAX_NUMBER
challenge = hashlib.sha256(f"{salt}{number}".encode()).hexdigest()
signature = hmac.new(hmac_key.encode(), challenge.encode(), hashlib.sha256).hexdigest()
return {
"algorithm": "SHA-256",
"challenge": challenge,
"salt": salt,
"signature": signature,
"maxnumber": ALTCHA_MAX_NUMBER,
}
def verify_altcha_solution(payload_b64: str, hmac_key: str) -> bool:
try:
padding = (4 - len(payload_b64) % 4) % 4
data = base64.b64decode(payload_b64 + "=" * padding)
payload = json.loads(data.decode())
algorithm = payload.get("algorithm", "")
challenge = payload.get("challenge", "")
salt = payload.get("salt", "")
number = payload.get("number")
signature = payload.get("signature", "")
if algorithm != "SHA-256" or not all([challenge, salt, signature, number is not None]):
return False
expected_sig = hmac.new(hmac_key.encode(), challenge.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected_sig, signature):
return False
computed = hashlib.sha256(f"{salt}{number}".encode()).hexdigest()
return hmac.compare_digest(computed, challenge)
except Exception:
return False
def generate_challenge_cookie(secret: str) -> str:
timestamp = str(int(time.time()))
nonce = os.urandom(8).hex()
message = f"{timestamp}.{nonce}"
sig = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
return f"{message}.{sig}"
def verify_challenge_cookie(cookie_value: str, secret: str) -> bool:
try:
last_dot = cookie_value.rfind(".")
if last_dot == -1:
return False
message = cookie_value[:last_dot]
sig = cookie_value[last_dot + 1:]
expected_sig = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected_sig, sig):
return False
timestamp = int(message.split(".")[0])
return (time.time() - timestamp) < CHALLENGE_TTL_SECONDS
except Exception:
return False

View File

@@ -1,4 +1,5 @@
from pathlib import Path
from secrets import token_hex
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -15,6 +16,7 @@ class Settings(BaseSettings):
secure_cookies: bool = Field(default=True)
session_default_minutes: int = Field(default=720)
oidc_state_ttl_minutes: int = Field(default=10)
challenge_secret: str = Field(default_factory=lambda: token_hex(32))
settings = Settings()

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,35 @@
document.addEventListener("DOMContentLoaded", function () {
var widget = document.getElementById("altcha-widget");
var progressFill = document.getElementById("progress-fill");
var statusText = document.getElementById("challenge-status");
var challengeIcon = document.getElementById("challenge-icon");
if (!widget) return;
var fakeProgress = 0;
var progressInterval = setInterval(function () {
fakeProgress = Math.min(fakeProgress + Math.random() * 2.5, 88);
progressFill.style.width = fakeProgress + "%";
}, 150);
widget.addEventListener("statechange", function (ev) {
if (ev.detail.state === "verified") {
clearInterval(progressInterval);
progressFill.style.width = "100%";
progressFill.classList.add("progress-done");
challengeIcon.textContent = "✓";
challengeIcon.classList.add("challenge-icon-done");
statusText.textContent = "Verification complete. Redirecting...";
document.getElementById("altcha-payload").value = ev.detail.payload;
setTimeout(function () {
document.getElementById("challenge-form").submit();
}, 500);
} else if (ev.detail.state === "error") {
clearInterval(progressInterval);
challengeIcon.textContent = "✕";
challengeIcon.classList.add("challenge-icon-error");
statusText.textContent = "Verification failed. Please reload the page.";
progressFill.classList.add("progress-error");
}
});
});

View File

@@ -238,6 +238,78 @@ input:focus {
margin: 2px 2px 2px 0;
}
/* ── Challenge page ── */
.challenge-wrapper {
text-align: center;
padding: 8px 0 4px;
}
.challenge-icon-wrap {
margin-bottom: 16px;
}
.challenge-icon {
font-size: 44px;
line-height: 1;
display: inline-block;
transition: transform 300ms ease;
}
.challenge-icon-done {
color: #22c55e;
font-style: normal;
font-size: 40px;
font-weight: 700;
}
.challenge-icon-error {
color: var(--danger);
font-style: normal;
font-size: 40px;
font-weight: 700;
}
.challenge-error-text {
color: var(--danger);
}
.challenge-progress {
margin-top: 24px;
}
.altcha-wrapper {
position: absolute;
left: -9999px;
top: -9999px;
width: 0;
height: 0;
overflow: hidden;
}
.progress-bar {
width: 100%;
height: 5px;
background: var(--line);
border-radius: 99px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: var(--accent);
border-radius: 99px;
transition: width 200ms ease, background 300ms ease;
}
.progress-done {
background: #22c55e !important;
}
.progress-error {
background: var(--danger) !important;
}
/* ── Dark mode toggle ── */
#dark-mode-toggle {
position: fixed;

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Verifying your browser — Gatekeeper{% endblock %}
{% block content %}
<div class="challenge-wrapper">
<div class="challenge-icon-wrap">
<span class="challenge-icon" id="challenge-icon">🛡</span>
</div>
<h1 class="card-title">Verifying your browser</h1>
{% if error %}
<p class="card-subtitle challenge-error-text">Verification failed. Please reload and try again.</p>
{% else %}
<p class="card-subtitle" id="challenge-status">Please wait a moment...</p>
{% endif %}
<div class="challenge-progress">
<div class="progress-bar">
<div class="progress-fill{% if error %} progress-error{% endif %}" id="progress-fill"{% if error %} style="width:100%"{% endif %}></div>
</div>
</div>
</div>
<form id="challenge-form" method="post" action="{{ external_path }}/challenge/verify">
<input type="hidden" name="next" value="{{ next }}">
<input type="hidden" name="altcha" id="altcha-payload">
</form>
{% if not error %}
<div aria-hidden="true" class="altcha-wrapper">
<altcha-widget
challengeurl="{{ external_path }}/challenge/data"
hidefooter
hidelogo
auto="onload"
id="altcha-widget"
></altcha-widget>
</div>
<script type="module" src="{{ external_path }}/static/altcha.min.js"></script>
<script src="{{ external_path }}/static/challenge.js" defer></script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,67 @@
from urllib.parse import urlsplit
from auth_gateway.services.challenge_service import (
CHALLENGE_COOKIE_NAME,
generate_altcha_challenge,
generate_challenge_cookie,
verify_altcha_solution,
)
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
router = APIRouter()
def _safe_next(url: str | None, external_path: str) -> str:
if not url:
return f"{external_path}/login"
parts = urlsplit(url)
if parts.scheme or parts.netloc:
return f"{external_path}/login"
path = parts.path or "/"
if not path.startswith("/"):
return f"{external_path}/login"
return f"{path}?{parts.query}" if parts.query else path
@router.get("/challenge", response_class=HTMLResponse)
async def challenge_page(request: Request, next: str = "/"):
external_path = request.app.state.settings.external_path
return request.app.state.templates.TemplateResponse(
"challenge.html",
{"request": request, "external_path": external_path, "next": next},
)
@router.get("/challenge/data")
async def challenge_data(request: Request):
hmac_key = request.app.state.settings.challenge_secret
challenge = generate_altcha_challenge(hmac_key)
return JSONResponse(challenge)
@router.post("/challenge/verify")
async def challenge_verify(request: Request, next: str = Form("/"), altcha: str = Form(...)):
hmac_key = request.app.state.settings.challenge_secret
external_path = request.app.state.settings.external_path
if not verify_altcha_solution(altcha, hmac_key):
return request.app.state.templates.TemplateResponse(
"challenge.html",
{"request": request, "external_path": external_path, "next": next, "error": True},
status_code=400,
)
cookie_value = generate_challenge_cookie(hmac_key)
safe_next = _safe_next(next, external_path)
response = RedirectResponse(safe_next, status_code=303)
response.set_cookie(
key=CHALLENGE_COOKIE_NAME,
value=cookie_value,
httponly=True,
secure=request.app.state.settings.secure_cookies,
samesite="strict",
path="/",
max_age=300,
)
return response

View File

@@ -110,3 +110,19 @@ def validate_csrf(request: Request, submitted_token: str | None) -> None:
cookie_token = request.cookies.get(cookie_name)
if not cookie_token or not submitted_token or cookie_token != submitted_token:
raise HTTPException(status_code=403, detail="CSRF validation failed.")
def challenge_is_valid(request: Request) -> bool:
from auth_gateway.services.challenge_service import CHALLENGE_COOKIE_NAME, verify_challenge_cookie
cookie = request.cookies.get(CHALLENGE_COOKIE_NAME)
if not cookie:
return False
return verify_challenge_cookie(cookie, request.app.state.settings.challenge_secret)
def build_challenge_url(request: Request, login_route: str, next_url: str) -> str:
external_path = request.app.state.settings.external_path.rstrip("/")
login_path = f"{external_path}{login_route}"
if next_url and next_url != "/":
login_path = f"{login_path}?{urlencode({'next': next_url})}"
return build_external_url(request, "/challenge", next=login_path)

View File

@@ -9,7 +9,9 @@ from auth_gateway.services.policy_engine import evaluate_ip_access, extract_clie
from auth_gateway.services.resolver import normalize_host
from auth_gateway.services.totp_service import verify_totp
from auth_gateway.web.dependencies import (
build_challenge_url,
build_external_url,
challenge_is_valid,
get_effective_expiration,
get_effective_policy,
get_oidc_method,
@@ -147,6 +149,8 @@ async def login_page(request: Request, next: str = "/"):
@router.get("/login/password", response_class=HTMLResponse)
async def login_password_page(request: Request, next: str = "/"):
if not challenge_is_valid(request):
return RedirectResponse(build_challenge_url(request, "/login/password", next), status_code=303)
runtime_config = get_runtime_config(request)
context = resolve_context_from_request(request, runtime_config, next)
effective_policy = get_effective_policy(runtime_config, context.policy_name)
@@ -171,6 +175,8 @@ async def login_password_submit(
password: str = Form(...),
csrf_token: str = Form(...),
):
if not challenge_is_valid(request):
return RedirectResponse(build_challenge_url(request, "/login/password", next), status_code=303)
runtime_config = get_runtime_config(request)
context = resolve_context_from_request(request, runtime_config, next)
effective_policy = get_effective_policy(runtime_config, context.policy_name)
@@ -226,6 +232,8 @@ async def login_password_submit(
@router.get("/login/totp", response_class=HTMLResponse)
async def login_totp_page(request: Request, next: str = "/"):
if not challenge_is_valid(request):
return RedirectResponse(build_challenge_url(request, "/login/totp", next), status_code=303)
runtime_config = get_runtime_config(request)
context = resolve_context_from_request(request, runtime_config, next)
effective_policy = get_effective_policy(runtime_config, context.policy_name)
@@ -248,6 +256,8 @@ async def login_totp_page(request: Request, next: str = "/"):
@router.post("/login/totp")
@limiter.limit(AUTH_RATE_LIMIT)
async def login_totp_submit(request: Request, next: str = Form("/"), token: str = Form(...), csrf_token: str = Form(...)):
if not challenge_is_valid(request):
return RedirectResponse(build_challenge_url(request, "/login/totp", next), status_code=303)
runtime_config = get_runtime_config(request)
context = resolve_context_from_request(request, runtime_config, next)
effective_policy = get_effective_policy(runtime_config, context.policy_name)