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
151 lines
4.8 KiB
Python
151 lines
4.8 KiB
Python
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"}
|