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:
61
README.md
61
README.md
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}'."}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
204
static/js/app.js
204
static/js/app.js
@@ -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();
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
showAppPage();
|
|
||||||
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">—</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;
|
||||||
|
|||||||
@@ -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}).",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user