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 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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."}
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user