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
133 lines
3.8 KiB
Python
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
|