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,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
|
||||
Reference in New Issue
Block a user