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,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