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,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