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
+94
View File
@@ -0,0 +1,94 @@
import base64
import hashlib
import json
from io import BytesIO
import qrcode
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.security import expires_in, random_token, now_utc
from app.models.device import Device
from app.models.enrollment import Enrollment
from app.models.user import User
from app.services.audit import audit
def token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
async def start_enrollment(
session: AsyncSession,
settings: Settings,
*,
username: str,
display_name: str | None,
email: str | None,
ip_address: str | None,
) -> tuple[Enrollment, dict, str]:
user = await session.scalar(select(User).where(User.username == username))
if not user:
user = User(username=username, display_name=display_name, email=email)
session.add(user)
await session.flush()
raw_token = random_token(32)
enrollment = Enrollment(
user_id=user.id,
token_hash=token_hash(raw_token),
expires_at=expires_in(settings.enrollment_ttl_seconds),
)
session.add(enrollment)
await session.flush()
qr_payload = {
"type": "nexamfa-enrollment",
"server_url": str(settings.public_base_url).rstrip("/"),
"enrollment_id": str(enrollment.id),
"enrollment_token": raw_token,
"username": user.username,
"expires_at": enrollment.expires_at.isoformat(),
}
qr = qrcode.make(json.dumps(qr_payload, separators=(",", ":")))
buf = BytesIO()
qr.save(buf, format="PNG")
qr_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
await audit(session, "enrollment.started", actor=username, target_type="user", target_id=str(user.id), ip_address=ip_address)
return enrollment, qr_payload, qr_b64
async def finish_enrollment(
session: AsyncSession,
*,
enrollment_token: str,
device_name: str,
public_key_pem: str,
public_key_alg: str,
fcm_token: str | None,
app_version: str | None,
attestation: dict | None,
ip_address: str | None,
) -> Device | None:
enrollment = await session.scalar(
select(Enrollment).where(Enrollment.token_hash == token_hash(enrollment_token))
)
if not enrollment or enrollment.used or enrollment.expires_at <= now_utc():
return None
device = Device(
user_id=enrollment.user_id,
name=device_name,
public_key_pem=public_key_pem,
public_key_alg=public_key_alg,
fcm_token=fcm_token,
app_version=app_version,
attestation=attestation,
last_seen_at=now_utc(),
)
enrollment.used = True
session.add(device)
await session.flush()
await audit(session, "device.enrolled", actor=str(enrollment.user_id), target_type="device", target_id=str(device.id), ip_address=ip_address)
return device