Add TOTP-based Multi-Factor Authentication (MFA) for local users

Global MFA toggle in Security settings, QR code setup on first login,
6-digit TOTP verification on subsequent logins. Azure AD users exempt.
Admins can reset user MFA. TOTP secrets encrypted at rest with Fernet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 23:14:06 +01:00
parent 647630ff19
commit 3d28f13054
13 changed files with 615 additions and 62 deletions

View File

@@ -1,8 +1,9 @@
"""Security utilities — password hashing (bcrypt) and token encryption (Fernet)."""
"""Security utilities — password hashing (bcrypt), token encryption (Fernet), TOTP."""
import os
import secrets
import pyotp
from cryptography.fernet import Fernet
from passlib.context import CryptContext
@@ -91,6 +92,32 @@ def generate_relay_secret() -> str:
return secrets.token_hex(16)
# ---------------------------------------------------------------------------
# TOTP (Time-based One-Time Password)
# ---------------------------------------------------------------------------
def generate_totp_secret() -> str:
"""Generate a new random TOTP secret (base32-encoded)."""
return pyotp.random_base32()
def verify_totp(secret: str, code: str) -> bool:
"""Verify a 6-digit TOTP code against a secret.
Allows a window of +/- 1 interval (30s) to account for clock drift.
"""
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
def generate_totp_uri(secret: str, username: str, issuer: str = "NetBird MSP") -> str:
"""Generate an otpauth:// URI for QR code generation."""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(name=username, issuer_name=issuer)
# ---------------------------------------------------------------------------
# Misc key generation
# ---------------------------------------------------------------------------
def generate_datastore_encryption_key() -> str:
"""Generate a base64-encoded 32-byte key for NetBird DataStoreEncryptionKey.

View File

@@ -23,6 +23,19 @@ class ChangePasswordRequest(BaseModel):
new_password: str = Field(..., min_length=12, max_length=128)
class MfaTokenRequest(BaseModel):
"""Request containing only an MFA token (for setup initiation)."""
mfa_token: str = Field(..., min_length=1)
class MfaVerifyRequest(BaseModel):
"""MFA TOTP verification payload."""
mfa_token: str = Field(..., min_length=1)
totp_code: str = Field(..., min_length=6, max_length=6)
# ---------------------------------------------------------------------------
# Customer
# ---------------------------------------------------------------------------
@@ -113,6 +126,7 @@ class SystemConfigUpdate(BaseModel):
branding_name: Optional[str] = Field(None, max_length=255)
branding_subtitle: Optional[str] = Field(None, max_length=255)
default_language: Optional[str] = Field(None, max_length=10)
mfa_enabled: Optional[bool] = None
azure_enabled: Optional[bool] = None
azure_tenant_id: Optional[str] = Field(None, max_length=255)
azure_client_id: Optional[str] = Field(None, max_length=255)