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

@@ -56,9 +56,10 @@ A management solution for running isolated NetBird instances for your MSP busine
### Security ### Security
- **JWT Authentication** — Token-based API authentication - **JWT Authentication** — Token-based API authentication
- **Azure AD / OIDC** — Optional single sign-on via Microsoft Entra ID - **Multi-Factor Authentication (MFA)** — Optional TOTP-based MFA for all local users, activatable in Security settings
- **Encrypted Credentials** — NPM passwords and relay secrets are Fernet-encrypted - **Azure AD / OIDC** — Optional single sign-on via Microsoft Entra ID (exempt from MFA)
- **User Management** — Create, edit, and delete admin users - **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 | | **System** | Base domain, admin email, NPM credentials, Docker images, port ranges, data directory |
| **Branding** | Platform name, subtitle, logo upload, default language | | **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 | | **Monitoring** | System resources, Docker stats |
Changes are applied immediately without restart. Changes are applied immediately without restart.
@@ -315,6 +318,30 @@ The dashboard shows:
- **Per-user default** — Set in Settings > Users during user creation - **Per-user default** — Set in Settings > Users during user creation
- **System default** — Set in Settings > Branding - **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 ## API Documentation
@@ -352,6 +379,13 @@ GET /api/settings/branding # Get branding (public, no auth)
PUT /api/settings # Update system settings PUT /api/settings # Update system settings
GET /api/users # List users GET /api/users # List users
POST /api/users # Create user 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 ### Example: Create Customer via API
@@ -441,15 +475,16 @@ Via the Web UI:
## Security Best Practices ## Security Best Practices
1. **Change default credentials** immediately after installation 1. **Enable MFA** — activate TOTP-based multi-factor authentication in Settings > Security
2. **Use strong passwords** (12+ characters recommended) 2. **Change default credentials** immediately after installation
3. **Keep NPM credentials secure** — they are stored encrypted in the database 3. **Use strong passwords** (12+ characters recommended)
4. **Enable firewall** and only open required ports (TCP 8000, UDP relay range) 4. **Keep NPM credentials secure** — they are stored encrypted in the database
5. **Use HTTPS** — put the MSP appliance behind a reverse proxy with SSL 5. **Enable firewall** and only open required ports (TCP 8000, UDP relay range)
6. **Regular updates** — both the appliance and NetBird images 6. **Use HTTPS** — put the MSP appliance behind a reverse proxy with SSL
7. **Backup your database** — `data/netbird_msp.db` contains all configuration 7. **Regular updates** — both the appliance and NetBird images
8. **Monitor logs** — check for suspicious activity 8. **Backup your database** — `data/netbird_msp.db` contains all configuration
9. **Restrict access** — use VPN or IP whitelist for the management interface 9. **Monitor logs** — check for suspicious activity
10. **Restrict access** — use VPN or IP whitelist for the management interface
--- ---

View File

