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:
2026-06-28 09:37:51 +02:00
commit f925009977
57 changed files with 2776 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+57
View File
@@ -0,0 +1,57 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, require_admin
from app.core.security import now_utc
from app.models.audit import AuditLog
from app.models.challenge import Challenge
from app.models.device import Device
from app.models.user import User
from app.schemas.api import AuditOut, ChallengeOut, DeviceOut, UserOut
from app.services.audit import audit
router = APIRouter(prefix="/api/admin", tags=["admin"], dependencies=[Depends(require_admin)])
@router.get("/users", response_model=list[UserOut])
async def users(session: AsyncSession = Depends(get_db)):
return (await session.scalars(select(User).order_by(User.username))).all()
@router.get("/devices", response_model=list[DeviceOut])
async def devices(session: AsyncSession = Depends(get_db)):
return (await session.scalars(select(Device).order_by(desc(Device.created_at)))).all()
@router.post("/devices/{device_id}/revoke")
async def revoke_device(device_id: UUID, request: Request, session: AsyncSession = Depends(get_db)):
device = await session.get(Device, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
device.is_revoked = True
device.revoked_at = now_utc()
await audit(
session,
"device.revoked",
actor="admin",
target_type="device",
target_id=str(device.id),
ip_address=request.client.host if request.client else None,
)
await session.commit()
return {"status": "revoked"}
@router.get("/audit", response_model=list[AuditOut])
async def audit_logs(limit: int = 100, session: AsyncSession = Depends(get_db)):
limit = min(max(limit, 1), 500)
return (await session.scalars(select(AuditLog).order_by(desc(AuditLog.created_at)).limit(limit))).all()
@router.get("/challenges", response_model=list[ChallengeOut])
async def challenges(limit: int = 100, session: AsyncSession = Depends(get_db)):
limit = min(max(limit, 1), 500)
return (await session.scalars(select(Challenge).order_by(desc(Challenge.created_at)).limit(limit))).all()
+193
View File
@@ -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."}
+150
View File
@@ -0,0 +1,150 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
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.schemas.api import (
ChallengeApprovalRequest,
ChallengeCreateRequest,
ChallengeOut,
DenyRequest,
EnrollmentFinishRequest,
EnrollmentFinishResponse,
EnrollmentStartRequest,
EnrollmentStartResponse,
)
from app.services.challenges import approve_challenge, create_challenge, deny_challenge, expire_if_needed
from app.services.enrollment import finish_enrollment, start_enrollment
from app.services.push import PushService
router = APIRouter(prefix="/api", tags=["api"])
@router.post("/enroll/start", response_model=EnrollmentStartResponse)
async def enroll_start(
body: EnrollmentStartRequest,
request: Request,
session: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_app_settings),
):
enrollment, qr_payload, qr_b64 = await start_enrollment(
session,
settings,
username=body.username,
display_name=body.display_name,
email=body.email,
ip_address=request.client.host if request.client else None,
)
await session.commit()
return EnrollmentStartResponse(
enrollment_id=enrollment.id,
qr_payload=qr_payload,
qr_png_base64=qr_b64,
expires_at=enrollment.expires_at,
)
@router.post("/enroll/finish", response_model=EnrollmentFinishResponse)
async def enroll_finish(
body: EnrollmentFinishRequest,
request: Request,
session: AsyncSession = Depends(get_db),
):
device = await finish_enrollment(
session,
enrollment_token=body.enrollment_token,
device_name=body.device_name,
public_key_pem=body.public_key_pem,
public_key_alg=body.public_key_alg,
fcm_token=body.fcm_token,
app_version=body.app_version,
attestation=body.attestation,
ip_address=request.client.host if request.client else None,
)
if not device:
raise HTTPException(status_code=400, detail="Invalid or expired enrollment")
await session.commit()
return EnrollmentFinishResponse(device_id=device.id, user_id=device.user_id)
@router.post("/challenges", response_model=ChallengeOut)
async def challenges_create(
body: ChallengeCreateRequest,
session: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_app_settings),
):
challenge = await create_challenge(
session,
settings,
PushService(settings),
username=body.username,
relying_party=body.relying_party,
requester_ip=body.requester_ip,
location=body.location,
ttl_seconds=body.ttl_seconds,
oidc_state=body.oidc_state,
)
if not challenge:
raise HTTPException(status_code=404, detail="No active enrolled device for user")
await session.commit()
return challenge
@router.get("/challenges/{challenge_id}", response_model=ChallengeOut)
async def challenges_get(challenge_id: UUID, session: AsyncSession = Depends(get_db)):
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)
await session.commit()
return challenge
@router.post("/challenges/{challenge_id}/approve")
async def challenges_approve(
challenge_id: UUID,
body: ChallengeApprovalRequest,
request: Request,
session: AsyncSession = Depends(get_db),
):
challenge = await session.get(Challenge, challenge_id)
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
ok, reason = await approve_challenge(
session,
challenge=challenge,
device_id=body.device_id,
payload=body.payload,
signature=body.signature,
ip_address=request.client.host if request.client else None,
)
await session.commit()
if not ok:
raise HTTPException(status_code=400, detail=reason)
return {"status": "approved"}
@router.post("/challenges/{challenge_id}/deny")
async def challenges_deny(
challenge_id: UUID,
body: DenyRequest,
request: Request,
session: AsyncSession = Depends(get_db),
):
challenge = await session.get(Challenge, challenge_id)
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
ok, reason = await deny_challenge(
session,
challenge=challenge,
device_id=body.device_id,
reason=body.reason,
ip_address=request.client.host if request.client else None,
)
await session.commit()
if not ok:
raise HTTPException(status_code=400, detail=reason)
return {"status": "denied"}