Add complete NexaMFA push MFA system with: - FastAPI backend with PostgreSQL, Redis, OIDC provider, and Prometheus metrics - React TypeScript admin console - Android Kotlin/Jetpack Compose app with biometric authentication - Docker Compose deployment configuration - Gitea CI workflow for backend, frontend, and Android builds - Environment configuration template with security settings - Documentation for security model, deployment
194 lines
6.8 KiB
Python
194 lines
6.8 KiB
Python
import json
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import Settings
|
|
from app.core.deps import get_app_settings, get_db
|
|
from app.models.challenge import Challenge
|
|
from app.models.oidc import AuthorizationCode
|
|
from app.services.challenges import create_challenge, expire_if_needed
|
|
from app.services.oidc import (
|
|
build_token_response,
|
|
consume_authorization_code,
|
|
is_approved,
|
|
issue_authorization_code,
|
|
jwks,
|
|
redirect_with_code,
|
|
redirect_with_error,
|
|
validate_authorize_request,
|
|
)
|
|
from app.services.push import PushService
|
|
|
|
router = APIRouter(tags=["oidc"])
|
|
OIDC_PRIVATE_KEY = None
|
|
|
|
|
|
def set_oidc_private_key(key) -> None:
|
|
global OIDC_PRIVATE_KEY
|
|
OIDC_PRIVATE_KEY = key
|
|
|
|
|
|
@router.get("/.well-known/openid-configuration")
|
|
async def openid_configuration(settings: Settings = Depends(get_app_settings)):
|
|
issuer = settings.oidc_issuer.rstrip("/")
|
|
return {
|
|
"issuer": issuer,
|
|
"authorization_endpoint": f"{issuer}/oauth/authorize",
|
|
"token_endpoint": f"{issuer}/oauth/token",
|
|
"userinfo_endpoint": f"{issuer}/oauth/userinfo",
|
|
"jwks_uri": f"{issuer}/oauth/jwks",
|
|
"response_types_supported": ["code"],
|
|
"subject_types_supported": ["public"],
|
|
"id_token_signing_alg_values_supported": ["RS256"],
|
|
"scopes_supported": ["openid", "profile", "email"],
|
|
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
|
|
"claims_supported": ["sub", "name", "preferred_username", "email", "amr"],
|
|
}
|
|
|
|
|
|
@router.get("/oauth/jwks")
|
|
async def jwks_endpoint():
|
|
return jwks(OIDC_PRIVATE_KEY)
|
|
|
|
|
|
@router.get("/oauth/authorize")
|
|
async def authorize(
|
|
request: Request,
|
|
response_type: str,
|
|
client_id: str,
|
|
redirect_uri: str,
|
|
scope: str = "openid profile email",
|
|
state: str | None = None,
|
|
nonce: str | None = None,
|
|
login_hint: str | None = None,
|
|
session: AsyncSession = Depends(get_db),
|
|
settings: Settings = Depends(get_app_settings),
|
|
):
|
|
error = validate_authorize_request(settings, client_id, redirect_uri, response_type)
|
|
if error:
|
|
return RedirectResponse(redirect_with_error(redirect_uri, error, state))
|
|
if not login_hint:
|
|
return HTMLResponse("<h1>NexaMFA</h1><p>authentik must send login_hint with the username.</p>", status_code=400)
|
|
|
|
challenge = await create_challenge(
|
|
session,
|
|
settings,
|
|
PushService(settings),
|
|
username=login_hint,
|
|
relying_party="authentik",
|
|
requester_ip=request.client.host if request.client else "unknown",
|
|
location=None,
|
|
ttl_seconds=settings.challenge_ttl_seconds,
|
|
oidc_state=state,
|
|
)
|
|
if not challenge:
|
|
return RedirectResponse(redirect_with_error(redirect_uri, "access_denied", state))
|
|
await session.commit()
|
|
html = f"""
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>NexaMFA Approval</title>
|
|
<style>
|
|
body {{ font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0f172a; color: #e5e7eb; }}
|
|
main {{ max-width: 560px; padding: 32px; }}
|
|
.dot {{ display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #22c55e; animation: pulse 1s infinite alternate; }}
|
|
@keyframes pulse {{ from {{ opacity: .35 }} to {{ opacity: 1 }} }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Approve sign-in</h1>
|
|
<p><span class="dot"></span> A NexaMFA push request was sent to your enrolled Android device.</p>
|
|
<p>This request expires in {settings.challenge_ttl_seconds} seconds.</p>
|
|
</main>
|
|
<script>
|
|
const challengeId = {json.dumps(str(challenge.id))};
|
|
const params = new URLSearchParams({json.dumps({
|
|
"client_id": client_id,
|
|
"redirect_uri": redirect_uri,
|
|
"scope": scope,
|
|
"state": state or "",
|
|
"nonce": nonce or "",
|
|
})});
|
|
async function poll() {{
|
|
const res = await fetch(`/oauth/status/${{challengeId}}?${{params.toString()}}`);
|
|
const data = await res.json();
|
|
if (data.redirect) window.location = data.redirect;
|
|
else if (data.done) document.body.innerHTML = "<main><h1>Request ended</h1><p>" + data.status + "</p></main>";
|
|
}}
|
|
setInterval(poll, 2000);
|
|
poll();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return HTMLResponse(html)
|
|
|
|
|
|
@router.get("/oauth/status/{challenge_id}")
|
|
async def oauth_status(
|
|
challenge_id: UUID,
|
|
client_id: str,
|
|
redirect_uri: str,
|
|
scope: str = "openid profile email",
|
|
state: str | None = None,
|
|
nonce: str | None = None,
|
|
session: AsyncSession = Depends(get_db),
|
|
settings: Settings = Depends(get_app_settings),
|
|
):
|
|
challenge = await session.get(Challenge, challenge_id)
|
|
if not challenge:
|
|
raise HTTPException(status_code=404, detail="Challenge not found")
|
|
await expire_if_needed(session, challenge)
|
|
if is_approved(challenge):
|
|
code = await issue_authorization_code(
|
|
session,
|
|
settings,
|
|
challenge=challenge,
|
|
client_id=client_id,
|
|
redirect_uri=redirect_uri,
|
|
scope=scope,
|
|
state=state,
|
|
nonce=nonce,
|
|
)
|
|
await session.commit()
|
|
return {"done": True, "redirect": redirect_with_code(redirect_uri, code, state)}
|
|
if challenge.status.value in {"denied", "expired"}:
|
|
await session.commit()
|
|
return {"done": True, "status": challenge.status.value, "redirect": redirect_with_error(redirect_uri, "access_denied", state)}
|
|
await session.commit()
|
|
return {"done": False, "status": challenge.status.value}
|
|
|
|
|
|
@router.post("/oauth/token")
|
|
async def token(
|
|
grant_type: str = Form(...),
|
|
code: str = Form(...),
|
|
redirect_uri: str = Form(...),
|
|
client_id: str = Form(...),
|
|
client_secret: str = Form(...),
|
|
session: AsyncSession = Depends(get_db),
|
|
settings: Settings = Depends(get_app_settings),
|
|
):
|
|
if grant_type != "authorization_code":
|
|
raise HTTPException(status_code=400, detail="unsupported_grant_type")
|
|
if client_id != settings.oidc_client_id or client_secret != settings.oidc_client_secret:
|
|
raise HTTPException(status_code=401, detail="invalid_client")
|
|
auth_code = await consume_authorization_code(session, code=code, client_id=client_id, redirect_uri=redirect_uri)
|
|
if not auth_code:
|
|
raise HTTPException(status_code=400, detail="invalid_grant")
|
|
response = await build_token_response(session, settings, OIDC_PRIVATE_KEY, auth_code)
|
|
await session.commit()
|
|
return JSONResponse(response)
|
|
|
|
|
|
@router.get("/oauth/userinfo")
|
|
async def userinfo():
|
|
return {"service": "NexaMFA", "note": "Use ID token claims for authenticated user details."}
|