diff --git a/README.md b/README.md index 0540dfd..a31bf68 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,10 @@ A management solution for running isolated NetBird instances for your MSP busine ### Security - **JWT Authentication** — Token-based API authentication -- **Azure AD / OIDC** — Optional single sign-on via Microsoft Entra ID -- **Encrypted Credentials** — NPM passwords and relay secrets are Fernet-encrypted -- **User Management** — Create, edit, and delete admin users +- **Multi-Factor Authentication (MFA)** — Optional TOTP-based MFA for all local users, activatable in Security settings +- **Azure AD / OIDC** — Optional single sign-on via Microsoft Entra ID (exempt from MFA) +- **Encrypted Credentials** — NPM passwords, relay secrets, and TOTP secrets are Fernet-encrypted at rest +- **User Management** — Create, edit, delete admin users, reset passwords and MFA --- @@ -270,7 +271,9 @@ Available under **Settings** in the web interface: |-----|----------| | **System** | Base domain, admin email, NPM credentials, Docker images, port ranges, data directory | | **Branding** | Platform name, subtitle, logo upload, default language | -| **Users** | Create/edit/delete admin users, per-user language preference | +| **Users** | Create/edit/delete admin users, per-user language preference, MFA reset | +| **Azure AD** | Azure AD / Entra ID SSO configuration | +| **Security** | Change admin password, enable/disable MFA globally, manage own TOTP | | **Monitoring** | System resources, Docker stats | Changes are applied immediately without restart. @@ -315,6 +318,30 @@ The dashboard shows: - **Per-user default** — Set in Settings > Users during user creation - **System default** — Set in Settings > Branding +### Multi-Factor Authentication (MFA) + +TOTP-based MFA can be enabled globally for all local users. Azure AD users are not affected (they use their own MFA). + +#### Enable MFA +1. Go to **Settings > Security** +2. Toggle **"Enable MFA for all local users"** +3. Click **"Save MFA Settings"** + +#### First Login with MFA +When MFA is enabled and a user logs in for the first time: +1. Enter username and password as usual +2. A QR code is displayed — scan it with an authenticator app (Google Authenticator, Microsoft Authenticator, Authy, etc.) +3. Enter the 6-digit code from the app to complete setup + +#### Subsequent Logins +1. Enter username and password +2. Enter the 6-digit code from the authenticator app + +#### Admin MFA Management +- **Reset a user's MFA** — In Settings > Users, click "Reset MFA" to force re-enrollment on next login +- **Disable own TOTP** — In Settings > Security, click "Disable my TOTP" to remove your own MFA setup +- **Disable MFA globally** — Uncheck the toggle in Settings > Security to allow login without MFA + --- ## API Documentation @@ -352,6 +379,13 @@ GET /api/settings/branding # Get branding (public, no auth) PUT /api/settings # Update system settings GET /api/users # List users POST /api/users # Create user +POST /api/users/{id}/reset-mfa # Reset user's MFA + +POST /api/auth/mfa/setup # Generate TOTP secret + QR code +POST /api/auth/mfa/setup/complete # Verify first TOTP code +POST /api/auth/mfa/verify # Verify TOTP code on login +GET /api/auth/mfa/status # Get MFA status +POST /api/auth/mfa/disable # Disable own TOTP ``` ### Example: Create Customer via API @@ -441,15 +475,16 @@ Via the Web UI: ## Security Best Practices -1. **Change default credentials** immediately after installation -2. **Use strong passwords** (12+ characters recommended) -3. **Keep NPM credentials secure** — they are stored encrypted in the database -4. **Enable firewall** and only open required ports (TCP 8000, UDP relay range) -5. **Use HTTPS** — put the MSP appliance behind a reverse proxy with SSL -6. **Regular updates** — both the appliance and NetBird images -7. **Backup your database** — `data/netbird_msp.db` contains all configuration -8. **Monitor logs** — check for suspicious activity -9. **Restrict access** — use VPN or IP whitelist for the management interface +1. **Enable MFA** — activate TOTP-based multi-factor authentication in Settings > Security +2. **Change default credentials** immediately after installation +3. **Use strong passwords** (12+ characters recommended) +4. **Keep NPM credentials secure** — they are stored encrypted in the database +5. **Enable firewall** and only open required ports (TCP 8000, UDP relay range) +6. **Use HTTPS** — put the MSP appliance behind a reverse proxy with SSL +7. **Regular updates** — both the appliance and NetBird images +8. **Backup your database** — `data/netbird_msp.db` contains all configuration +9. **Monitor logs** — check for suspicious activity +10. **Restrict access** — use VPN or IP whitelist for the management interface --- diff --git a/app/database.py b/app/database.py index 62b62c9..29d1f97 100644 --- a/app/database.py +++ b/app/database.py @@ -80,6 +80,9 @@ def _run_migrations() -> None: ("system_config", "default_language", "TEXT DEFAULT 'en'"), ("users", "default_language", "TEXT"), ("deployments", "npm_stream_id", "INTEGER"), + ("system_config", "mfa_enabled", "BOOLEAN DEFAULT 0"), + ("users", "totp_secret_encrypted", "TEXT"), + ("users", "totp_enabled", "BOOLEAN DEFAULT 0"), ] for table, column, col_type in migrations: if not _has_column(table, column): diff --git a/app/dependencies.py b/app/dependencies.py index a18a583..c2d754c 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -30,6 +30,39 @@ def create_access_token(username: str, expires_delta: Optional[timedelta] = None return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALGORITHM) +def create_mfa_token(username: str) -> str: + """Create a short-lived JWT for the MFA verification step (5 min).""" + expire = datetime.utcnow() + timedelta(minutes=5) + payload = {"sub": username, "exp": expire, "purpose": "mfa"} + return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALGORITHM) + + +def verify_mfa_token(token: str) -> str: + """Verify an MFA-purpose JWT and return the username. + + Raises HTTPException if the token is invalid, expired, or not an MFA token. + """ + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALGORITHM]) + if payload.get("purpose") != "mfa": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid MFA token.", + ) + username: Optional[str] = payload.get("sub") + if not username: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid MFA token.", + ) + return username + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="MFA token expired or invalid.", + ) + + def get_current_user( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme), db: Session = Depends(get_db), diff --git a/app/models.py b/app/models.py index e6e6789..c6682de 100644 --- a/app/models.py +++ b/app/models.py @@ -161,6 +161,7 @@ class SystemConfig(Base): ) branding_logo_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) default_language: Mapped[Optional[str]] = mapped_column(String(10), default="en") + mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False) azure_enabled: Mapped[bool] = mapped_column(Boolean, default=False) azure_tenant_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) azure_client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) @@ -193,6 +194,7 @@ class SystemConfig(Base): "branding_subtitle": self.branding_subtitle or "Multi-Tenant Management Platform", "branding_logo_path": self.branding_logo_path, "default_language": self.default_language or "en", + "mfa_enabled": bool(self.mfa_enabled), "azure_enabled": bool(self.azure_enabled), "azure_tenant_id": self.azure_tenant_id or "", "azure_client_id": self.azure_client_id or "", @@ -252,10 +254,12 @@ class User(Base): role: Mapped[str] = mapped_column(String(20), default="admin") auth_provider: Mapped[str] = mapped_column(String(20), default="local") default_language: Mapped[Optional[str]] = mapped_column(String(10), nullable=True, default=None) + totp_secret_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) def to_dict(self) -> dict: - """Serialize user to dictionary (no password).""" + """Serialize user to dictionary (no password, no TOTP secret).""" return { "id": self.id, "username": self.username, @@ -264,5 +268,6 @@ class User(Base): "role": self.role or "admin", "auth_provider": self.auth_provider or "local", "default_language": self.default_language, + "totp_enabled": bool(self.totp_enabled), "created_at": self.created_at.isoformat() if self.created_at else None, } diff --git a/app/routers/auth.py b/app/routers/auth.py index a08ea2a..7e541cc 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -1,5 +1,7 @@ -"""Authentication API endpoints — login, logout, current user, password change, Azure AD.""" +"""Authentication API endpoints — login, logout, current user, password change, MFA, Azure AD.""" +import base64 +import io import logging import secrets from datetime import datetime @@ -9,10 +11,18 @@ from pydantic import BaseModel from sqlalchemy.orm import Session from app.database import get_db -from app.dependencies import create_access_token, get_current_user +from app.dependencies import create_access_token, create_mfa_token, get_current_user, verify_mfa_token from app.models import SystemConfig, User -from app.utils.security import decrypt_value, hash_password, verify_password -from app.utils.validators import ChangePasswordRequest, LoginRequest +from app.utils.security import ( + decrypt_value, + encrypt_value, + generate_totp_secret, + generate_totp_uri, + hash_password, + verify_password, + verify_totp, +) +from app.utils.validators import ChangePasswordRequest, LoginRequest, MfaTokenRequest, MfaVerifyRequest logger = logging.getLogger(__name__) router = APIRouter() @@ -20,15 +30,7 @@ router = APIRouter() @router.post("/login") async def login(payload: LoginRequest, db: Session = Depends(get_db)): - """Authenticate and return a JWT token. - - Args: - payload: Username and password. - db: Database session. - - Returns: - JSON with ``access_token`` and ``token_type``. - """ + """Authenticate with username/password. May require MFA as a second step.""" user = db.query(User).filter(User.username == payload.username).first() if not user or not verify_password(payload.password, user.password_hash): raise HTTPException( @@ -41,6 +43,17 @@ async def login(payload: LoginRequest, db: Session = Depends(get_db)): detail="Account is disabled.", ) + # Check if MFA is required (only for local users) + if user.auth_provider == "local": + config = db.query(SystemConfig).filter(SystemConfig.id == 1).first() + if config and getattr(config, "mfa_enabled", False): + mfa_token = create_mfa_token(user.username) + return { + "mfa_required": True, + "mfa_token": mfa_token, + "totp_setup_needed": not bool(user.totp_enabled), + } + token = create_access_token(user.username) logger.info("User %s logged in.", user.username) return { @@ -50,24 +63,140 @@ async def login(payload: LoginRequest, db: Session = Depends(get_db)): } +# --------------------------------------------------------------------------- +# MFA endpoints +# --------------------------------------------------------------------------- +@router.post("/mfa/setup") +async def mfa_setup(payload: MfaTokenRequest, db: Session = Depends(get_db)): + """Generate a new TOTP secret and QR code for first-time MFA setup.""" + username = verify_mfa_token(payload.mfa_token) + user = db.query(User).filter(User.username == username).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") + + # Generate new secret and store encrypted (not yet enabled) + secret = generate_totp_secret() + user.totp_secret_encrypted = encrypt_value(secret) + db.commit() + + # Generate QR code as base64 data URI + uri = generate_totp_uri(secret, username) + import qrcode + + img = qrcode.make(uri) + buf = io.BytesIO() + img.save(buf, format="PNG") + qr_b64 = base64.b64encode(buf.getvalue()).decode() + + return { + "secret": secret, + "qr_code": f"data:image/png;base64,{qr_b64}", + "otpauth_uri": uri, + } + + +@router.post("/mfa/setup/complete") +async def mfa_setup_complete(payload: MfaVerifyRequest, db: Session = Depends(get_db)): + """Verify the first TOTP code to complete MFA setup, then issue access token.""" + username = verify_mfa_token(payload.mfa_token) + user = db.query(User).filter(User.username == username).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") + + if not user.totp_secret_encrypted: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="TOTP setup not initiated. Call /auth/mfa/setup first.", + ) + + secret = decrypt_value(user.totp_secret_encrypted) + if not verify_totp(secret, payload.totp_code): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid TOTP code.", + ) + + user.totp_enabled = True + db.commit() + + token = create_access_token(user.username) + logger.info("User %s completed MFA setup and logged in.", user.username) + return { + "access_token": token, + "token_type": "bearer", + "user": user.to_dict(), + } + + +@router.post("/mfa/verify") +async def mfa_verify(payload: MfaVerifyRequest, db: Session = Depends(get_db)): + """Verify a TOTP code for users who already have MFA set up.""" + username = verify_mfa_token(payload.mfa_token) + user = db.query(User).filter(User.username == username).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") + + if not user.totp_secret_encrypted or not user.totp_enabled: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="TOTP is not set up for this user.", + ) + + secret = decrypt_value(user.totp_secret_encrypted) + if not verify_totp(secret, payload.totp_code): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid TOTP code.", + ) + + token = create_access_token(user.username) + logger.info("User %s passed MFA verification.", user.username) + return { + "access_token": token, + "token_type": "bearer", + "user": user.to_dict(), + } + + +@router.get("/mfa/status") +async def mfa_status( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Return MFA status for the current user and global setting.""" + config = db.query(SystemConfig).filter(SystemConfig.id == 1).first() + return { + "mfa_enabled_global": bool(config and getattr(config, "mfa_enabled", False)), + "totp_enabled_user": bool(current_user.totp_enabled), + } + + +@router.post("/mfa/disable") +async def mfa_disable( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Disable TOTP for the current user.""" + current_user.totp_enabled = False + current_user.totp_secret_encrypted = None + db.commit() + logger.info("User %s disabled their TOTP.", current_user.username) + return {"message": "TOTP disabled successfully."} + + +# --------------------------------------------------------------------------- +# Password change +# --------------------------------------------------------------------------- @router.post("/logout") async def logout(current_user: User = Depends(get_current_user)): - """Logout (client-side token discard). - - Returns: - Confirmation message. - """ + """Logout (client-side token discard).""" logger.info("User %s logged out.", current_user.username) return {"message": "Logged out successfully."} @router.get("/me") async def get_me(current_user: User = Depends(get_current_user)): - """Return the current authenticated user's profile. - - Returns: - User dict (no password hash). - """ + """Return the current authenticated user's profile.""" return current_user.to_dict() @@ -77,16 +206,7 @@ async def change_password( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): - """Change the current user's password. - - Args: - payload: Current and new password. - current_user: Authenticated user. - db: Database session. - - Returns: - Confirmation message. - """ + """Change the current user's password.""" if not verify_password(payload.current_password, current_user.password_hash): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -99,6 +219,9 @@ async def change_password( return {"message": "Password changed successfully."} +# --------------------------------------------------------------------------- +# Azure AD +# --------------------------------------------------------------------------- class AzureCallbackRequest(BaseModel): """Azure AD auth code callback payload.""" code: str diff --git a/app/routers/users.py b/app/routers/users.py index f10b980..6fafa69 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -129,3 +129,28 @@ async def reset_password( logger.info("Password reset for user '%s' by '%s'.", user.username, current_user.username) return {"message": "Password reset successfully.", "new_password": new_password} + + +@router.post("/{user_id}/reset-mfa") +async def reset_mfa( + user_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Reset MFA (TOTP) for a user. They will need to set up again on next login.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") + + if user.auth_provider != "local": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot reset MFA for Azure AD users.", + ) + + user.totp_enabled = False + user.totp_secret_encrypted = None + db.commit() + + logger.info("MFA reset for user '%s' by '%s'.", user.username, current_user.username) + return {"message": f"MFA reset for '{user.username}'."} diff --git a/app/utils/security.py b/app/utils/security.py index acadd46..45b5f28 100644 --- a/app/utils/security.py +++ b/app/utils/security.py @@ -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. diff --git a/app/utils/validators.py b/app/utils/validators.py index 1f28ec3..881a2c3 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -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) diff --git a/requirements.txt b/requirements.txt index ce1ca73..f778cda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,6 +18,8 @@ urllib3<2 psutil==5.9.7 pyyaml==6.0.1 msal==1.28.0 +pyotp==2.9.0 +qrcode[pil]==7.4.2 pytest==7.4.3 pytest-asyncio==0.23.2 pytest-httpx==0.28.0 diff --git a/static/index.html b/static/index.html index 21c68cc..b2433d9 100644 --- a/static/index.html +++ b/static/index.html @@ -40,6 +40,43 @@ Sign in with Microsoft + + +
+
+

