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
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+57
View File
@@ -0,0 +1,57 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, require_admin
from app.core.security import now_utc
from app.models.audit import AuditLog
from app.models.challenge import Challenge
from app.models.device import Device
from app.models.user import User
from app.schemas.api import AuditOut, ChallengeOut, DeviceOut, UserOut
from app.services.audit import audit
router = APIRouter(prefix="/api/admin", tags=["admin"], dependencies=[Depends(require_admin)])
@router.get("/users", response_model=list[UserOut])
async def users(session: AsyncSession = Depends(get_db)):
return (await session.scalars(select(User).order_by(User.username))).all()
@router.get("/devices", response_model=list[DeviceOut])
async def devices(session: AsyncSession = Depends(get_db)):
return (await session.scalars(select(Device).order_by(desc(Device.created_at)))).all()
@router.post("/devices/{device_id}/revoke")
async def revoke_device(device_id: UUID, request: Request, session: AsyncSession = Depends(get_db)):
device = await session.get(Device, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
device.is_revoked = True
device.revoked_at = now_utc()
await audit(
session,
"device.revoked",
actor="admin",
target_type="device",
target_id=str(device.id),
ip_address=request.client.host if request.client else None,
)
await session.commit()
return {"status": "revoked"}
@router.get("/audit", response_model=list[AuditOut])
async def audit_logs(limit: int = 100, session: AsyncSession = Depends(get_db)):
limit = min(max(limit, 1), 500)
return (await session.scalars(select(AuditLog).order_by(desc(AuditLog.created_at)).limit(limit))).all()
@router.get("/challenges", response_model=list[ChallengeOut])
async def challenges(limit: int = 100, session: AsyncSession = Depends(get_db)):
limit = min(max(limit, 1), 500)
return (await session.scalars(select(Challenge).order_by(desc(Challenge.created_at)).limit(limit))).all()
+193
View File
@@ -0,0 +1,193 @@
import json
from uuid import UUID
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
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.models.oidc import AuthorizationCode
from app.services.challenges import create_challenge, expire_if_needed
from app.services.oidc import (
build_token_response,
consume_authorization_code,
is_approved,
issue_authorization_code,
jwks,
redirect_with_code,
redirect_with_error,
validate_authorize_request,
)
from app.services.push import PushService
router = APIRouter(tags=["oidc"])
OIDC_PRIVATE_KEY = None
def set_oidc_private_key(key) -> None:
global OIDC_PRIVATE_KEY
OIDC_PRIVATE_KEY = key
@router.get("/.well-known/openid-configuration")
async def openid_configuration(settings: Settings = Depends(get_app_settings)):
issuer = settings.oidc_issuer.rstrip("/")
return {
"issuer": issuer,
"authorization_endpoint": f"{issuer}/oauth/authorize",
"token_endpoint": f"{issuer}/oauth/token",
"userinfo_endpoint": f"{issuer}/oauth/userinfo",
"jwks_uri": f"{issuer}/oauth/jwks",
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "profile", "email"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"claims_supported": ["sub", "name", "preferred_username", "email", "amr"],
}
@router.get("/oauth/jwks")
async def jwks_endpoint():
return jwks(OIDC_PRIVATE_KEY)
@router.get("/oauth/authorize")
async def authorize(
request: Request,
response_type: str,
client_id: str,
redirect_uri: str,
scope: str = "openid profile email",
state: str | None = None,
nonce: str | None = None,
login_hint: str | None = None,
session: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_app_settings),
):
error = validate_authorize_request(settings, client_id, redirect_uri, response_type)
if error:
return RedirectResponse(redirect_with_error(redirect_uri, error, state))
if not login_hint:
return HTMLResponse("<h1>NexaMFA</h1><p>authentik must send login_hint with the username.</p>", status_code=400)
challenge = await create_challenge(
session,
settings,
PushService(settings),
username=login_hint,
relying_party="authentik",
requester_ip=request.client.host if request.client else "unknown",
location=None,
ttl_seconds=settings.challenge_ttl_seconds,
oidc_state=state,
)
if not challenge:
return RedirectResponse(redirect_with_error(redirect_uri, "access_denied", state))
await session.commit()
html = f"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NexaMFA Approval</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0f172a; color: #e5e7eb; }}
main {{ max-width: 560px; padding: 32px; }}
.dot {{ display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #22c55e; animation: pulse 1s infinite alternate; }}
@keyframes pulse {{ from {{ opacity: .35 }} to {{ opacity: 1 }} }}
</style>
</head>
<body>
<main>
<h1>Approve sign-in</h1>
<p><span class="dot"></span> A NexaMFA push request was sent to your enrolled Android device.</p>
<p>This request expires in {settings.challenge_ttl_seconds} seconds.</p>
</main>
<script>
const challengeId = {json.dumps(str(challenge.id))};
const params = new URLSearchParams({json.dumps({
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scope,
"state": state or "",
"nonce": nonce or "",
})});
async function poll() {{
const res = await fetch(`/oauth/status/${{challengeId}}?${{params.toString()}}`);
const data = await res.json();
if (data.redirect) window.location = data.redirect;
else if (data.done) document.body.innerHTML = "<main><h1>Request ended</h1><p>" + data.status + "</p></main>";
}}
setInterval(poll, 2000);
poll();
</script>
</body>
</html>
"""
return HTMLResponse(html)
@router.get("/oauth/status/{challenge_id}")
async def oauth_status(
challenge_id: UUID,
client_id: str,
redirect_uri: str,
scope: str = "openid profile email",
state: str | None = None,
nonce: str | None = None,
session: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_app_settings),
):
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)
if is_approved(challenge):
code = await issue_authorization_code(
session,
settings,
challenge=challenge,
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state,
nonce=nonce,
)
await session.commit()
return {"done": True, "redirect": redirect_with_code(redirect_uri, code, state)}
if challenge.status.value in {"denied", "expired"}:
await session.commit()
return {"done": True, "status": challenge.status.value, "redirect": redirect_with_error(redirect_uri, "access_denied", state)}
await session.commit()
return {"done": False, "status": challenge.status.value}
@router.post("/oauth/token")
async def token(
grant_type: str = Form(...),
code: str = Form(...),
redirect_uri: str = Form(...),
client_id: str = Form(...),
client_secret: str = Form(...),
session: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_app_settings),
):
if grant_type != "authorization_code":
raise HTTPException(status_code=400, detail="unsupported_grant_type")
if client_id != settings.oidc_client_id or client_secret != settings.oidc_client_secret:
raise HTTPException(status_code=401, detail="invalid_client")
auth_code = await consume_authorization_code(session, code=code, client_id=client_id, redirect_uri=redirect_uri)
if not auth_code:
raise HTTPException(status_code=400, detail="invalid_grant")
response = await build_token_response(session, settings, OIDC_PRIVATE_KEY, auth_code)
await session.commit()
return JSONResponse(response)
@router.get("/oauth/userinfo")
async def userinfo():
return {"service": "NexaMFA", "note": "Use ID token claims for authenticated user details."}
+150
View File
@@ -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"}
+49
View File
@@ -0,0 +1,49 @@
from functools import lru_cache
from typing import Literal
from pydantic import AnyHttpUrl, Field, computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "NexaMFA"
environment: Literal["dev", "test", "prod"] = "dev"
public_base_url: AnyHttpUrl = "https://mfa.example.com"
cors_origins: str = "http://localhost:5173"
database_url: str = "postgresql+asyncpg://nexamfa:nexamfa@postgres:5432/nexamfa"
redis_url: str = "redis://redis:6379/0"
admin_token: str = Field(default="change-me-admin-token")
oidc_issuer: str = "https://mfa.example.com"
oidc_client_id: str = "authentik"
oidc_client_secret: str = "change-me-oidc-secret"
oidc_redirect_uris: str = "https://authentik.example.com/application/o/nexamfa/callback/"
oidc_signing_key_pem: str | None = None
challenge_ttl_seconds: int = 60
enrollment_ttl_seconds: int = 600
access_token_ttl_seconds: int = 300
auth_code_ttl_seconds: int = 120
rate_limit_default: str = "120/minute"
rate_limit_approve: str = "12/minute"
fcm_project_id: str | None = None
fcm_service_account_json: str | None = None
@computed_field
@property
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@computed_field
@property
def oidc_redirect_uri_list(self) -> list[str]:
return [uri.strip() for uri in self.oidc_redirect_uris.split(",") if uri.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
+32
View File
@@ -0,0 +1,32 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import get_settings
class Base(DeclarativeBase):
pass
settings = get_settings()
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
yield session
async def create_all() -> None:
import app.models.audit # noqa: F401
import app.models.challenge # noqa: F401
import app.models.device # noqa: F401
import app.models.enrollment # noqa: F401
import app.models.oidc # noqa: F401
import app.models.user # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+25
View File
@@ -0,0 +1,25 @@
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings, get_settings
from app.core.database import get_session
async def get_db(session: AsyncSession = Depends(get_session)) -> AsyncSession:
return session
def get_app_settings() -> Settings:
return get_settings()
async def require_admin(
request: Request,
authorization: str | None = Header(default=None),
settings: Settings = Depends(get_app_settings),
) -> None:
token = None
if authorization and authorization.lower().startswith("bearer "):
token = authorization.split(" ", 1)[1]
if token != settings.admin_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin token")
+98
View File
@@ -0,0 +1,98 @@
from datetime import UTC, datetime, timedelta
import base64
import json
import secrets
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature
from cryptography.hazmat.primitives.hashes import SHA256
import jwt
def now_utc() -> datetime:
return datetime.now(UTC)
def random_token(bytes_len: int = 32) -> str:
return secrets.token_urlsafe(bytes_len)
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def b64url_decode(value: str) -> bytes:
pad = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(value + pad)
def canonical_json(payload: dict[str, Any]) -> bytes:
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def load_public_key(public_key_pem: str):
return serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
def verify_signature(public_key_pem: str, payload: dict[str, Any], signature_b64: str) -> bool:
key = load_public_key(public_key_pem)
signature = b64url_decode(signature_b64)
message = canonical_json(payload)
try:
if isinstance(key, ec.EllipticCurvePublicKey):
if len(signature) == 64:
signature = encode_dss_signature(
int.from_bytes(signature[:32], "big"),
int.from_bytes(signature[32:], "big"),
)
key.verify(signature, message, ec.ECDSA(SHA256()))
return True
if isinstance(key, rsa.RSAPublicKey):
key.verify(signature, message, padding.PKCS1v15(), SHA256())
return True
except Exception:
return False
return False
def generate_oidc_private_key() -> rsa.RSAPrivateKey:
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
def serialize_private_key(key: rsa.RSAPrivateKey) -> str:
return key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
).decode("utf-8")
def load_or_create_oidc_key(key_pem: str | None) -> rsa.RSAPrivateKey:
if key_pem:
return serialization.load_pem_private_key(key_pem.encode("utf-8"), password=None)
return generate_oidc_private_key()
def public_jwk(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, str]:
numbers = private_key.public_key().public_numbers()
return {
"kty": "RSA",
"use": "sig",
"kid": kid,
"alg": "RS256",
"n": b64url(numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big")),
"e": b64url(numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big")),
}
def sign_jwt(claims: dict[str, Any], private_key: rsa.RSAPrivateKey, kid: str) -> str:
claims = claims.copy()
claims.setdefault("iat", int(now_utc().timestamp()))
return jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": kid})
def expires_in(seconds: int) -> datetime:
return now_utc() + timedelta(seconds=seconds)
+67
View File
@@ -0,0 +1,67 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from starlette.responses import JSONResponse
from app.api import admin, oidc, public
from app.core.config import get_settings
from app.core.database import create_all
from app.core.security import load_or_create_oidc_key
settings = get_settings()
REQUESTS = Counter("nexamfa_http_requests_total", "HTTP requests", ["method", "path", "status"])
LATENCY = Histogram("nexamfa_http_request_duration_seconds", "HTTP request latency", ["method", "path"])
limiter = Limiter(key_func=get_remote_address, default_limits=[settings.rate_limit_default])
@asynccontextmanager
async def lifespan(app: FastAPI):
oidc.set_oidc_private_key(load_or_create_oidc_key(settings.oidc_signing_key_pem))
await create_all()
yield
app = FastAPI(title="NexaMFA", version="0.1.0", lifespan=lifespan)
app.state.limiter = limiter
app.add_middleware(SlowAPIMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(_: Request, exc: RateLimitExceeded):
return JSONResponse({"detail": "Rate limit exceeded"}, status_code=429)
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
with LATENCY.labels(request.method, request.url.path).time():
response = await call_next(request)
REQUESTS.labels(request.method, request.url.path, str(response.status_code)).inc()
return response
@app.get("/health")
async def health():
return {"status": "ok", "service": "NexaMFA"}
@app.get("/metrics")
async def metrics():
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
app.include_router(public.router)
app.include_router(admin.router)
app.include_router(oidc.router)
+8
View File
@@ -0,0 +1,8 @@
from app.models.audit import AuditLog
from app.models.challenge import Challenge, ChallengeStatus
from app.models.device import Device
from app.models.enrollment import Enrollment
from app.models.oidc import AuthorizationCode
from app.models.user import User
__all__ = ["AuditLog", "AuthorizationCode", "Challenge", "ChallengeStatus", "Device", "Enrollment", "User"]
+20
View File
@@ -0,0 +1,20 @@
from datetime import datetime
import uuid
from sqlalchemy import JSON, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
actor: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
action: Mapped[str] = mapped_column(String(128), index=True)
target_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
target_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
+36
View File
@@ -0,0 +1,36 @@
from datetime import datetime
from enum import StrEnum
import uuid
from sqlalchemy import JSON, DateTime, Enum, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ChallengeStatus(StrEnum):
pending = "pending"
approved = "approved"
denied = "denied"
expired = "expired"
class Challenge(Base):
__tablename__ = "challenges"
__table_args__ = (UniqueConstraint("nonce", name="uq_challenges_nonce"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
device_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("devices.id", ondelete="SET NULL"), nullable=True, index=True)
status: Mapped[ChallengeStatus] = mapped_column(Enum(ChallengeStatus), default=ChallengeStatus.pending, index=True)
relying_party: Mapped[str] = mapped_column(String(255))
requester_ip: Mapped[str] = mapped_column(String(64))
location: Mapped[str | None] = mapped_column(String(255), nullable=True)
nonce: Mapped[str] = mapped_column(String(128), index=True)
payload: Mapped[dict] = mapped_column(JSON)
signature: Mapped[str | None] = mapped_column(Text, nullable=True)
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
responded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
oidc_state: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
import uuid
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Device(Base):
__tablename__ = "devices"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(255))
platform: Mapped[str] = mapped_column(String(64), default="android")
public_key_pem: Mapped[str] = mapped_column(Text)
public_key_alg: Mapped[str] = mapped_column(String(64), default="ES256")
fcm_token: Mapped[str | None] = mapped_column(Text, nullable=True)
app_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
attestation: Mapped[dict | None] = mapped_column(JSON, nullable=True)
is_revoked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", back_populates="devices")
+18
View File
@@ -0,0 +1,18 @@
from datetime import datetime
import uuid
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Enrollment(Base):
__tablename__ = "enrollments"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
used: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+24
View File
@@ -0,0 +1,24 @@
from datetime import datetime
import uuid
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AuthorizationCode(Base):
__tablename__ = "authorization_codes"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
code_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
client_id: Mapped[str] = mapped_column(String(255), index=True)
redirect_uri: Mapped[str] = mapped_column(String(1024))
scope: Mapped[str] = mapped_column(String(1024), default="openid profile email")
state: Mapped[str | None] = mapped_column(String(1024), nullable=True)
nonce: Mapped[str | None] = mapped_column(String(1024), nullable=True)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
challenge_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
used: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+20
View File
@@ -0,0 +1,20 @@
from datetime import datetime
import uuid
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(255), unique=True, index=True)
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
devices = relationship("Device", back_populates="user")
+106
View File
@@ -0,0 +1,106 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from pydantic import BaseModel, Field
class UserOut(BaseModel):
id: UUID
username: str
display_name: str | None = None
email: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
class DeviceOut(BaseModel):
id: UUID
user_id: UUID
name: str
platform: str
public_key_alg: str
is_revoked: bool
revoked_at: datetime | None = None
last_seen_at: datetime | None = None
created_at: datetime
model_config = {"from_attributes": True}
class EnrollmentStartRequest(BaseModel):
username: str = Field(min_length=1, max_length=255)
display_name: str | None = None
email: str | None = None
class EnrollmentStartResponse(BaseModel):
enrollment_id: UUID
qr_payload: dict[str, Any]
qr_png_base64: str
expires_at: datetime
class EnrollmentFinishRequest(BaseModel):
enrollment_token: str
device_name: str = Field(min_length=1, max_length=255)
public_key_pem: str
public_key_alg: str = "ES256"
fcm_token: str | None = None
app_version: str | None = None
attestation: dict[str, Any] | None = None
class EnrollmentFinishResponse(BaseModel):
device_id: UUID
user_id: UUID
class ChallengeCreateRequest(BaseModel):
username: str
relying_party: str
requester_ip: str
location: str | None = None
ttl_seconds: int | None = Field(default=None, ge=10, le=300)
oidc_state: str | None = None
class ChallengeOut(BaseModel):
id: UUID
user_id: UUID
device_id: UUID | None
status: str
relying_party: str
requester_ip: str
location: str | None
payload: dict[str, Any]
issued_at: datetime
expires_at: datetime
responded_at: datetime | None
model_config = {"from_attributes": True}
class ChallengeApprovalRequest(BaseModel):
device_id: UUID
signature: str
payload: dict[str, Any]
class DenyRequest(BaseModel):
device_id: UUID | None = None
reason: str | None = Field(default=None, max_length=255)
class AuditOut(BaseModel):
id: UUID
actor: str | None
action: str
target_type: str | None
target_id: str | None
ip_address: str | None
metadata_json: dict[str, Any] | None
created_at: datetime
model_config = {"from_attributes": True}
+25
View File
@@ -0,0 +1,25 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit import AuditLog
async def audit(
session: AsyncSession,
action: str,
*,
actor: str | None = None,
target_type: str | None = None,
target_id: str | None = None,
ip_address: str | None = None,
metadata: dict | None = None,
) -> None:
session.add(
AuditLog(
actor=actor,
action=action,
target_type=target_type,
target_id=target_id,
ip_address=ip_address,
metadata_json=metadata,
)
)
+138
View File
@@ -0,0 +1,138 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.security import expires_in, now_utc, random_token, verify_signature
from app.models.challenge import Challenge, ChallengeStatus
from app.models.device import Device
from app.models.user import User
from app.services.audit import audit
from app.services.push import PushService
def build_challenge_payload(challenge: Challenge, username: str) -> dict:
return {
"challenge_id": str(challenge.id),
"user_id": str(challenge.user_id),
"username": username,
"relying_party": challenge.relying_party,
"requester_ip": challenge.requester_ip,
"location": challenge.location,
"issued_at": challenge.issued_at.isoformat(),
"expires_at": challenge.expires_at.isoformat(),
"nonce": challenge.nonce,
}
async def create_challenge(
session: AsyncSession,
settings: Settings,
push: PushService,
*,
username: str,
relying_party: str,
requester_ip: str,
location: str | None,
ttl_seconds: int | None,
oidc_state: str | None = None,
) -> Challenge | None:
user = await session.scalar(select(User).where(User.username == username))
if not user:
return None
device = await session.scalar(
select(Device)
.where(Device.user_id == user.id, Device.is_revoked.is_(False))
.order_by(Device.last_seen_at.desc().nullslast(), Device.created_at.desc())
)
if not device:
return None
issued = now_utc()
challenge = Challenge(
user_id=user.id,
device_id=device.id,
relying_party=relying_party,
requester_ip=requester_ip,
location=location,
nonce=random_token(32),
issued_at=issued,
expires_at=expires_in(ttl_seconds or settings.challenge_ttl_seconds),
oidc_state=oidc_state,
payload={},
)
session.add(challenge)
await session.flush()
challenge.payload = build_challenge_payload(challenge, user.username)
await audit(
session,
"challenge.created",
actor=user.username,
target_type="challenge",
target_id=str(challenge.id),
ip_address=requester_ip,
metadata={"device_id": str(device.id), "oidc": bool(oidc_state)},
)
await push.send_challenge(device.fcm_token, str(challenge.id))
return challenge
async def expire_if_needed(session: AsyncSession, challenge: Challenge) -> bool:
if challenge.status == ChallengeStatus.pending and challenge.expires_at <= now_utc():
challenge.status = ChallengeStatus.expired
challenge.responded_at = now_utc()
await audit(session, "challenge.expired", target_type="challenge", target_id=str(challenge.id))
return True
return False
async def approve_challenge(
session: AsyncSession,
*,
challenge: Challenge,
device_id,
payload: dict,
signature: str,
ip_address: str | None,
) -> tuple[bool, str]:
await expire_if_needed(session, challenge)
if challenge.status != ChallengeStatus.pending:
await audit(session, "challenge.replay_blocked", target_type="challenge", target_id=str(challenge.id), ip_address=ip_address)
return False, "challenge_not_pending"
if str(device_id) != str(challenge.device_id):
return False, "wrong_device"
device = await session.get(Device, device_id)
if not device or device.is_revoked:
await audit(session, "challenge.revoked_device_blocked", target_type="challenge", target_id=str(challenge.id), ip_address=ip_address)
return False, "device_revoked"
if payload != challenge.payload:
return False, "payload_mismatch"
if not verify_signature(device.public_key_pem, payload, signature):
await audit(session, "challenge.signature_invalid", target_type="challenge", target_id=str(challenge.id), ip_address=ip_address)
return False, "invalid_signature"
challenge.status = ChallengeStatus.approved
challenge.signature = signature
challenge.responded_at = now_utc()
device.last_seen_at = now_utc()
await audit(session, "challenge.approved", actor=str(device.user_id), target_type="challenge", target_id=str(challenge.id), ip_address=ip_address, metadata={"device_id": str(device.id)})
return True, "approved"
async def deny_challenge(
session: AsyncSession,
*,
challenge: Challenge,
device_id,
reason: str | None,
ip_address: str | None,
) -> tuple[bool, str]:
await expire_if_needed(session, challenge)
if challenge.status != ChallengeStatus.pending:
return False, "challenge_not_pending"
if device_id and str(device_id) != str(challenge.device_id):
return False, "wrong_device"
challenge.status = ChallengeStatus.denied
challenge.responded_at = now_utc()
await audit(session, "challenge.denied", target_type="challenge", target_id=str(challenge.id), ip_address=ip_address, metadata={"reason": reason})
return True, "denied"
+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
+132
View File
@@ -0,0 +1,132 @@
import hashlib
from urllib.parse import urlencode
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.security import expires_in, now_utc, public_jwk, random_token, sign_jwt
from app.models.challenge import Challenge, ChallengeStatus
from app.models.oidc import AuthorizationCode
from app.models.user import User
OIDC_KEY_ID = "nexamfa-oidc-1"
def hash_code(code: str) -> str:
return hashlib.sha256(code.encode("utf-8")).hexdigest()
def validate_authorize_request(settings: Settings, client_id: str, redirect_uri: str, response_type: str) -> str | None:
if client_id != settings.oidc_client_id:
return "invalid_client"
if redirect_uri not in settings.oidc_redirect_uri_list:
return "invalid_redirect_uri"
if response_type != "code":
return "unsupported_response_type"
return None
async def issue_authorization_code(
session: AsyncSession,
settings: Settings,
*,
challenge: Challenge,
client_id: str,
redirect_uri: str,
scope: str,
state: str | None,
nonce: str | None,
) -> str:
code = random_token(32)
session.add(
AuthorizationCode(
code_hash=hash_code(code),
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state,
nonce=nonce,
user_id=challenge.user_id,
challenge_id=challenge.id,
expires_at=expires_in(settings.auth_code_ttl_seconds),
)
)
return code
async def consume_authorization_code(
session: AsyncSession,
*,
code: str,
client_id: str,
redirect_uri: str,
) -> AuthorizationCode | None:
auth_code = await session.scalar(select(AuthorizationCode).where(AuthorizationCode.code_hash == hash_code(code)))
if not auth_code or auth_code.used or auth_code.expires_at <= now_utc():
return None
if auth_code.client_id != client_id or auth_code.redirect_uri != redirect_uri:
return None
auth_code.used = True
return auth_code
def redirect_with_code(redirect_uri: str, code: str, state: str | None) -> str:
query = {"code": code}
if state:
query["state"] = state
return f"{redirect_uri}?{urlencode(query)}"
def redirect_with_error(redirect_uri: str, error: str, state: str | None = None) -> str:
query = {"error": error}
if state:
query["state"] = state
return f"{redirect_uri}?{urlencode(query)}"
async def build_token_response(
session: AsyncSession,
settings: Settings,
private_key,
auth_code: AuthorizationCode,
) -> dict:
user = await session.get(User, auth_code.user_id)
now = int(now_utc().timestamp())
exp = now + settings.access_token_ttl_seconds
claims = {
"iss": settings.oidc_issuer,
"sub": str(user.id),
"aud": auth_code.client_id,
"exp": exp,
"iat": now,
"auth_time": now,
"amr": ["push", "biometric"],
"name": user.display_name or user.username,
"preferred_username": user.username,
"email": user.email,
}
if auth_code.nonce:
claims["nonce"] = auth_code.nonce
id_token = sign_jwt(claims, private_key, OIDC_KEY_ID)
access_token = sign_jwt(
{"iss": settings.oidc_issuer, "sub": str(user.id), "aud": "nexamfa-api", "exp": exp, "scope": auth_code.scope},
private_key,
OIDC_KEY_ID,
)
return {
"access_token": access_token,
"id_token": id_token,
"token_type": "Bearer",
"expires_in": settings.access_token_ttl_seconds,
"scope": auth_code.scope,
}
def jwks(private_key) -> dict:
return {"keys": [public_jwk(private_key, OIDC_KEY_ID)]}
def is_approved(challenge: Challenge) -> bool:
return challenge.status == ChallengeStatus.approved
+44
View File
@@ -0,0 +1,44 @@
import json
import logging
from google.oauth2 import service_account
from google.auth.transport.requests import Request as GoogleAuthRequest
import httpx
from app.core.config import Settings
logger = logging.getLogger(__name__)
class PushService:
def __init__(self, settings: Settings):
self.settings = settings
async def send_challenge(self, fcm_token: str | None, challenge_id: str) -> None:
if not fcm_token:
logger.info("Skipping push: device has no FCM token for challenge %s", challenge_id)
return
if not self.settings.fcm_project_id or not self.settings.fcm_service_account_json:
logger.info("Skipping push: FCM credentials not configured for challenge %s", challenge_id)
return
credentials = service_account.Credentials.from_service_account_info(
json.loads(self.settings.fcm_service_account_json),
scopes=["https://www.googleapis.com/auth/firebase.messaging"],
)
credentials.refresh(GoogleAuthRequest())
payload = {
"message": {
"token": fcm_token,
"data": {"challenge_id": challenge_id},
"android": {"priority": "high"},
}
}
logger.debug("Prepared FCM payload: %s", json.dumps(payload))
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
f"https://fcm.googleapis.com/v1/projects/{self.settings.fcm_project_id}/messages:send",
json=payload,
headers={"Authorization": f"Bearer {credentials.token}"},
)
response.raise_for_status()