Files
nessi f925009977 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
2026-06-28 09:37:51 +02:00

133 lines
3.8 KiB
Python

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