mirror of
https://github.com/eduardogsilva/wireguard_webadmin.git
synced 2026-03-21 16:06:18 +00:00
implement challenge verification flow with altcha integration and add challenge page
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
8
containers/auth-gateway/auth_gateway/static/altcha.min.js
vendored
Normal file
8
containers/auth-gateway/auth_gateway/static/altcha.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
35
containers/auth-gateway/auth_gateway/static/challenge.js
Normal file
35
containers/auth-gateway/auth_gateway/static/challenge.js
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
67
containers/auth-gateway/auth_gateway/web/challenge_routes.py
Normal file
67
containers/auth-gateway/auth_gateway/web/challenge_routes.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user