chore: initial project setup with backend, frontend, Android app, and CI/CD
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
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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."}
|
||||
Reference in New Issue
Block a user