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
+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)