@@ -80,6 +80,9 @@ def _run_migrations() -> None:
("system_config", "default_language", "TEXT DEFAULT 'en'"), ("system_config", "default_language", "TEXT DEFAULT 'en'"),
("users", "default_language", "TEXT"), ("users", "default_language", "TEXT"),
("deployments", "npm_stream_id", "INTEGER"), ("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: for table, column, col_type in migrations:
if not _has_column(table, column): if not _has_column(table, column):

View File

@@ -30,6 +30,39 @@ def create_access_token(username: str, expires_delta: Optional[timedelta] = None
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALGORITHM) 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( def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme), credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme),
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -161,6 +161,7 @@ class SystemConfig(Base):
) )
branding_logo_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) branding_logo_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
default_language: Mapped[Optional[str]] = mapped_column(String(10), default="en") 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_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
azure_tenant_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) azure_tenant_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
azure_client_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_subtitle": self.branding_subtitle or "Multi-Tenant Management Platform",
"branding_logo_path": self.branding_logo_path, "branding_logo_path": self.branding_logo_path,
"default_language": self.default_language or "en", "default_language": self.default_language or "en",
"mfa_enabled": bool(self.mfa_enabled),
"azure_enabled": bool(self.azure_enabled), "azure_enabled": bool(self.azure_enabled),
"azure_tenant_id": self.azure_tenant_id or "", "azure_tenant_id": self.azure_tenant_id or "",
"azure_client_id": self.azure_client_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") role: Mapped[str] = mapped_column(String(20), default="admin")
auth_provider: Mapped[str] = mapped_column(String(20), default="local") auth_provider: Mapped[str] = mapped_column(String(20), default="local")
default_language: Mapped[Optional[str]] = mapped_column(String(10), nullable=True, default=None) 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) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Serialize user to dictionary (no password).""" """Serialize user to dictionary (no password, no TOTP secret)."""
return { return {
"id": self.id, "id": self.id,
"username": self.username, "username": self.username,
@@ -264,5 +268,6 @@ class User(Base):
"role": self.role or "admin", "role": self.role or "admin",
"auth_provider": self.auth_provider or "local", "auth_provider": self.auth_provider or "local",
"default_language": self.default_language, "default_language": self.default_language,
"totp_enabled": bool(self.totp_enabled),
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
} }

View File

@@ -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 logging
import secrets import secrets
from datetime import datetime from datetime import datetime
@@ -9,10 +11,18 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db 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.models import SystemConfig, User
from app.utils.security import decrypt_value, hash_password, verify_password from app.utils.security import (
from app.utils.validators import ChangePasswordRequest, LoginRequest 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__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -20,15 +30,7 @@ router = APIRouter()
@router.post("/login") @router.post("/login")
async def login(payload: LoginRequest, db: Session = Depends(get_db)): async def login(payload: LoginRequest, db: Session = Depends(get_db)):
"""Authenticate and return a JWT token. """Authenticate with username/password. May require MFA as a second step."""
Args:
payload: Username and password.
db: Database session.
Returns:
JSON with ``access_token`` and ``token_type``.
"""
user = db.query(User).filter(User.username == payload.username).first() user = db.query(User).filter(User.username == payload.username).first()
if not user or not verify_password(payload.password, user.password_hash): if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException( raise HTTPException(
@@ -41,6 +43,17 @@ async def login(payload: LoginRequest, db: Session = Depends(get_db)):
detail="Account is disabled.", 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) token = create_access_token(user.username)
logger.info("User %s logged in.", user.username) logger.info("User %s logged in.", user.username)
return { 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") @router.post("/logout")
async def logout(current_user: User = Depends(get_current_user)): async def logout(current_user: User = Depends(get_current_user)):
"""Logout (client-side token discard). """Logout (client-side token discard)."""
Returns:
Confirmation message.
"""
logger.info("User %s logged out.", current_user.username) logger.info("User %s logged out.", current_user.username)
return {"message": "Logged out successfully."} return {"message": "Logged out successfully."}
@router.get("/me") @router.get("/me")
async def get_me(current_user: User = Depends(get_current_user)): async def get_me(current_user: User = Depends(get_current_user)):
"""Return the current authenticated user's profile. """Return the current authenticated user's profile."""
Returns:
User dict (no password hash).
"""
return current_user.to_dict() return current_user.to_dict()
@@ -77,16 +206,7 @@ async def change_password(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Change the current user's password. """Change the current user's password."""
Args:
payload: Current and new password.
current_user: Authenticated user.
db: Database session.
Returns:
Confirmation message.
"""
if not verify_password(payload.current_password, current_user.password_hash): if not verify_password(payload.current_password, current_user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -99,6 +219,9 @@ async def change_password(
return {"message": "Password changed successfully."} return {"message": "Password changed successfully."}
# ---------------------------------------------------------------------------
# Azure AD
# ---------------------------------------------------------------------------
class AzureCallbackRequest(BaseModel): class AzureCallbackRequest(BaseModel):
"""Azure AD auth code callback payload.""" """Azure AD auth code callback payload."""
code: str code: str

View File

@@ -129,3 +129,28 @@ async def reset_password(
logger.info("Password reset for user '%s' by '%s'.", user.username, current_user.username) logger.info("Password reset for user '%s' by '%s'.", user.username, current_user.username)
return {"message": "Password reset successfully.", "new_password": new_password} 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}'."}

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 os
import secrets import secrets
import pyotp
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from passlib.context import CryptContext from passlib.context import CryptContext
@@ -91,6 +92,32 @@ def generate_relay_secret() -> str:
return secrets.token_hex(16) 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: def generate_datastore_encryption_key() -> str:
"""Generate a base64-encoded 32-byte key for NetBird DataStoreEncryptionKey. """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) 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 # Customer
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -113,6 +126,7 @@ class SystemConfigUpdate(BaseModel):
branding_name: Optional[str] = Field(None, max_length=255) branding_name: Optional[str] = Field(None, max_length=255)
branding_subtitle: 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) default_language: Optional[str] = Field(None, max_length=10)
mfa_enabled: Optional[bool] = None
azure_enabled: Optional[bool] = None azure_enabled: Optional[bool] = None
azure_tenant_id: Optional[str] = Field(None, max_length=255) azure_tenant_id: Optional[str] = Field(None, max_length=255)
azure_client_id: Optional[str] = Field(None, max_length=255) azure_client_id: Optional[str] = Field(None, max_length=255)

View File

@@ -18,6 +18,8 @@ urllib3<2
psutil==5.9.7 psutil==5.9.7
pyyaml==6.0.1 pyyaml==6.0.1
msal==1.28.0 msal==1.28.0
pyotp==2.9.0
qrcode[pil]==7.4.2
pytest==7.4.3 pytest==7.4.3
pytest-asyncio==0.23.2 pytest-asyncio==0.23.2
pytest-httpx==0.28.0 pytest-httpx==0.28.0

View File

@@ -40,6 +40,43 @@
<i class="bi bi-microsoft me-2"></i><span data-i18n="login.signInWithMicrosoft">Sign in with Microsoft</span> <i class="bi bi-microsoft me-2"></i><span data-i18n="login.signInWithMicrosoft">Sign in with Microsoft</span>
</button> </button>
</div> </div>
<!-- MFA: TOTP Verify (existing setup) -->
<div id="mfa-verify-section" class="d-none">
<div id="mfa-verify-error" class="alert alert-danger d-none"></div>
<p class="text-muted text-center mb-3" data-i18n="mfa.enterCode">Enter your 6-digit authenticator code</p>
<form id="mfa-verify-form">
<input type="text" class="form-control form-control-lg text-center" id="mfa-code"
maxlength="6" pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code" required autofocus>
<button type="submit" class="btn btn-primary w-100 mt-3">
<span class="spinner-border spinner-border-sm d-none me-1" id="mfa-verify-spinner"></span>
<span data-i18n="mfa.verify">Verify</span>
</button>
</form>
<a href="#" id="mfa-back-to-login" class="d-block text-center mt-2 small" data-i18n="mfa.backToLogin">Back to login</a>
</div>
<!-- MFA: TOTP Setup (first time) -->
<div id="mfa-setup-section" class="d-none">
<div id="mfa-setup-error" class="alert alert-danger d-none"></div>
<p class="text-muted text-center mb-2" data-i18n="mfa.scanQrCode">Scan this QR code with your authenticator app</p>
<div class="text-center mb-3">
<img id="mfa-qr-code" class="img-fluid rounded" style="max-width:200px" alt="TOTP QR Code">
</div>
<p class="text-muted small text-center mb-3">
<span data-i18n="mfa.orEnterManually">Or enter this key manually:</span><br>
<code id="mfa-secret-manual" class="user-select-all"></code>
</p>
<form id="mfa-setup-form">
<input type="text" class="form-control form-control-lg text-center" id="mfa-setup-code"
maxlength="6" pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code" required>
<button type="submit" class="btn btn-success w-100 mt-3">
<span class="spinner-border spinner-border-sm d-none me-1" id="mfa-setup-spinner"></span>
<span data-i18n="mfa.verifyAndActivate">Verify & Activate</span>
</button>
</form>
<a href="#" id="mfa-setup-back-to-login" class="d-block text-center mt-2 small" data-i18n="mfa.backToLogin">Back to login</a>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -457,12 +494,13 @@
<th data-i18n="settings.thRole">Role</th> <th data-i18n="settings.thRole">Role</th>
<th data-i18n="settings.thAuth">Auth</th> <th data-i18n="settings.thAuth">Auth</th>
<th data-i18n="settings.thLanguage">Language</th> <th data-i18n="settings.thLanguage">Language</th>
<th>MFA</th>
<th data-i18n="settings.thStatus">Status</th> <th data-i18n="settings.thStatus">Status</th>
<th data-i18n="settings.thActions">Actions</th> <th data-i18n="settings.thActions">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="users-table-body"> <tbody id="users-table-body">
<tr><td colspan="8" class="text-center text-muted py-4" data-i18n="common.loading">Loading...</td></tr> <tr><td colspan="9" class="text-center text-muted py-4" data-i18n="common.loading">Loading...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -509,6 +547,28 @@
<!-- Security --> <!-- Security -->
<div class="tab-pane fade" id="settings-security"> <div class="tab-pane fade" id="settings-security">
<!-- MFA Settings -->
<div class="card shadow-sm mb-4" id="mfa-settings-card">
<div class="card-body">
<h5 class="mb-3" data-i18n="mfa.title">Multi-Factor Authentication (MFA)</h5>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="cfg-mfa-enabled">
<label class="form-check-label" for="cfg-mfa-enabled" data-i18n="mfa.enableMfa">Enable MFA for all local users</label>
</div>
<p class="text-muted small" data-i18n="mfa.mfaDescription">When enabled, local users must verify with a TOTP authenticator app after entering their password. Azure AD users are not affected.</p>
<button class="btn btn-primary btn-sm" id="save-mfa-settings" onclick="saveMfaSettings()">
<i class="bi bi-save me-1"></i><span data-i18n="mfa.saveMfaSettings">Save MFA Settings</span>
</button>
<hr class="my-3" id="mfa-own-status-divider">
<h6 data-i18n="mfa.yourTotpStatus">Your TOTP Status</h6>
<div id="mfa-own-status" class="mb-2"></div>
<button class="btn btn-outline-danger btn-sm d-none" id="mfa-disable-own" onclick="disableOwnTotp()">
<i class="bi bi-shield-x me-1"></i><span data-i18n="mfa.disableMyTotp">Disable my TOTP</span>
</button>
</div>
</div>
<!-- Change Password -->
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<h5 class="mb-3" data-i18n="settings.securityTitle">Change Admin Password</h5> <h5 class="mb-3" data-i18n="settings.securityTitle">Change Admin Password</h5>

View File

@@ -36,7 +36,7 @@ async function api(method, path, body = null) {
console.error(`API network error: ${method} ${path}`, networkErr); console.error(`API network error: ${method} ${path}`, networkErr);
throw new Error(t('errors.networkError')); throw new Error(t('errors.networkError'));
} }
if (resp.status === 401) { if (resp.status === 401 && !path.startsWith('/auth/mfa/') && path !== '/auth/login') {
logout(); logout();
throw new Error(t('errors.sessionExpired')); throw new Error(t('errors.sessionExpired'));
} }
@@ -98,6 +98,8 @@ async function initApp() {
function showLoginPage() { function showLoginPage() {
document.getElementById('login-page').classList.remove('d-none'); document.getElementById('login-page').classList.remove('d-none');
document.getElementById('app-page').classList.add('d-none'); document.getElementById('app-page').classList.add('d-none');
// Reset MFA sections when going back to login
resetLoginForm();
} }
function showAppPage() { 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) => { document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const errorEl = document.getElementById('login-error'); const errorEl = document.getElementById('login-error');
@@ -223,16 +228,26 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
username: document.getElementById('login-username').value, username: document.getElementById('login-username').value,
password: document.getElementById('login-password').value, password: document.getElementById('login-password').value,
}); });
authToken = data.access_token;
localStorage.setItem('authToken', authToken); // Check if MFA is required
currentUser = data.user; if (data.mfa_required) {
document.getElementById('nav-username').textContent = currentUser.username; pendingMfaToken = data.mfa_token;
// Apply user's language preference document.getElementById('login-form').classList.add('d-none');
if (currentUser.default_language) { document.getElementById('azure-login-divider').classList.add('d-none');
await setLanguage(currentUser.default_language);
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();
} }
showAppPage(); return;
loadDashboard(); }
// Normal login (no MFA)
completeLogin(data);
} catch (err) { } catch (err) {
errorEl.textContent = err.message; errorEl.textContent = err.message;
errorEl.classList.remove('d-none'); 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() { function logout() {
// Use fetch directly (not api()) to avoid 401 → logout → 401 infinite loop // Use fetch directly (not api()) to avoid 401 → logout → 401 infinite loop
if (authToken) { if (authToken) {
@@ -711,6 +827,10 @@ async function loadSettings() {
document.getElementById('cfg-default-language').value = cfg.default_language || 'en'; document.getElementById('cfg-default-language').value = cfg.default_language || 'en';
updateLogoPreview(cfg.branding_logo_path); updateLogoPreview(cfg.branding_logo_path);
// MFA tab (Security)
document.getElementById('cfg-mfa-enabled').checked = cfg.mfa_enabled || false;
loadMfaStatus();
// Azure AD tab // Azure AD tab
document.getElementById('cfg-azure-enabled').checked = cfg.azure_enabled || false; document.getElementById('cfg-azure-enabled').checked = cfg.azure_enabled || false;
document.getElementById('cfg-azure-tenant').value = cfg.azure_tenant_id || ''; document.getElementById('cfg-azure-tenant').value = cfg.azure_tenant_id || '';
@@ -905,11 +1025,14 @@ async function loadUsers() {
const users = await api('GET', '/users'); const users = await api('GET', '/users');
const tbody = document.getElementById('users-table-body'); const tbody = document.getElementById('users-table-body');
if (!users || users.length === 0) { if (!users || users.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('settings.noUsersFound') || t('common.loading')}</td></tr>`; tbody.innerHTML = `<tr><td colspan="9" class="text-center text-muted py-4">${t('settings.noUsersFound') || t('common.loading')}</td></tr>`;
return; return;
} }
tbody.innerHTML = users.map(u => { tbody.innerHTML = users.map(u => {
const langDisplay = u.default_language ? u.default_language.toUpperCase() : `<span class="text-muted">${t('settings.systemDefault')}</span>`; const langDisplay = u.default_language ? u.default_language.toUpperCase() : `<span class="text-muted">${t('settings.systemDefault')}</span>`;
const mfaDisplay = u.totp_enabled
? `<span class="badge bg-success">${t('mfa.totpActive')}</span>`
: `<span class="text-muted">&mdash;</span>`;
return `<tr> return `<tr>
<td>${u.id}</td> <td>${u.id}</td>
<td><strong>${esc(u.username)}</strong></td> <td><strong>${esc(u.username)}</strong></td>
@@ -917,6 +1040,7 @@ async function loadUsers() {
<td><span class="badge bg-info">${esc(u.role || 'admin')}</span></td> <td><span class="badge bg-info">${esc(u.role || 'admin')}</span></td>
<td><span class="badge bg-${u.auth_provider === 'azure' ? 'primary' : 'secondary'}">${esc(u.auth_provider || 'local')}</span></td> <td><span class="badge bg-${u.auth_provider === 'azure' ? 'primary' : 'secondary'}">${esc(u.auth_provider || 'local')}</span></td>
<td>${langDisplay}</td> <td>${langDisplay}</td>
<td>${mfaDisplay}</td>
<td>${u.is_active ? `<span class="badge bg-success">${t('common.active')}</span>` : `<span class="badge bg-danger">${t('common.disabled')}</span>`}</td> <td>${u.is_active ? `<span class="badge bg-success">${t('common.active')}</span>` : `<span class="badge bg-danger">${t('common.disabled')}</span>`}</td>
<td> <td>
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
@@ -925,13 +1049,14 @@ async function loadUsers() {
: `<button class="btn btn-outline-success" title="${t('common.enable')}" onclick="toggleUserActive(${u.id}, true)"><i class="bi bi-play-circle"></i></button>` : `<button class="btn btn-outline-success" title="${t('common.enable')}" onclick="toggleUserActive(${u.id}, true)"><i class="bi bi-play-circle"></i></button>`
} }
${u.auth_provider === 'local' ? `<button class="btn btn-outline-info" title="${t('common.resetPassword')}" onclick="resetUserPassword(${u.id}, '${esc(u.username)}')"><i class="bi bi-key"></i></button>` : ''} ${u.auth_provider === 'local' ? `<button class="btn btn-outline-info" title="${t('common.resetPassword')}" onclick="resetUserPassword(${u.id}, '${esc(u.username)}')"><i class="bi bi-key"></i></button>` : ''}
${u.totp_enabled ? `<button class="btn btn-outline-secondary" title="${t('mfa.resetMfa')}" onclick="resetUserMfa(${u.id}, '${esc(u.username)}')"><i class="bi bi-shield-x"></i></button>` : ''}
<button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="deleteUser(${u.id}, '${esc(u.username)}')"><i class="bi bi-trash"></i></button> <button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="deleteUser(${u.id}, '${esc(u.username)}')"><i class="bi bi-trash"></i></button>
</div> </div>
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
} catch (err) { } catch (err) {
document.getElementById('users-table-body').innerHTML = `<tr><td colspan="8" class="text-danger">${err.message}</td></tr>`; document.getElementById('users-table-body').innerHTML = `<tr><td colspan="9" class="text-danger">${err.message}</td></tr>`;
} }
} }
@@ -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 = `<span class="badge bg-success">${t('mfa.totpActive')}</span>`;
disableBtn.classList.remove('d-none');
} else {
statusEl.innerHTML = `<span class="badge bg-warning text-dark">${t('mfa.totpNotSetUp')}</span>`;
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) { function togglePasswordVisibility(inputId) {
const input = document.getElementById(inputId); const input = document.getElementById(inputId);
if (!input) return; if (!input) return;

View File

@@ -225,6 +225,29 @@
"cancel": "Abbrechen", "cancel": "Abbrechen",
"createUser": "Benutzer erstellen" "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": { "common": {
"loading": "Laden...", "loading": "Laden...",
"back": "Zurueck", "back": "Zurueck",
@@ -244,7 +267,7 @@
"disabled": "Deaktiviert" "disabled": "Deaktiviert"
}, },
"errors": { "errors": {
"networkError": "Netzwerkfehler \u2014 Server nicht erreichbar.", "networkError": "Netzwerkfehler Server nicht erreichbar.",
"sessionExpired": "Sitzung abgelaufen.", "sessionExpired": "Sitzung abgelaufen.",
"requestFailed": "Anfrage fehlgeschlagen.", "requestFailed": "Anfrage fehlgeschlagen.",
"serverError": "Serverfehler (HTTP {status}).", "serverError": "Serverfehler (HTTP {status}).",

View File

@@ -225,6 +225,29 @@
"cancel": "Cancel", "cancel": "Cancel",
"createUser": "Create User" "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": { "common": {
"loading": "Loading...", "loading": "Loading...",
"back": "Back", "back": "Back",