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,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
|
||||
Reference in New Issue
Block a user