Enter your 6-digit authenticator code

+
+ + +
+ Back to login +
+ + +
+
+

Scan this QR code with your authenticator app

+
+ TOTP QR Code +
+

+ Or enter this key manually:
+ +

+
+ + +
+ Back to login +
@@ -457,12 +494,13 @@ Role Auth Language + MFA Status Actions - Loading... + Loading... @@ -509,6 +547,28 @@
+ +
+
+
Multi-Factor Authentication (MFA)
+
+ + +
+

When enabled, local users must verify with a TOTP authenticator app after entering their password. Azure AD users are not affected.

+ +
+
Your TOTP Status
+
+ +
+
+ +
Change Admin Password
diff --git a/static/js/app.js b/static/js/app.js index 70f2f6a..1e5e89d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -36,7 +36,7 @@ async function api(method, path, body = null) { console.error(`API network error: ${method} ${path}`, networkErr); throw new Error(t('errors.networkError')); } - if (resp.status === 401) { + if (resp.status === 401 && !path.startsWith('/auth/mfa/') && path !== '/auth/login') { logout(); throw new Error(t('errors.sessionExpired')); } @@ -98,6 +98,8 @@ async function initApp() { function showLoginPage() { document.getElementById('login-page').classList.remove('d-none'); document.getElementById('app-page').classList.add('d-none'); + // Reset MFA sections when going back to login + resetLoginForm(); } function showAppPage() { @@ -211,6 +213,9 @@ async function handleAzureCallback() { } } +// Track MFA token between login steps +let pendingMfaToken = null; + document.getElementById('login-form').addEventListener('submit', async (e) => { e.preventDefault(); const errorEl = document.getElementById('login-error'); @@ -223,16 +228,26 @@ document.getElementById('login-form').addEventListener('submit', async (e) => { username: document.getElementById('login-username').value, password: document.getElementById('login-password').value, }); - authToken = data.access_token; - localStorage.setItem('authToken', authToken); - currentUser = data.user; - document.getElementById('nav-username').textContent = currentUser.username; - // Apply user's language preference - if (currentUser.default_language) { - await setLanguage(currentUser.default_language); + + // Check if MFA is required + if (data.mfa_required) { + pendingMfaToken = data.mfa_token; + document.getElementById('login-form').classList.add('d-none'); + document.getElementById('azure-login-divider').classList.add('d-none'); + + if (data.totp_setup_needed) { + // First-time TOTP setup — get QR code + await startMfaSetup(); + } else { + // Existing TOTP — show verify form + document.getElementById('mfa-verify-section').classList.remove('d-none'); + document.getElementById('mfa-code').focus(); + } + return; } - showAppPage(); - loadDashboard(); + + // Normal login (no MFA) + completeLogin(data); } catch (err) { errorEl.textContent = err.message; errorEl.classList.remove('d-none'); @@ -241,6 +256,107 @@ document.getElementById('login-form').addEventListener('submit', async (e) => { } }); +async function completeLogin(data) { + authToken = data.access_token; + localStorage.setItem('authToken', authToken); + currentUser = data.user; + document.getElementById('nav-username').textContent = currentUser.username; + if (currentUser.default_language) { + await setLanguage(currentUser.default_language); + } + pendingMfaToken = null; + showAppPage(); + loadDashboard(); +} + +function resetLoginForm() { + pendingMfaToken = null; + document.getElementById('login-form').classList.remove('d-none'); + document.getElementById('mfa-verify-section').classList.add('d-none'); + document.getElementById('mfa-setup-section').classList.add('d-none'); + document.getElementById('login-error').classList.add('d-none'); + document.getElementById('login-password').value = ''; + // Re-check azure config visibility + if (azureConfig.azure_enabled) { + document.getElementById('azure-login-divider').classList.remove('d-none'); + } +} + +// MFA Verify form (existing TOTP) +document.getElementById('mfa-verify-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const errorEl = document.getElementById('mfa-verify-error'); + const spinner = document.getElementById('mfa-verify-spinner'); + errorEl.classList.add('d-none'); + spinner.classList.remove('d-none'); + + try { + const data = await api('POST', '/auth/mfa/verify', { + mfa_token: pendingMfaToken, + totp_code: document.getElementById('mfa-code').value, + }); + completeLogin(data); + } catch (err) { + errorEl.textContent = err.message; + errorEl.classList.remove('d-none'); + document.getElementById('mfa-code').value = ''; + document.getElementById('mfa-code').focus(); + } finally { + spinner.classList.add('d-none'); + } +}); + +// MFA Setup — get QR code from server +async function startMfaSetup() { + try { + const data = await api('POST', '/auth/mfa/setup', { + mfa_token: pendingMfaToken, + }); + document.getElementById('mfa-qr-code').src = data.qr_code; + document.getElementById('mfa-secret-manual').textContent = data.secret; + document.getElementById('mfa-setup-section').classList.remove('d-none'); + document.getElementById('mfa-setup-code').focus(); + } catch (err) { + document.getElementById('login-error').textContent = err.message; + document.getElementById('login-error').classList.remove('d-none'); + resetLoginForm(); + } +} + +// MFA Setup Complete form (first-time TOTP) +document.getElementById('mfa-setup-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const errorEl = document.getElementById('mfa-setup-error'); + const spinner = document.getElementById('mfa-setup-spinner'); + errorEl.classList.add('d-none'); + spinner.classList.remove('d-none'); + + try { + const data = await api('POST', '/auth/mfa/setup/complete', { + mfa_token: pendingMfaToken, + totp_code: document.getElementById('mfa-setup-code').value, + }); + completeLogin(data); + } catch (err) { + errorEl.textContent = err.message; + errorEl.classList.remove('d-none'); + document.getElementById('mfa-setup-code').value = ''; + document.getElementById('mfa-setup-code').focus(); + } finally { + spinner.classList.add('d-none'); + } +}); + +// Back-to-login links +document.getElementById('mfa-back-to-login').addEventListener('click', (e) => { + e.preventDefault(); + resetLoginForm(); +}); +document.getElementById('mfa-setup-back-to-login').addEventListener('click', (e) => { + e.preventDefault(); + resetLoginForm(); +}); + function logout() { // Use fetch directly (not api()) to avoid 401 → logout → 401 infinite loop if (authToken) { @@ -711,6 +827,10 @@ async function loadSettings() { document.getElementById('cfg-default-language').value = cfg.default_language || 'en'; updateLogoPreview(cfg.branding_logo_path); + // MFA tab (Security) + document.getElementById('cfg-mfa-enabled').checked = cfg.mfa_enabled || false; + loadMfaStatus(); + // Azure AD tab document.getElementById('cfg-azure-enabled').checked = cfg.azure_enabled || false; document.getElementById('cfg-azure-tenant').value = cfg.azure_tenant_id || ''; @@ -905,11 +1025,14 @@ async function loadUsers() { const users = await api('GET', '/users'); const tbody = document.getElementById('users-table-body'); if (!users || users.length === 0) { - tbody.innerHTML = `${t('settings.noUsersFound') || t('common.loading')}`; + tbody.innerHTML = `${t('settings.noUsersFound') || t('common.loading')}`; return; } tbody.innerHTML = users.map(u => { const langDisplay = u.default_language ? u.default_language.toUpperCase() : `${t('settings.systemDefault')}`; + const mfaDisplay = u.totp_enabled + ? `${t('mfa.totpActive')}` + : ``; return ` ${u.id} ${esc(u.username)} @@ -917,6 +1040,7 @@ async function loadUsers() { ${esc(u.role || 'admin')} ${esc(u.auth_provider || 'local')} ${langDisplay} + ${mfaDisplay} ${u.is_active ? `${t('common.active')}` : `${t('common.disabled')}`}
@@ -925,13 +1049,14 @@ async function loadUsers() { : `` } ${u.auth_provider === 'local' ? `` : ''} + ${u.totp_enabled ? `` : ''}
`; }).join(''); } catch (err) { - document.getElementById('users-table-body').innerHTML = `${err.message}`; + document.getElementById('users-table-body').innerHTML = `${err.message}`; } } @@ -1019,6 +1144,61 @@ document.getElementById('settings-azure-form').addEventListener('submit', async } }); +// --------------------------------------------------------------------------- +// MFA Settings +// --------------------------------------------------------------------------- +async function saveMfaSettings() { + try { + await api('PUT', '/settings/system', { + mfa_enabled: document.getElementById('cfg-mfa-enabled').checked, + }); + showSettingsAlert('success', t('mfa.mfaSaved')); + } catch (err) { + showSettingsAlert('danger', t('errors.failed', { error: err.message })); + } +} + +async function loadMfaStatus() { + try { + const data = await api('GET', '/auth/mfa/status'); + document.getElementById('cfg-mfa-enabled').checked = data.mfa_enabled_global; + + const statusEl = document.getElementById('mfa-own-status'); + const disableBtn = document.getElementById('mfa-disable-own'); + + if (data.totp_enabled_user) { + statusEl.innerHTML = `${t('mfa.totpActive')}`; + disableBtn.classList.remove('d-none'); + } else { + statusEl.innerHTML = `${t('mfa.totpNotSetUp')}`; + disableBtn.classList.add('d-none'); + } + } catch (err) { + console.error('Failed to load MFA status:', err); + } +} + +async function disableOwnTotp() { + try { + await api('POST', '/auth/mfa/disable'); + showSettingsAlert('success', t('mfa.mfaDisabled')); + loadMfaStatus(); + } catch (err) { + showSettingsAlert('danger', t('errors.failed', { error: err.message })); + } +} + +async function resetUserMfa(id, username) { + if (!confirm(t('mfa.confirmResetMfa', { username }))) return; + try { + await api('POST', `/users/${id}/reset-mfa`); + showSettingsAlert('success', t('mfa.mfaResetSuccess', { username })); + loadUsers(); + } catch (err) { + showSettingsAlert('danger', t('errors.failed', { error: err.message })); + } +} + function togglePasswordVisibility(inputId) { const input = document.getElementById(inputId); if (!input) return; diff --git a/static/lang/de.json b/static/lang/de.json index 98d968a..9ea1ec8 100644 --- a/static/lang/de.json +++ b/static/lang/de.json @@ -225,6 +225,29 @@ "cancel": "Abbrechen", "createUser": "Benutzer erstellen" }, + "mfa": { + "title": "Multi-Faktor-Authentifizierung (MFA)", + "enableMfa": "MFA fuer alle lokalen Benutzer aktivieren", + "mfaDescription": "Wenn aktiviert, muessen lokale Benutzer sich nach der Passworteingabe mit einer TOTP-Authenticator-App verifizieren. Azure AD Benutzer sind nicht betroffen.", + "saveMfaSettings": "MFA Einstellungen speichern", + "yourTotpStatus": "Ihr TOTP Status", + "totpActive": "Aktiv", + "totpNotSetUp": "Nicht eingerichtet", + "disableMyTotp": "Mein TOTP deaktivieren", + "enterCode": "Geben Sie Ihren 6-stelligen Authenticator-Code ein", + "verify": "Verifizieren", + "backToLogin": "Zurueck zum Login", + "scanQrCode": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App", + "orEnterManually": "Oder geben Sie diesen Schluessel manuell ein:", + "verifyAndActivate": "Verifizieren & Aktivieren", + "resetMfa": "MFA zuruecksetzen", + "confirmResetMfa": "MFA fuer '{username}' zuruecksetzen? Der Benutzer muss seinen Authenticator beim naechsten Login neu einrichten.", + "mfaResetSuccess": "MFA fuer '{username}' zurueckgesetzt.", + "mfaDisabled": "Ihr TOTP wurde deaktiviert.", + "mfaSaved": "MFA Einstellungen gespeichert.", + "invalidCode": "Ungueltiger Code. Bitte versuchen Sie es erneut.", + "codeExpired": "Verifizierung abgelaufen. Bitte melden Sie sich erneut an." + }, "common": { "loading": "Laden...", "back": "Zurueck", @@ -244,7 +267,7 @@ "disabled": "Deaktiviert" }, "errors": { - "networkError": "Netzwerkfehler \u2014 Server nicht erreichbar.", + "networkError": "Netzwerkfehler — Server nicht erreichbar.", "sessionExpired": "Sitzung abgelaufen.", "requestFailed": "Anfrage fehlgeschlagen.", "serverError": "Serverfehler (HTTP {status}).", diff --git a/static/lang/en.json b/static/lang/en.json index 4e5b84d..3d048e5 100644 --- a/static/lang/en.json +++ b/static/lang/en.json @@ -225,6 +225,29 @@ "cancel": "Cancel", "createUser": "Create User" }, + "mfa": { + "title": "Multi-Factor Authentication (MFA)", + "enableMfa": "Enable MFA for all local users", + "mfaDescription": "When enabled, local users must verify with a TOTP authenticator app after entering their password. Azure AD users are not affected.", + "saveMfaSettings": "Save MFA Settings", + "yourTotpStatus": "Your TOTP Status", + "totpActive": "Active", + "totpNotSetUp": "Not set up", + "disableMyTotp": "Disable my TOTP", + "enterCode": "Enter your 6-digit authenticator code", + "verify": "Verify", + "backToLogin": "Back to login", + "scanQrCode": "Scan this QR code with your authenticator app", + "orEnterManually": "Or enter this key manually:", + "verifyAndActivate": "Verify & Activate", + "resetMfa": "Reset MFA", + "confirmResetMfa": "Reset MFA for '{username}'? They will need to set up their authenticator again on next login.", + "mfaResetSuccess": "MFA reset for '{username}'.", + "mfaDisabled": "Your TOTP has been disabled.", + "mfaSaved": "MFA settings saved.", + "invalidCode": "Invalid code. Please try again.", + "codeExpired": "Verification expired. Please log in again." + }, "common": { "loading": "Loading...", "back": "Back",