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 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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."}
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user