Files
NexaMFA/backend/app/api/public.py
T
nessi f925009977 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
2026-06-28 09:37:51 +02:00

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"}