mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)
feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)
This commit is contained in:
parent
8af0966e68
commit
ba1c97c808
44 changed files with 10473 additions and 290 deletions
|
|
@ -1514,7 +1514,7 @@ async def create_archive_slicer_token(
|
|||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
|
||||
token = create_slicer_download_token("archive", archive_id)
|
||||
token = await create_slicer_download_token("archive", archive_id)
|
||||
return {"token": token}
|
||||
|
||||
|
||||
|
|
@ -1533,7 +1533,7 @@ async def download_archive_for_slicer(
|
|||
"""
|
||||
from backend.app.core.auth import verify_slicer_download_token
|
||||
|
||||
if not verify_slicer_download_token(token, "archive", archive_id):
|
||||
if not await verify_slicer_download_token(token, "archive", archive_id):
|
||||
raise HTTPException(403, "Invalid or expired download token")
|
||||
|
||||
service = ArchiveService(db)
|
||||
|
|
@ -3512,7 +3512,7 @@ async def create_source_slicer_token(
|
|||
if not archive.source_3mf_path:
|
||||
raise HTTPException(404, "No source 3MF attached to this archive")
|
||||
|
||||
token = create_slicer_download_token("source", archive_id)
|
||||
token = await create_slicer_download_token("source", archive_id)
|
||||
return {"token": token}
|
||||
|
||||
|
||||
|
|
@ -3530,7 +3530,7 @@ async def download_source_3mf_for_slicer_with_token(
|
|||
"""
|
||||
from backend.app.core.auth import verify_slicer_download_token
|
||||
|
||||
if not verify_slicer_download_token(token, "source", archive_id):
|
||||
if not await verify_slicer_download_token(token, "source", archive_id):
|
||||
raise HTTPException(403, "Invalid or expired download token")
|
||||
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
from datetime import timedelta
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
import jwt as _jwt
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, Response, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
from jwt.exceptions import PyJWTError
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
|
|
@ -14,6 +19,7 @@ from backend.app.core.auth import (
|
|||
SECRET_KEY,
|
||||
Permission,
|
||||
RequirePermissionIfAuthEnabled,
|
||||
_is_token_fresh,
|
||||
_validate_api_key,
|
||||
authenticate_user,
|
||||
authenticate_user_by_email,
|
||||
|
|
@ -22,14 +28,18 @@ from backend.app.core.auth import (
|
|||
get_password_hash,
|
||||
get_user_by_email,
|
||||
get_user_by_username,
|
||||
is_jti_revoked,
|
||||
revoke_jti,
|
||||
security,
|
||||
)
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.database import async_session, get_db
|
||||
from backend.app.core.permissions import ALL_PERMISSIONS
|
||||
from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.auth import (
|
||||
ForgotPasswordConfirmRequest,
|
||||
ForgotPasswordRequest,
|
||||
ForgotPasswordResponse,
|
||||
GroupBrief,
|
||||
|
|
@ -45,13 +55,14 @@ from backend.app.schemas.auth import (
|
|||
UserResponse,
|
||||
)
|
||||
from backend.app.services.email_service import (
|
||||
create_password_reset_email_from_template,
|
||||
generate_secure_password,
|
||||
create_password_reset_link_email_from_template,
|
||||
get_smtp_settings,
|
||||
save_smtp_settings,
|
||||
send_email,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _user_to_response(user: User) -> UserResponse:
|
||||
"""Convert a User model to UserResponse schema."""
|
||||
|
|
@ -84,6 +95,50 @@ def _api_key_to_user_response(api_key) -> UserResponse:
|
|||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M-R9-A: Real client IP resolution for rate limiting behind reverse proxies.
|
||||
# Set TRUSTED_PROXY_IPS (comma-separated) to enable X-Forwarded-For trust.
|
||||
# Without this env var client.host is used directly (safe default).
|
||||
# ---------------------------------------------------------------------------
|
||||
_TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
|
||||
ip.strip() for ip in os.environ.get("TRUSTED_PROXY_IPS", "").split(",") if ip.strip()
|
||||
)
|
||||
|
||||
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
"""Return the real client IP for rate-limiting purposes.
|
||||
|
||||
When TRUSTED_PROXY_IPS is configured and the direct TCP peer is a trusted
|
||||
proxy, X-Forwarded-For is evaluated right-to-left: the rightmost IP that is
|
||||
NOT itself a trusted proxy is the true client address (M-R10-A fix).
|
||||
|
||||
Standard nginx with proxy_add_x_forwarded_for *appends* the client IP, so
|
||||
the rightmost entry is always the one added by the last trusted proxy —
|
||||
i.e. the real client. Walking right-to-left and skipping known proxies is
|
||||
safe for multi-hop chains as well.
|
||||
|
||||
Falls back to request.client.host when TRUSTED_PROXY_IPS is unset (direct
|
||||
deployment without a reverse proxy).
|
||||
"""
|
||||
# I5: Use a per-request unique token instead of "unknown" when the transport
|
||||
# layer provides no client address. This prevents all such requests from
|
||||
# sharing one rate-limit bucket, and avoids collision with a literal username
|
||||
# "unknown". The token is not stable across requests, which is intentional:
|
||||
# we cannot track the IP so we also cannot rate-limit by it meaningfully.
|
||||
direct_ip = request.client.host if request.client else f"__no_ip_{secrets.token_hex(8)}__"
|
||||
if _TRUSTED_PROXY_IPS and direct_ip in _TRUSTED_PROXY_IPS:
|
||||
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
||||
ips = [ip.strip() for ip in forwarded_for.split(",") if ip.strip()]
|
||||
# Walk right-to-left; skip IPs that belong to trusted proxies.
|
||||
for ip in reversed(ips):
|
||||
if ip not in _TRUSTED_PROXY_IPS:
|
||||
return ip
|
||||
# Edge case: every entry is a trusted proxy — fall back to leftmost.
|
||||
if ips:
|
||||
return ips[0]
|
||||
return direct_ip
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
|
|
@ -206,7 +261,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
|
|||
logger.error("Failed to create admin user: %s", e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create admin user: {str(e)}",
|
||||
detail="Failed to create admin user",
|
||||
)
|
||||
|
||||
# Set auth enabled and mark setup as completed
|
||||
|
|
@ -227,7 +282,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
|
|||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Setup failed: {str(e)}",
|
||||
detail="Setup failed",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -272,15 +327,20 @@ async def disable_auth(
|
|||
logger.error("Failed to disable authentication: %s", e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to disable authentication: {str(e)}",
|
||||
detail="Failed to disable authentication",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def login(raw_request: Request, request: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
||||
"""Login and get access token.
|
||||
|
||||
Supports username or email-based login. Username lookup is case-insensitive.
|
||||
|
||||
When 2FA is enabled for the user the response contains ``requires_2fa=True``
|
||||
and a short-lived ``pre_auth_token`` instead of the final JWT. The client
|
||||
must then call ``POST /auth/2fa/verify`` (or first ``POST /auth/2fa/email/send``
|
||||
to trigger an email OTP) to obtain the real access token.
|
||||
"""
|
||||
# Check if auth is enabled
|
||||
auth_enabled = await is_auth_enabled(db)
|
||||
|
|
@ -290,6 +350,16 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
|
|||
detail="Authentication is not enabled",
|
||||
)
|
||||
|
||||
# Rate-limit repeated login failures — two independent buckets (M-R5-B / M-R6-A):
|
||||
# 1. Per-username (10/15 min): prevents password brute-force on a known account.
|
||||
# 2. Per-IP (20/15 min): prevents an attacker from locking out arbitrary accounts
|
||||
# (DoS) by sending failures for many usernames from a single address.
|
||||
from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS, check_rate_limit, record_failed_attempt
|
||||
|
||||
await check_rate_limit(db, request.username, event_type=EventType.LOGIN_ATTEMPT, max_attempts=MAX_LOGIN_ATTEMPTS)
|
||||
client_ip = _get_client_ip(raw_request)
|
||||
await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20)
|
||||
|
||||
# Check if LDAP is enabled
|
||||
ldap_user = None
|
||||
ldap_settings = await _get_ldap_settings(db)
|
||||
|
|
@ -338,6 +408,8 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
|
|||
user = await authenticate_user_by_email(db, request.username, request.password)
|
||||
|
||||
if not user:
|
||||
await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT)
|
||||
await record_failed_attempt(db, client_ip, event_type=EventType.LOGIN_IP)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
|
|
@ -348,6 +420,64 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
|
|||
result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
|
||||
user = result.scalar_one()
|
||||
|
||||
# L-R6-A: Password was correct — reset login failure counters for both buckets
|
||||
from backend.app.api.routes.mfa import clear_failed_attempts
|
||||
|
||||
await clear_failed_attempts(db, user.username, event_type=EventType.LOGIN_ATTEMPT)
|
||||
await clear_failed_attempts(db, client_ip, event_type=EventType.LOGIN_IP)
|
||||
|
||||
# --- 2FA check ---
|
||||
# Determine which 2FA methods are active for this user.
|
||||
|
||||
from backend.app.models.settings import Settings as _Settings
|
||||
from backend.app.models.user_totp import UserTOTP
|
||||
|
||||
totp_result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))
|
||||
user_totp = totp_result.scalar_one_or_none()
|
||||
totp_enabled = user_totp is not None and user_totp.is_enabled
|
||||
|
||||
email_2fa_result = await db.execute(select(_Settings).where(_Settings.key == f"user_{user.id}_email_2fa_enabled"))
|
||||
email_2fa_setting = email_2fa_result.scalar_one_or_none()
|
||||
email_otp_enabled = (
|
||||
email_2fa_setting is not None and email_2fa_setting.value.lower() == "true" and user.email is not None
|
||||
)
|
||||
|
||||
if totp_enabled or email_otp_enabled:
|
||||
# Import here to avoid circular imports
|
||||
from backend.app.api.routes.mfa import create_pre_auth_token
|
||||
|
||||
# Bind the pre_auth_token to an HttpOnly cookie so XSS cannot steal the
|
||||
# token from JS memory and complete 2FA from a different client.
|
||||
challenge_id = secrets.token_urlsafe(32)
|
||||
pre_auth_token = await create_pre_auth_token(db, user.username, challenge_id=challenge_id)
|
||||
response.set_cookie(
|
||||
key="2fa_challenge",
|
||||
value=challenge_id,
|
||||
httponly=True,
|
||||
# H-1: only transmit over HTTPS so the binding cookie can't be intercepted
|
||||
# on mixed-content deployments. Falls back to False on plain HTTP so tests
|
||||
# and local development still work (the client wouldn't send it otherwise).
|
||||
secure=raw_request.url.scheme == "https",
|
||||
samesite="lax",
|
||||
max_age=300,
|
||||
path="/api/v1/auth/2fa",
|
||||
)
|
||||
methods: list[str] = []
|
||||
if totp_enabled:
|
||||
methods.append("totp")
|
||||
if email_otp_enabled:
|
||||
methods.append("email")
|
||||
# Backup codes are always available when TOTP is set up
|
||||
if totp_enabled:
|
||||
methods.append("backup")
|
||||
|
||||
return LoginResponse(
|
||||
requires_2fa=True,
|
||||
pre_auth_token=pre_auth_token,
|
||||
two_fa_methods=methods,
|
||||
)
|
||||
|
||||
# No 2FA — issue full token immediately
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
|
||||
|
||||
|
|
@ -403,6 +533,14 @@ async def get_current_user_info(
|
|||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti): # B1: logout bypass fix
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
iat: int | float | None = payload.get("iat")
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -420,6 +558,13 @@ async def get_current_user_info(
|
|||
# Reload with groups for proper permission calculation
|
||||
result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
|
||||
user = result.scalar_one()
|
||||
# L-R8-A: reject tokens issued before the last password change
|
||||
if not _is_token_fresh(iat, user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return _user_to_response(user)
|
||||
|
||||
# No credentials provided
|
||||
|
|
@ -431,8 +576,44 @@ async def get_current_user_info(
|
|||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""Logout (client should discard token)."""
|
||||
async def logout(
|
||||
raw_request: Request,
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
|
||||
):
|
||||
"""Logout — revokes the current JWT so it cannot be reused after logout."""
|
||||
if credentials is not None:
|
||||
raw_token = credentials.credentials
|
||||
# Nit2: Verify signature before revoking to prevent DoS-revoke attacks
|
||||
# (an attacker crafting a token with an arbitrary jti cannot force
|
||||
# revocation of a legitimate token because the signature check rejects it).
|
||||
# Expired tokens are still accepted — the user is logging out and their
|
||||
# token may have just expired; we still want to record the revocation.
|
||||
try:
|
||||
verified = _jwt.decode(
|
||||
raw_token,
|
||||
SECRET_KEY,
|
||||
algorithms=[ALGORITHM],
|
||||
options={"verify_exp": False}, # allow expired tokens at logout
|
||||
)
|
||||
jti: str | None = verified.get("jti")
|
||||
exp = verified.get("exp")
|
||||
username: str | None = verified.get("sub")
|
||||
if jti and exp:
|
||||
expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
|
||||
try:
|
||||
await revoke_jti(jti, expires_at, username)
|
||||
except Exception as exc:
|
||||
_logger.error("Failed to revoke JTI on logout for user %s: %s", username, exc)
|
||||
except PyJWTError:
|
||||
client_ip = _get_client_ip(raw_request)
|
||||
ua = raw_request.headers.get("user-agent", "<unknown>")
|
||||
_logger.error(
|
||||
"Logout received token that failed signature verification — skipping revocation "
|
||||
"(possible tamper attempt; ip=%s ua=%s)",
|
||||
client_ip,
|
||||
ua,
|
||||
)
|
||||
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
|
||||
|
|
@ -467,8 +648,8 @@ async def test_smtp_connection(
|
|||
logger.info(f"Test email sent successfully to {test_request.test_recipient}")
|
||||
return TestSMTPResponse(success=True, message="Test email sent successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send test email: {e}")
|
||||
return TestSMTPResponse(success=False, message=f"Failed to send test email: {str(e)}")
|
||||
logger.error("Failed to send test email: %s", e)
|
||||
return TestSMTPResponse(success=False, message="Failed to send test email")
|
||||
|
||||
|
||||
@router.get("/smtp", response_model=SMTPSettings | None)
|
||||
|
|
@ -502,10 +683,10 @@ async def save_smtp_config(
|
|||
return {"message": "SMTP settings saved successfully"}
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Failed to save SMTP settings: {e}")
|
||||
logger.error("Failed to save SMTP settings: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to save SMTP settings: {str(e)}",
|
||||
detail="Failed to save SMTP settings",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -547,10 +728,10 @@ async def enable_advanced_auth(
|
|||
return {"message": "Advanced authentication enabled successfully", "advanced_auth_enabled": True}
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Failed to enable advanced authentication: {e}")
|
||||
logger.error("Failed to enable advanced authentication: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to enable advanced authentication: {str(e)}",
|
||||
detail="Failed to enable advanced authentication",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -581,10 +762,10 @@ async def disable_advanced_auth(
|
|||
return {"message": "Advanced authentication disabled successfully", "advanced_auth_enabled": False}
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Failed to disable advanced authentication: {e}")
|
||||
logger.error("Failed to disable advanced authentication: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to disable advanced authentication: {str(e)}",
|
||||
detail="Failed to disable advanced authentication",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -599,13 +780,68 @@ async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
|
|||
}
|
||||
|
||||
|
||||
# TTL for password-reset tokens (H-6)
|
||||
_RESET_TOKEN_TTL = timedelta(hours=1)
|
||||
|
||||
# Rate-limit for password-reset email sends per identifier (M-A)
|
||||
_MAX_PWD_RESET_SENDS = 3
|
||||
_PWD_RESET_SEND_WINDOW = timedelta(minutes=15)
|
||||
# L-NEW-6: per-IP cap to prevent mass-reset flooding across many addresses
|
||||
_MAX_PWD_RESET_SENDS_PER_IP = 10
|
||||
|
||||
|
||||
async def _send_reset_email_or_delete_token(
|
||||
reset_token: str,
|
||||
smtp_settings,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
text_body: str,
|
||||
html_body: str,
|
||||
log_label: str,
|
||||
) -> None:
|
||||
"""Background task: send a password-reset email and delete the token on failure.
|
||||
|
||||
C1: FastAPI silently swallows BackgroundTask exceptions. This wrapper
|
||||
catches send failures, deletes the single-use token so it cannot be used
|
||||
(user is not locked out forever — they can request a new link), and logs at
|
||||
ERROR so operators are alerted without leaking details to the caller.
|
||||
"""
|
||||
try:
|
||||
send_email(smtp_settings, to_email, subject, text_body, html_body)
|
||||
_logger.info("Password reset email sent (%s) to %s", log_label, to_email)
|
||||
except Exception as exc:
|
||||
_logger.error(
|
||||
"Password reset email failed (%s) to %s — deleting token to unblock re-request: %s",
|
||||
log_label,
|
||||
to_email,
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
async with async_session() as db:
|
||||
await db.execute(
|
||||
delete(AuthEphemeralToken).where(
|
||||
AuthEphemeralToken.token == reset_token,
|
||||
AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as db_exc:
|
||||
_logger.error("Failed to delete reset token after send failure: %s", db_exc)
|
||||
|
||||
|
||||
@router.post("/forgot-password", response_model=ForgotPasswordResponse)
|
||||
async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Request password reset via email (advanced auth only)."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
async def forgot_password(
|
||||
request: ForgotPasswordRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Request password reset via email (advanced auth only).
|
||||
|
||||
H-6: Issues a short-lived single-use reset token and emails the user a
|
||||
secure link instead of a plaintext temporary password. The new password is
|
||||
set only when the user clicks the link and POSTs to /forgot-password/confirm.
|
||||
"""
|
||||
# Check if advanced auth is enabled
|
||||
advanced_auth = await is_advanced_auth_enabled(db)
|
||||
if not advanced_auth:
|
||||
|
|
@ -614,6 +850,47 @@ async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Dep
|
|||
detail="Advanced authentication is not enabled",
|
||||
)
|
||||
|
||||
# M-A: Rate-limit by normalised email to prevent reset-email flooding.
|
||||
# Apply unconditionally (before the user lookup) so unknown emails are also
|
||||
# throttled — this prevents both flooding and timing-based enumeration.
|
||||
identifier = request.email.lower()
|
||||
cutoff = datetime.now(timezone.utc) - _PWD_RESET_SEND_WINDOW
|
||||
rate_result = await db.execute(
|
||||
select(AuthRateLimitEvent).where(
|
||||
AuthRateLimitEvent.username == identifier,
|
||||
AuthRateLimitEvent.event_type == EventType.PASSWORD_RESET_SEND,
|
||||
AuthRateLimitEvent.occurred_at > cutoff,
|
||||
)
|
||||
)
|
||||
if len(rate_result.scalars().all()) >= _MAX_PWD_RESET_SENDS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Too many password reset requests. Please wait {_PWD_RESET_SEND_WINDOW.seconds // 60} minutes.",
|
||||
)
|
||||
|
||||
# L-NEW-6: per-IP rate limit — prevents mass-reset flooding across many
|
||||
# different email addresses from a single source IP.
|
||||
client_ip = _get_client_ip(raw_request)
|
||||
ip_rate_result = await db.execute(
|
||||
select(AuthRateLimitEvent).where(
|
||||
AuthRateLimitEvent.username == client_ip,
|
||||
AuthRateLimitEvent.event_type == EventType.PASSWORD_RESET_IP,
|
||||
AuthRateLimitEvent.occurred_at > cutoff,
|
||||
)
|
||||
)
|
||||
if len(ip_rate_result.scalars().all()) >= _MAX_PWD_RESET_SENDS_PER_IP:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Too many password reset requests. Please wait {_PWD_RESET_SEND_WINDOW.seconds // 60} minutes.",
|
||||
)
|
||||
|
||||
# Nit7: Always record the IP-level event (prevents spray attacks across many
|
||||
# different email addresses from one IP). The email-level event is only
|
||||
# recorded when we actually send an email to a local user — LDAP/OIDC users
|
||||
# do not consume a slot because this flow is a no-op for them.
|
||||
db.add(AuthRateLimitEvent(username=client_ip, event_type=EventType.PASSWORD_RESET_IP))
|
||||
await db.commit()
|
||||
|
||||
# Get SMTP settings
|
||||
smtp_settings = await get_smtp_settings(db)
|
||||
if not smtp_settings:
|
||||
|
|
@ -622,47 +899,116 @@ async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Dep
|
|||
detail="Email service is not configured",
|
||||
)
|
||||
|
||||
# Find user by email
|
||||
# Find user by email — always return success to prevent email enumeration.
|
||||
user = await get_user_by_email(db, request.email)
|
||||
|
||||
# Always return success message to prevent email enumeration
|
||||
# but only send email if user exists and is not an LDAP user
|
||||
if user and user.is_active and user.auth_source != "ldap":
|
||||
# M-1: exclude LDAP and OIDC users — they must use their respective provider.
|
||||
if user and user.is_active and user.auth_source not in ("ldap", "oidc"):
|
||||
try:
|
||||
# Generate new password
|
||||
new_password = generate_secure_password()
|
||||
user.password_hash = get_password_hash(new_password)
|
||||
# Record email-level slot only for local users who will actually receive
|
||||
# the reset email (Nit7: don't waste the user's quota for LDAP/OIDC no-ops).
|
||||
db.add(AuthRateLimitEvent(username=identifier, event_type=EventType.PASSWORD_RESET_SEND))
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
# Prune any outstanding reset tokens for this user before issuing a new one.
|
||||
await db.execute(
|
||||
delete(AuthEphemeralToken).where(
|
||||
AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
|
||||
AuthEphemeralToken.username == user.username,
|
||||
)
|
||||
)
|
||||
reset_token = secrets.token_urlsafe(32)
|
||||
db.add(
|
||||
AuthEphemeralToken(
|
||||
token=reset_token,
|
||||
token_type=TokenType.PASSWORD_RESET,
|
||||
username=user.username,
|
||||
expires_at=now + _RESET_TOKEN_TTL,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
login_url = await get_external_login_url(db)
|
||||
# M-B: Deliver token in the URL fragment so it never reaches the server
|
||||
# in access-logs or Referer headers (mirrors H-4 for the OIDC token).
|
||||
reset_url = f"{login_url}#reset_token={reset_token}"
|
||||
|
||||
# Send password reset email
|
||||
subject, text_body, html_body = await create_password_reset_email_from_template(
|
||||
db, user.username, new_password, login_url
|
||||
subject, text_body, html_body = await create_password_reset_link_email_from_template(
|
||||
db, user.username, reset_url
|
||||
)
|
||||
send_email(smtp_settings, user.email, subject, text_body, html_body)
|
||||
|
||||
logger.info(f"Password reset email sent to {user.email}")
|
||||
# L-R9-B: send asynchronously so response time is independent of
|
||||
# whether the user exists (prevents email-existence timing oracle).
|
||||
# C1: wrapper deletes the token if SMTP fails so the user can re-request.
|
||||
background_tasks.add_task(
|
||||
_send_reset_email_or_delete_token,
|
||||
reset_token,
|
||||
smtp_settings,
|
||||
user.email,
|
||||
subject,
|
||||
text_body,
|
||||
html_body,
|
||||
"forgot_password",
|
||||
)
|
||||
_logger.info("Password reset email queued for %s", user.email)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send password reset email: {e}")
|
||||
# Don't reveal error to user for security
|
||||
_logger.error("Failed to send password reset email: %s", e)
|
||||
# Don't reveal error to caller for security
|
||||
|
||||
return ForgotPasswordResponse(
|
||||
message="If the email address is associated with an account, a password reset email has been sent."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/forgot-password/confirm", response_model=ForgotPasswordResponse)
|
||||
async def forgot_password_confirm(request: ForgotPasswordConfirmRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Complete a password reset by supplying the token from the reset email.
|
||||
|
||||
H-6: Atomically consumes the single-use token (DELETE…RETURNING) and sets
|
||||
the new password. Expired or already-used tokens are silently rejected with
|
||||
the same response to prevent oracle attacks.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
delete(AuthEphemeralToken)
|
||||
.where(
|
||||
AuthEphemeralToken.token == request.token,
|
||||
AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
|
||||
)
|
||||
.returning(AuthEphemeralToken.username, AuthEphemeralToken.expires_at)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
await db.commit()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
|
||||
|
||||
username, expires_at = row
|
||||
# SQLite returns naive datetimes; treat them as UTC.
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if now > expires_at:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
|
||||
|
||||
user = await get_user_by_username(db, username)
|
||||
# M-1: block LDAP/OIDC users — they authenticate via their provider, not local password.
|
||||
if not user or not user.is_active or user.auth_source in ("ldap", "oidc"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
|
||||
|
||||
user.password_hash = get_password_hash(request.new_password)
|
||||
user.password_changed_at = now # M-R7-B: invalidate all prior JWTs
|
||||
await db.commit()
|
||||
_logger.info("Password reset completed for user '%s'", username)
|
||||
|
||||
return ForgotPasswordResponse(message="Password has been reset successfully.")
|
||||
|
||||
|
||||
@router.post("/reset-password", response_model=ResetPasswordResponse)
|
||||
async def reset_user_password(
|
||||
request: ResetPasswordRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reset a user's password and send them an email (admin only, advanced auth only)."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Reload user with groups for proper is_admin check
|
||||
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
|
||||
admin_user = result.scalar_one()
|
||||
|
|
@ -698,10 +1044,11 @@ async def reset_user_password(
|
|||
detail="User not found",
|
||||
)
|
||||
|
||||
if user.auth_source == "ldap":
|
||||
# M-1: block LDAP/OIDC users — passwords are managed by their respective providers.
|
||||
if user.auth_source in ("ldap", "oidc"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot reset password for LDAP users — passwords are managed by the LDAP server",
|
||||
detail="Cannot reset password for LDAP/OIDC users — authentication is managed by their provider",
|
||||
)
|
||||
|
||||
if not user.email:
|
||||
|
|
@ -711,27 +1058,51 @@ async def reset_user_password(
|
|||
)
|
||||
|
||||
try:
|
||||
# Generate new password
|
||||
new_password = generate_secure_password()
|
||||
user.password_hash = get_password_hash(new_password)
|
||||
# H-B: Issue a single-use reset link instead of generating a plaintext password.
|
||||
# The admin never sees the credential — the user sets their own password.
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.execute(
|
||||
delete(AuthEphemeralToken).where(
|
||||
AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
|
||||
AuthEphemeralToken.username == user.username,
|
||||
)
|
||||
)
|
||||
reset_token = secrets.token_urlsafe(32)
|
||||
db.add(
|
||||
AuthEphemeralToken(
|
||||
token=reset_token,
|
||||
token_type=TokenType.PASSWORD_RESET,
|
||||
username=user.username,
|
||||
expires_at=now + _RESET_TOKEN_TTL,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
login_url = await get_external_login_url(db)
|
||||
reset_url = f"{login_url}#reset_token={reset_token}"
|
||||
|
||||
# Send password reset email
|
||||
subject, text_body, html_body = await create_password_reset_email_from_template(
|
||||
db, user.username, new_password, login_url
|
||||
subject, text_body, html_body = await create_password_reset_link_email_from_template(
|
||||
db, user.username, reset_url
|
||||
)
|
||||
background_tasks.add_task(
|
||||
_send_reset_email_or_delete_token,
|
||||
reset_token,
|
||||
smtp_settings,
|
||||
user.email,
|
||||
subject,
|
||||
text_body,
|
||||
html_body,
|
||||
"admin_reset",
|
||||
)
|
||||
send_email(smtp_settings, user.email, subject, text_body, html_body)
|
||||
|
||||
logger.info(f"Password reset by admin {admin_user.username} for user {user.username}")
|
||||
return ResetPasswordResponse(message=f"Password reset email sent to {user.email}")
|
||||
_logger.info("Admin password reset link queued for user '%s' by admin '%s'", user.username, admin_user.username)
|
||||
return ResetPasswordResponse(message=f"Password reset link sent to {user.email}")
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Failed to reset password for user {user.username}: {e}")
|
||||
_logger.error("Failed to send admin password reset for user '%s': %s", user.username, e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to reset password: {str(e)}",
|
||||
detail="Failed to send password reset link. Check server logs.", # L-R7-B: no internal details
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -525,7 +525,7 @@ async def create_stream_token(
|
|||
Returns a token valid for 60 minutes that can be appended as ?token=xxx
|
||||
to camera stream/snapshot URLs loaded via <img> tags.
|
||||
"""
|
||||
return {"token": create_camera_stream_token()}
|
||||
return {"token": await create_camera_stream_token()}
|
||||
|
||||
|
||||
@router.get("/{printer_id}/camera/stream")
|
||||
|
|
|
|||
|
|
@ -2499,7 +2499,7 @@ async def create_library_slicer_token(
|
|||
if not file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
token = create_slicer_download_token("library", file_id)
|
||||
token = await create_slicer_download_token("library", file_id)
|
||||
return {"token": token}
|
||||
|
||||
|
||||
|
|
@ -2518,7 +2518,7 @@ async def download_library_file_for_slicer(
|
|||
"""
|
||||
from backend.app.core.auth import verify_slicer_download_token
|
||||
|
||||
if not verify_slicer_download_token(token, "library", file_id):
|
||||
if not await verify_slicer_download_token(token, "library", file_id):
|
||||
raise HTTPException(status_code=403, detail="Invalid or expired download token")
|
||||
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
|
||||
|
|
|
|||
1690
backend/app/api/routes/mfa.py
Normal file
1690
backend/app/api/routes/mfa.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,22 @@
|
|||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
import jwt as _jwt
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.api.routes.settings import get_external_login_url
|
||||
from backend.app.core.auth import (
|
||||
ALGORITHM,
|
||||
SECRET_KEY,
|
||||
RequirePermissionIfAuthEnabled,
|
||||
get_current_user_optional,
|
||||
get_password_hash,
|
||||
revoke_jti,
|
||||
security,
|
||||
verify_password,
|
||||
)
|
||||
from backend.app.core.database import get_db
|
||||
|
|
@ -398,6 +407,7 @@ async def delete_user(
|
|||
@router.post("/me/change-password", response_model=dict)
|
||||
async def change_own_password(
|
||||
password_data: ChangePasswordRequest,
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
|
||||
current_user: User | None = Depends(get_current_user_optional),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
@ -421,19 +431,19 @@ async def change_own_password(
|
|||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Account has no local password set",
|
||||
)
|
||||
|
||||
# Rate-limit failed password-change attempts (H-R5-A)
|
||||
from backend.app.api.routes.mfa import MAX_2FA_ATTEMPTS, check_rate_limit, record_failed_attempt
|
||||
|
||||
await check_rate_limit(db, current_user.username, event_type="password_change", max_attempts=MAX_2FA_ATTEMPTS)
|
||||
|
||||
if not verify_password(password_data.current_password, current_user.password_hash):
|
||||
await record_failed_attempt(db, current_user.username, event_type="password_change")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Current password is incorrect",
|
||||
)
|
||||
|
||||
# Validate new password
|
||||
if len(password_data.new_password) < 6:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="New password must be at least 6 characters",
|
||||
)
|
||||
|
||||
# Fetch user from this session to ensure changes are persisted
|
||||
result = await db.execute(select(User).where(User.id == current_user.id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
|
@ -445,6 +455,32 @@ async def change_own_password(
|
|||
|
||||
# Update password
|
||||
user.password_hash = get_password_hash(password_data.new_password)
|
||||
user.password_changed_at = datetime.now(timezone.utc) # M-R7-B: invalidate all prior JWTs
|
||||
await db.commit()
|
||||
|
||||
# L-R6-A: Password verified successfully — reset the failure counter
|
||||
from backend.app.api.routes.mfa import clear_failed_attempts
|
||||
|
||||
await clear_failed_attempts(db, user.username, event_type="password_change")
|
||||
|
||||
# Revoke the current session token so the caller must re-authenticate (M-R5-A)
|
||||
if credentials is not None:
|
||||
try:
|
||||
payload = _jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
jti = payload.get("jti")
|
||||
exp = payload.get("exp")
|
||||
if jti and exp:
|
||||
try:
|
||||
await revoke_jti(jti, datetime.fromtimestamp(exp, tz=timezone.utc), user.username)
|
||||
except Exception as exc:
|
||||
# B4: log so operators know revocation is broken; password was
|
||||
# already changed so the token will fail freshness checks anyway.
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).error(
|
||||
"Failed to revoke JTI after password change for user %s: %s", user.username, exc
|
||||
)
|
||||
except Exception:
|
||||
pass # Decode failure is harmless — token is already invalidated by password_changed_at
|
||||
|
||||
return {"message": "Password changed successfully"}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,14 @@ from fastapi import Depends, Header, HTTPException, status
|
|||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jwt.exceptions import PyJWTError as JWTError
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.core.database import async_session, get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
|
||||
|
|
@ -93,79 +94,118 @@ def _get_jwt_secret() -> str:
|
|||
# JWT settings
|
||||
SECRET_KEY = _get_jwt_secret()
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours (M-2: reduced from 7 days)
|
||||
|
||||
# HTTP Bearer token
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
# --- Slicer download tokens ---
|
||||
# Short-lived tokens for slicer protocol handlers that can't send auth headers.
|
||||
# Maps token → (resource_key, expiry). resource_key = "archive:{id}" or "library:{id}".
|
||||
_slicer_tokens: dict[str, tuple[str, datetime]] = {}
|
||||
# Short-lived, single-use tokens for slicer protocol handlers that can't send
|
||||
# auth headers. Stored in AuthEphemeralToken (token_type=TokenType.SLICER_DOWNLOAD)
|
||||
# so they survive server restarts and work in multi-worker deployments (M-3).
|
||||
SLICER_TOKEN_EXPIRE_MINUTES = 5
|
||||
|
||||
|
||||
def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
|
||||
"""Create a short-lived download token for slicer protocol handlers."""
|
||||
# Cleanup expired tokens
|
||||
async def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
|
||||
"""Create a short-lived, single-use download token for slicer protocol handlers."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = [k for k, (_, exp) in _slicer_tokens.items() if exp < now]
|
||||
for k in expired:
|
||||
del _slicer_tokens[k]
|
||||
|
||||
expires_at = now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES)
|
||||
token = secrets.token_urlsafe(24)
|
||||
resource_key = f"{resource_type}:{resource_id}"
|
||||
_slicer_tokens[token] = (resource_key, now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES))
|
||||
async with async_session() as db:
|
||||
# Prune expired tokens opportunistically
|
||||
await db.execute(
|
||||
delete(AuthEphemeralToken).where(
|
||||
AuthEphemeralToken.token_type == TokenType.SLICER_DOWNLOAD,
|
||||
AuthEphemeralToken.expires_at < now,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
AuthEphemeralToken(
|
||||
token=token,
|
||||
token_type=TokenType.SLICER_DOWNLOAD,
|
||||
nonce=resource_key,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def verify_slicer_download_token(token: str, resource_type: str, resource_id: int) -> bool:
|
||||
"""Verify a slicer download token is valid for the given resource."""
|
||||
entry = _slicer_tokens.get(token)
|
||||
if not entry:
|
||||
return False
|
||||
resource_key, expiry = entry
|
||||
if datetime.now(timezone.utc) > expiry:
|
||||
del _slicer_tokens[token]
|
||||
return False
|
||||
async def verify_slicer_download_token(token: str, resource_type: str, resource_id: int) -> bool:
|
||||
"""Verify and atomically consume a slicer download token.
|
||||
|
||||
Returns True only if the token is valid, unexpired, and bound to the given resource.
|
||||
DELETE...RETURNING ensures the token is single-use even under concurrent requests.
|
||||
|
||||
M-NEW-1 fix: nonce (resource key) is included in the WHERE clause so the DELETE
|
||||
only succeeds when the token is presented to the *correct* resource endpoint.
|
||||
Previously the token was consumed (committed) even when stored_key != expected_key,
|
||||
permanently invalidating it while returning False to the caller.
|
||||
"""
|
||||
expected_key = f"{resource_type}:{resource_id}"
|
||||
if resource_key != expected_key:
|
||||
return False
|
||||
# Token is single-use
|
||||
del _slicer_tokens[token]
|
||||
return True
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
delete(AuthEphemeralToken)
|
||||
.where(
|
||||
AuthEphemeralToken.token == token,
|
||||
AuthEphemeralToken.token_type == TokenType.SLICER_DOWNLOAD,
|
||||
AuthEphemeralToken.nonce == expected_key,
|
||||
AuthEphemeralToken.expires_at > now,
|
||||
)
|
||||
.returning(AuthEphemeralToken.id)
|
||||
)
|
||||
if result.one_or_none() is None:
|
||||
return False
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
# --- Camera stream tokens ---
|
||||
# Reusable tokens for camera stream/snapshot endpoints loaded via <img> tags.
|
||||
# Unlike slicer tokens, these are NOT single-use (streams reconnect on errors)
|
||||
# and have a longer expiry. Maps token → expiry.
|
||||
_camera_stream_tokens: dict[str, datetime] = {}
|
||||
# Reusable tokens for camera stream/snapshot endpoints loaded via <img>/<video>
|
||||
# tags (these cannot send Authorization headers). Unlike slicer tokens they are
|
||||
# NOT single-use — streams reconnect on errors. Stored in AuthEphemeralToken
|
||||
# (token_type="camera_stream") for multi-worker compatibility (M-3).
|
||||
CAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60
|
||||
|
||||
|
||||
def create_camera_stream_token() -> str:
|
||||
async def create_camera_stream_token() -> str:
|
||||
"""Create a reusable token for camera stream/snapshot access."""
|
||||
now = datetime.now(timezone.utc)
|
||||
# Cleanup expired tokens
|
||||
expired = [k for k, exp in _camera_stream_tokens.items() if exp < now]
|
||||
for k in expired:
|
||||
del _camera_stream_tokens[k]
|
||||
|
||||
expires_at = now + timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)
|
||||
token = secrets.token_urlsafe(24)
|
||||
_camera_stream_tokens[token] = now + timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)
|
||||
async with async_session() as db:
|
||||
# Prune expired tokens opportunistically
|
||||
await db.execute(
|
||||
delete(AuthEphemeralToken).where(
|
||||
AuthEphemeralToken.token_type == "camera_stream",
|
||||
AuthEphemeralToken.expires_at < now,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
AuthEphemeralToken(
|
||||
token=token,
|
||||
token_type="camera_stream",
|
||||
expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def verify_camera_stream_token(token: str) -> bool:
|
||||
"""Verify a camera stream token is valid."""
|
||||
expiry = _camera_stream_tokens.get(token)
|
||||
if not expiry:
|
||||
return False
|
||||
if datetime.now(timezone.utc) > expiry:
|
||||
del _camera_stream_tokens[token]
|
||||
return False
|
||||
return True
|
||||
async def verify_camera_stream_token(token: str) -> bool:
|
||||
"""Verify a camera stream token is valid (reusable — does not consume it)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(AuthEphemeralToken).where(
|
||||
AuthEphemeralToken.token == token,
|
||||
AuthEphemeralToken.token_type == "camera_stream",
|
||||
AuthEphemeralToken.expires_at > now,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
|
|
@ -185,17 +225,73 @@ def get_password_hash(password: str) -> str:
|
|||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
"""Create a JWT access token."""
|
||||
"""Create a JWT access token with jti (revocation) and iat (freshness) claims."""
|
||||
to_encode = data.copy()
|
||||
now = datetime.now(timezone.utc)
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
expire = now + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
expire = now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
jti = secrets.token_hex(16)
|
||||
to_encode.update({"exp": expire, "jti": jti, "iat": now})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def _is_token_fresh(iat: int | float | None, user: User) -> bool:
|
||||
"""Return False if the token was issued before the user's last password change.
|
||||
|
||||
Used to invalidate all sessions after a password reset/change (M-R7-B).
|
||||
All tokens without an iat claim are unconditionally rejected — every token
|
||||
issued by this server carries iat, so absence means the token is forged or
|
||||
from a pre-iat code path whose max TTL (24 h) has long since expired.
|
||||
"""
|
||||
if iat is None:
|
||||
return False
|
||||
if not hasattr(user, "password_changed_at") or user.password_changed_at is None:
|
||||
return True # No password change recorded yet (I2 migration handles this)
|
||||
token_issued_at = datetime.fromtimestamp(iat, tz=timezone.utc)
|
||||
pca = user.password_changed_at
|
||||
if pca.tzinfo is None:
|
||||
pca = pca.replace(tzinfo=timezone.utc)
|
||||
# JWT iat is whole seconds; truncate pca so tokens issued in the same second pass.
|
||||
pca = pca.replace(microsecond=0)
|
||||
return token_issued_at >= pca
|
||||
|
||||
|
||||
async def revoke_jti(jti: str, expires_at: datetime, username: str | None = None) -> None:
|
||||
"""Store a revoked JWT jti so it is rejected on future requests.
|
||||
|
||||
Silently ignores duplicate inserts (e.g. double-logout with the same token).
|
||||
"""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
async with async_session() as db:
|
||||
revoked = AuthEphemeralToken(
|
||||
token=jti,
|
||||
token_type="revoked_jti",
|
||||
username=username,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(revoked)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback() # jti already revoked — desired state, ignore
|
||||
|
||||
|
||||
async def is_jti_revoked(jti: str) -> bool:
|
||||
"""Return True if the given jti has been revoked."""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(AuthEphemeralToken).where(
|
||||
AuthEphemeralToken.token == jti,
|
||||
AuthEphemeralToken.token_type == "revoked_jti",
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
|
||||
"""Get a user by username (case-insensitive) with groups loaded for permission checks."""
|
||||
result = await db.execute(
|
||||
|
|
@ -216,12 +312,13 @@ async def authenticate_user(db: AsyncSession, username: str, password: str) -> U
|
|||
"""Authenticate a user by username and password.
|
||||
|
||||
Username lookup is case-insensitive. Password is case-sensitive.
|
||||
LDAP and OIDC users must authenticate via their respective providers.
|
||||
"""
|
||||
user = await get_user_by_username(db, username)
|
||||
if not user:
|
||||
return None
|
||||
if getattr(user, "auth_source", "local") == "ldap":
|
||||
return None # LDAP users authenticate via LDAP, not local password
|
||||
if getattr(user, "auth_source", "local") in ("ldap", "oidc"):
|
||||
return None # LDAP/OIDC users must authenticate via their provider
|
||||
if not user.password_hash or not verify_password(password, user.password_hash):
|
||||
return None
|
||||
if not user.is_active:
|
||||
|
|
@ -233,12 +330,13 @@ async def authenticate_user_by_email(db: AsyncSession, email: str, password: str
|
|||
"""Authenticate a user by email and password.
|
||||
|
||||
Email lookup is case-insensitive. Password is case-sensitive.
|
||||
LDAP and OIDC users must authenticate via their respective providers.
|
||||
"""
|
||||
user = await get_user_by_email(db, email)
|
||||
if not user:
|
||||
return None
|
||||
if getattr(user, "auth_source", "local") == "ldap":
|
||||
return None
|
||||
if getattr(user, "auth_source", "local") in ("ldap", "oidc"):
|
||||
return None # LDAP/OIDC users must authenticate via their provider
|
||||
if not user.password_hash or not verify_password(password, user.password_hash):
|
||||
return None
|
||||
if not user.is_active:
|
||||
|
|
@ -262,10 +360,23 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
|
|||
async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
|
||||
"""Validate an API key and return the APIKey object if valid, None otherwise.
|
||||
|
||||
This is an internal helper used by auth functions to check API keys.
|
||||
L-1: Pre-filter by key_prefix (first 8 chars) before running pbkdf2 so only
|
||||
O(1) candidate rows are hashed instead of the full key table. The prefix is
|
||||
not secret (it is shown in the admin UI), so this does not reduce security.
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(select(APIKey).where(APIKey.enabled.is_(True)))
|
||||
# key_prefix is stored as "<first-8-chars>..." (e.g. "bb_Abc12...").
|
||||
# Matching on the first 8 chars of the submitted key reduces the scan to
|
||||
# at most one row in practice (2^40 collision space for 5 base64 chars).
|
||||
key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value
|
||||
result = await db.execute(
|
||||
select(APIKey).where(
|
||||
APIKey.enabled.is_(True),
|
||||
APIKey.key_prefix.like(
|
||||
key_lookup.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%", escape="\\"
|
||||
),
|
||||
)
|
||||
)
|
||||
api_keys = result.scalars().all()
|
||||
|
||||
for api_key in api_keys:
|
||||
|
|
@ -289,23 +400,40 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
|
|||
async def get_current_user_optional(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
|
||||
) -> User | None:
|
||||
"""Get the current authenticated user from JWT token, or None if not authenticated."""
|
||||
"""Get the current authenticated user from JWT token, or None if not authenticated.
|
||||
|
||||
Returns None only when NO credentials are supplied. If a token is supplied
|
||||
but invalid/revoked, raises 401 — a revoked token must not grant anonymous
|
||||
access (I6).
|
||||
"""
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
_unauthorized = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
token = credentials.credentials
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
return None
|
||||
raise _unauthorized
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise _unauthorized # I6: revoked token → 401, not anonymous
|
||||
iat: int | float | None = payload.get("iat")
|
||||
except JWTError:
|
||||
return None
|
||||
raise _unauthorized
|
||||
|
||||
async with async_session() as db:
|
||||
user = await get_user_by_username(db, username)
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
raise _unauthorized
|
||||
if not _is_token_fresh(iat, user):
|
||||
raise _unauthorized
|
||||
return user
|
||||
|
||||
|
||||
|
|
@ -326,6 +454,10 @@ async def get_current_user(
|
|||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise credentials_exception
|
||||
iat: int | float | None = payload.get("iat")
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
|
|
@ -338,6 +470,8 @@ async def get_current_user(
|
|||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is disabled",
|
||||
)
|
||||
if not _is_token_fresh(iat, user):
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
|
|
@ -390,6 +524,14 @@ async def require_auth_if_enabled(
|
|||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
iat: int | float | None = payload.get("iat")
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -404,6 +546,12 @@ async def require_auth_if_enabled(
|
|||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not _is_token_fresh(iat, user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
# No credentials provided
|
||||
|
|
@ -483,8 +631,18 @@ async def get_api_key(
|
|||
detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer <key>'",
|
||||
)
|
||||
|
||||
# Get all API keys and check them
|
||||
result = await db.execute(select(APIKey).where(APIKey.enabled.is_(True)))
|
||||
# M-NEW-2: Pre-filter by key_prefix (first 8 chars) to avoid O(n) pbkdf2 over all
|
||||
# enabled keys — same fix as in _validate_api_key (L-1 from previous review).
|
||||
key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value
|
||||
result = await db.execute(
|
||||
select(APIKey).where(
|
||||
APIKey.enabled.is_(True),
|
||||
APIKey.key_prefix.like(
|
||||
key_lookup.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%",
|
||||
escape="\\",
|
||||
),
|
||||
)
|
||||
)
|
||||
api_keys = result.scalars().all()
|
||||
|
||||
for api_key in api_keys:
|
||||
|
|
@ -627,12 +785,18 @@ def require_permission(*permissions: str | Permission):
|
|||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise credentials_exception
|
||||
iat: int | float | None = payload.get("iat")
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = await get_user_by_username(db, username)
|
||||
if user is None or not user.is_active:
|
||||
raise credentials_exception
|
||||
if not _is_token_fresh(iat, user):
|
||||
raise credentials_exception
|
||||
|
||||
if not user.has_all_permissions(*perm_strings):
|
||||
raise HTTPException(
|
||||
|
|
@ -699,6 +863,14 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
|
|||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
iat: int | float | None = payload.get("iat")
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -713,6 +885,12 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
|
|||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not _is_token_fresh(iat, user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.has_all_permissions(*perm_strings):
|
||||
raise HTTPException(
|
||||
|
|
@ -753,7 +931,7 @@ def require_camera_stream_token_if_auth_enabled():
|
|||
async with async_session() as db:
|
||||
if not await is_auth_enabled(db):
|
||||
return # Auth disabled, allow access
|
||||
if not token or not verify_camera_stream_token(token):
|
||||
if not token or not await verify_camera_stream_token(token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Valid camera stream token required. Obtain one from POST /api/v1/printers/camera/stream-token",
|
||||
|
|
@ -828,6 +1006,14 @@ def require_ownership_permission(
|
|||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
iat: int | float | None = payload.get("iat")
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -842,6 +1028,12 @@ def require_ownership_permission(
|
|||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not _is_token_fresh(iat, user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if user.has_permission(all_perm):
|
||||
return user, True
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ async def init_db():
|
|||
ams_label,
|
||||
api_key,
|
||||
archive,
|
||||
auth_ephemeral,
|
||||
bug_report,
|
||||
color_catalog,
|
||||
external_link,
|
||||
|
|
@ -168,6 +169,7 @@ async def init_db():
|
|||
maintenance,
|
||||
notification,
|
||||
notification_template,
|
||||
oidc_provider,
|
||||
orca_base_cache,
|
||||
pending_upload,
|
||||
print_batch,
|
||||
|
|
@ -188,6 +190,8 @@ async def init_db():
|
|||
spoolbuddy_device,
|
||||
user,
|
||||
user_email_pref,
|
||||
user_otp_code,
|
||||
user_totp,
|
||||
virtual_printer,
|
||||
)
|
||||
|
||||
|
|
@ -306,6 +310,19 @@ async def run_migrations(conn):
|
|||
except (OperationalError, ProgrammingError):
|
||||
pass # Already applied
|
||||
|
||||
# Migration: Enforce uniqueness on user_oidc_links for existing rows.
|
||||
# create_all() is idempotent and does not add constraints to existing tables,
|
||||
# so we create covering unique indexes explicitly here.
|
||||
await _safe_execute(
|
||||
conn,
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_provider_sub"
|
||||
" ON user_oidc_links (provider_id, provider_user_id)",
|
||||
)
|
||||
await _safe_execute(
|
||||
conn,
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_user_provider ON user_oidc_links (user_id, provider_id)",
|
||||
)
|
||||
|
||||
# Migration: Create FTS5 virtual table for archive full-text search (SQLite only)
|
||||
# PostgreSQL uses tsvector + GIN index instead (set up in archives.py search route)
|
||||
if is_sqlite():
|
||||
|
|
@ -1439,6 +1456,33 @@ async def run_migrations(conn):
|
|||
"ON smart_plug_energy_snapshots(plug_id, recorded_at)",
|
||||
)
|
||||
|
||||
# Migration: Add PKCE code_verifier column to auth_ephemeral_tokens
|
||||
await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN code_verifier VARCHAR(128)")
|
||||
|
||||
# Migration: Add TOTP replay-protection counter to user_totp
|
||||
await _safe_execute(conn, "ALTER TABLE user_totp ADD COLUMN last_totp_counter BIGINT")
|
||||
|
||||
# Migration: Add challenge_id for pre-auth token client binding (HttpOnly cookie)
|
||||
await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN challenge_id VARCHAR(128)")
|
||||
|
||||
# Migration: Add auto_link_existing_accounts column to oidc_providers (M-4)
|
||||
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT 1")
|
||||
|
||||
# Migration: Add password_changed_at to users (M-R7-B)
|
||||
# Tracks the last time a user's password was changed/reset. JWTs whose iat
|
||||
# predates this timestamp are rejected in all six auth validation paths.
|
||||
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN password_changed_at DATETIME")
|
||||
|
||||
# Migration: Back-fill password_changed_at = created_at for existing users (I2).
|
||||
# Users who never changed their password would have NULL here, meaning old
|
||||
# tokens could never be invalidated via the freshness check. Setting it to
|
||||
# created_at is conservative: any token issued before the account was created
|
||||
# is always invalid, so this is a safe lower bound.
|
||||
await _safe_execute(
|
||||
conn,
|
||||
"UPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL",
|
||||
)
|
||||
|
||||
# Seed default settings keys that must exist on fresh install
|
||||
default_settings = [
|
||||
("advanced_auth_enabled", "false"),
|
||||
|
|
|
|||
88
backend/app/core/encryption.py
Normal file
88
backend/app/core/encryption.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""At-rest encryption for high-value secrets (TOTP keys, OIDC client_secret).
|
||||
|
||||
Set the ``MFA_ENCRYPTION_KEY`` environment variable to a URL-safe base64-encoded
|
||||
32-byte key (generate with ``python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"``)
|
||||
to enable Fernet symmetric encryption.
|
||||
|
||||
When the key is not set, values are stored as plaintext and a warning is emitted.
|
||||
Existing plaintext values are read back correctly even after the key is added
|
||||
(values without the ``fernet:`` prefix are treated as legacy plaintext).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FERNET_PREFIX = "fernet:"
|
||||
_fernet_instance = None
|
||||
_warn_shown = False
|
||||
|
||||
|
||||
def _get_fernet():
|
||||
global _fernet_instance, _warn_shown
|
||||
|
||||
if _fernet_instance is not None:
|
||||
return _fernet_instance
|
||||
|
||||
key = os.environ.get("MFA_ENCRYPTION_KEY")
|
||||
if key:
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
_fernet_instance = Fernet(key.encode() if isinstance(key, str) else key)
|
||||
return _fernet_instance
|
||||
|
||||
if not _warn_shown:
|
||||
logger.warning(
|
||||
"MFA_ENCRYPTION_KEY is not set — TOTP secrets and OIDC client_secrets are "
|
||||
"stored in plaintext. Generate a key with: "
|
||||
'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
|
||||
)
|
||||
_warn_shown = True
|
||||
return None
|
||||
|
||||
|
||||
def mfa_encrypt(plaintext: str) -> str:
|
||||
"""Encrypt a secret value. Returns the ciphertext with a ``fernet:`` prefix,
|
||||
or the original plaintext if ``MFA_ENCRYPTION_KEY`` is not configured."""
|
||||
f = _get_fernet()
|
||||
if f is None:
|
||||
return plaintext
|
||||
return _FERNET_PREFIX + f.encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def mfa_decrypt(value: str) -> str:
|
||||
"""Decrypt a value previously encrypted with ``mfa_encrypt``.
|
||||
|
||||
Values without the ``fernet:`` prefix are returned as-is (legacy plaintext).
|
||||
Raises ``RuntimeError`` if the prefix is present but no key is configured.
|
||||
"""
|
||||
if not value.startswith(_FERNET_PREFIX):
|
||||
# Nit6: Warn when a key IS configured but the stored value is plaintext.
|
||||
# This surfaces rows that were written before encryption was enabled so
|
||||
# operators know they need a migration / re-enroll cycle.
|
||||
if _get_fernet() is not None:
|
||||
logger.warning(
|
||||
"mfa_decrypt: MFA_ENCRYPTION_KEY is set but the stored value has no "
|
||||
"'fernet:' prefix — returning legacy plaintext. Consider re-enrolling "
|
||||
"this secret to store it encrypted."
|
||||
)
|
||||
return value # Legacy plaintext — backward compatible
|
||||
|
||||
f = _get_fernet()
|
||||
if f is None:
|
||||
raise RuntimeError(
|
||||
"MFA_ENCRYPTION_KEY must be set to decrypt MFA secrets that were stored with encryption enabled."
|
||||
)
|
||||
from cryptography.fernet import InvalidToken
|
||||
|
||||
try:
|
||||
return f.decrypt(value[len(_FERNET_PREFIX) :].encode()).decode()
|
||||
except InvalidToken:
|
||||
raise RuntimeError(
|
||||
"MFA secret was encrypted under a different MFA_ENCRYPTION_KEY. "
|
||||
"Key rotation is not currently supported — restore the previous key "
|
||||
"or have users re-enroll."
|
||||
)
|
||||
|
|
@ -32,6 +32,7 @@ from backend.app.api.routes import (
|
|||
local_presets,
|
||||
maintenance,
|
||||
metrics,
|
||||
mfa,
|
||||
notification_templates,
|
||||
notifications,
|
||||
obico,
|
||||
|
|
@ -3741,6 +3742,101 @@ def stop_expected_prints_cleanup() -> None:
|
|||
logging.getLogger(__name__).info("Expected prints cleanup stopped")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# L-2: Periodic auth-token cleanup (stale TOTP + expired revoked JTIs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_auth_cleanup_task: asyncio.Task | None = None
|
||||
_AUTH_CLEANUP_INTERVAL = 3600 # seconds (hourly)
|
||||
|
||||
|
||||
async def _run_auth_cleanup() -> None:
|
||||
"""Single cleanup pass: remove stale TOTP records, expired revoked JTIs, and old rate-limit events."""
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent
|
||||
from backend.app.models.user_totp import UserTOTP
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Remove unconfirmed (is_enabled=False) TOTP records older than 1 hour.
|
||||
try:
|
||||
async with async_session() as db:
|
||||
stale_cutoff = now - timedelta(hours=1)
|
||||
result = await db.execute(
|
||||
select(UserTOTP).where(
|
||||
UserTOTP.is_enabled.is_(False),
|
||||
UserTOTP.created_at < stale_cutoff,
|
||||
)
|
||||
)
|
||||
stale_records = result.scalars().all()
|
||||
if stale_records:
|
||||
for rec in stale_records:
|
||||
await db.delete(rec)
|
||||
await db.commit()
|
||||
logging.info("Auth cleanup: removed %d stale unconfirmed TOTP record(s)", len(stale_records))
|
||||
except Exception as e:
|
||||
logging.warning("Auth cleanup: failed to purge stale TOTP records: %s", e)
|
||||
|
||||
# Remove expired revoked-JTI entries (they are no longer needed once the
|
||||
# original token's exp has passed — the token would be rejected by JWT
|
||||
# signature verification regardless).
|
||||
try:
|
||||
async with async_session() as db:
|
||||
await db.execute(
|
||||
delete(AuthEphemeralToken).where(
|
||||
AuthEphemeralToken.token_type == "revoked_jti",
|
||||
AuthEphemeralToken.expires_at < now,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logging.warning("Auth cleanup: failed to purge expired revoked JTIs: %s", e)
|
||||
|
||||
# L-R6-B: Purge AuthRateLimitEvent rows older than the lockout window (15 min).
|
||||
# Events outside this window can never affect rate-limit decisions — they only
|
||||
# consume DB space. Use the same window constant as the rate limiter so the
|
||||
# two are always in sync.
|
||||
try:
|
||||
from backend.app.api.routes.mfa import LOCKOUT_WINDOW
|
||||
|
||||
async with async_session() as db:
|
||||
await db.execute(
|
||||
delete(AuthRateLimitEvent).where(
|
||||
AuthRateLimitEvent.occurred_at < now - LOCKOUT_WINDOW,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logging.warning("Auth cleanup: failed to purge stale rate-limit events: %s", e)
|
||||
|
||||
|
||||
async def _auth_cleanup_loop() -> None:
|
||||
"""Periodic background task: run auth cleanup every hour."""
|
||||
while True:
|
||||
try:
|
||||
await _run_auth_cleanup()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logging.warning("Auth cleanup loop error: %s", e)
|
||||
await asyncio.sleep(_AUTH_CLEANUP_INTERVAL)
|
||||
|
||||
|
||||
def start_auth_cleanup() -> None:
|
||||
global _auth_cleanup_task
|
||||
if _auth_cleanup_task is None:
|
||||
_auth_cleanup_task = asyncio.create_task(_auth_cleanup_loop())
|
||||
logging.getLogger(__name__).info("Auth periodic cleanup started")
|
||||
|
||||
|
||||
def stop_auth_cleanup() -> None:
|
||||
global _auth_cleanup_task
|
||||
if _auth_cleanup_task:
|
||||
_auth_cleanup_task.cancel()
|
||||
_auth_cleanup_task = None
|
||||
logging.getLogger(__name__).info("Auth periodic cleanup stopped")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
|
|
@ -3942,6 +4038,9 @@ async def lifespan(app: FastAPI):
|
|||
# registered but on_print_start never fires)
|
||||
start_expected_prints_cleanup()
|
||||
|
||||
# L-2: Start periodic auth cleanup (stale TOTP + expired revoked JTIs)
|
||||
start_auth_cleanup()
|
||||
|
||||
# Initialize virtual printer manager and sync from DB
|
||||
from backend.app.services.virtual_printer import virtual_printer_manager
|
||||
|
||||
|
|
@ -3967,6 +4066,7 @@ async def lifespan(app: FastAPI):
|
|||
stop_spoolbuddy_watchdog()
|
||||
stop_camera_cleanup()
|
||||
stop_expected_prints_cleanup()
|
||||
stop_auth_cleanup()
|
||||
printer_manager.disconnect_all()
|
||||
await close_spoolman_client()
|
||||
|
||||
|
|
@ -4010,6 +4110,14 @@ PUBLIC_API_ROUTES = {
|
|||
# Advanced auth status needed for login page
|
||||
"/api/v1/auth/advanced-auth/status",
|
||||
"/api/v1/auth/forgot-password", # Password reset for advanced auth
|
||||
"/api/v1/auth/forgot-password/confirm", # Complete password reset with token (H-6)
|
||||
# 2FA routes that are called BEFORE a JWT is issued (pre-auth flow)
|
||||
"/api/v1/auth/2fa/verify", # Exchange pre_auth_token + 2FA code for JWT
|
||||
"/api/v1/auth/2fa/email/send", # Send OTP email (pre_auth_token based)
|
||||
# OIDC routes that must be reachable without a JWT
|
||||
"/api/v1/auth/oidc/providers", # Public list of enabled providers
|
||||
"/api/v1/auth/oidc/callback", # Redirect target from OIDC provider
|
||||
"/api/v1/auth/oidc/exchange", # Exchange short-lived OIDC token for JWT
|
||||
# Version check for updates (no sensitive data)
|
||||
"/api/v1/updates/version",
|
||||
# Metrics endpoint handles its own prometheus_token authentication
|
||||
|
|
@ -4020,6 +4128,8 @@ PUBLIC_API_ROUTES = {
|
|||
PUBLIC_API_PREFIXES = [
|
||||
# WebSocket connections handle their own auth
|
||||
"/api/v1/ws",
|
||||
# OIDC authorize redirects — include provider_id in path
|
||||
"/api/v1/auth/oidc/authorize/",
|
||||
]
|
||||
|
||||
# Route patterns that are public (read-only display data)
|
||||
|
|
@ -4053,6 +4163,27 @@ async def security_headers_middleware(request, call_next):
|
|||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
# Content-Security-Policy for the React SPA.
|
||||
# Notes:
|
||||
# - 'unsafe-inline' for style-src: React and UI libs inject inline styles at runtime.
|
||||
# - connect-src ws:/wss:: MQTT/printer WebSocket connections.
|
||||
# - img-src data: / blob:: base64 thumbnails and Blob-URL timelapse previews.
|
||||
# - media-src blob:: timelapse video player uses Blob URLs.
|
||||
# - font-src data:: some icon fonts are embedded as data URIs.
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"media-src 'self' blob:; "
|
||||
"connect-src 'self' ws: wss:; "
|
||||
"font-src 'self' data:; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"frame-ancestors 'none';"
|
||||
)
|
||||
if request.url.scheme == "https":
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
return response
|
||||
|
||||
|
||||
|
|
@ -4121,18 +4252,34 @@ async def auth_middleware(request, call_next):
|
|||
import jwt
|
||||
|
||||
try:
|
||||
from backend.app.core.auth import ALGORITHM, SECRET_KEY
|
||||
from backend.app.core.auth import (
|
||||
ALGORITHM,
|
||||
SECRET_KEY,
|
||||
_is_token_fresh,
|
||||
get_user_by_username,
|
||||
is_jti_revoked,
|
||||
)
|
||||
|
||||
token = auth_header.replace("Bearer ", "")
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise ValueError("No username in token")
|
||||
jti = payload.get("jti")
|
||||
if not jti:
|
||||
raise ValueError("No jti in token")
|
||||
iat = payload.get("iat")
|
||||
|
||||
# Verify user exists and is active
|
||||
# Reject revoked tokens (defense-in-depth gateway check)
|
||||
if await is_jti_revoked(jti):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Token has been revoked"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Verify user exists, is active, and token is still fresh (L-R8-A)
|
||||
async with async_session() as db:
|
||||
from backend.app.core.auth import get_user_by_username
|
||||
|
||||
user = await get_user_by_username(db, username)
|
||||
if not user or not user.is_active:
|
||||
return JSONResponse(
|
||||
|
|
@ -4140,6 +4287,12 @@ async def auth_middleware(request, call_next):
|
|||
content={"detail": "User not found or inactive"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not _is_token_fresh(iat, user):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Token no longer valid"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
except jwt.ExpiredSignatureError:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
|
|
@ -4158,6 +4311,7 @@ async def auth_middleware(request, call_next):
|
|||
|
||||
# API routes
|
||||
app.include_router(auth.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(mfa.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(bug_report.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(users.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(groups.router, prefix=app_settings.api_prefix)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from backend.app.models.ams_history import AMSSensorHistory
|
|||
from backend.app.models.ams_label import AmsLabel
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent
|
||||
from backend.app.models.color_catalog import ColorCatalogEntry
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
|
|
@ -12,6 +13,7 @@ from backend.app.models.local_preset import LocalPreset
|
|||
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
|
||||
from backend.app.models.notification import NotificationLog
|
||||
from backend.app.models.notification_template import NotificationTemplate
|
||||
from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
|
||||
from backend.app.models.orca_base_cache import OrcaBaseProfile
|
||||
from backend.app.models.pending_upload import PendingUpload
|
||||
from backend.app.models.print_batch import PrintBatch
|
||||
|
|
@ -28,6 +30,8 @@ from backend.app.models.spool_usage_history import SpoolUsageHistory
|
|||
from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
|
||||
from backend.app.models.user import User
|
||||
from backend.app.models.user_email_pref import UserEmailPreference
|
||||
from backend.app.models.user_otp_code import UserOTPCode
|
||||
from backend.app.models.user_totp import UserTOTP
|
||||
|
||||
__all__ = [
|
||||
"Printer",
|
||||
|
|
@ -56,6 +60,8 @@ __all__ = [
|
|||
"GitHubBackupConfig",
|
||||
"GitHubBackupLog",
|
||||
"LocalPreset",
|
||||
"OIDCProvider",
|
||||
"UserOIDCLink",
|
||||
"OrcaBaseProfile",
|
||||
"Spool",
|
||||
"SpoolKProfile",
|
||||
|
|
@ -65,4 +71,8 @@ __all__ = [
|
|||
"ColorCatalogEntry",
|
||||
"SpoolBuddyDevice",
|
||||
"UserEmailPreference",
|
||||
"UserOTPCode",
|
||||
"UserTOTP",
|
||||
"AuthEphemeralToken",
|
||||
"AuthRateLimitEvent",
|
||||
]
|
||||
|
|
|
|||
199
backend/app/models/auth_ephemeral.py
Normal file
199
backend/app/models/auth_ephemeral.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"""Ephemeral authentication tokens and rate-limit events.
|
||||
|
||||
These tables replace the module-level in-memory dicts in mfa.py, making
|
||||
the 2FA / OIDC flow compatible with multi-worker deployments and persistent
|
||||
across server restarts.
|
||||
|
||||
Tables
|
||||
------
|
||||
AuthEphemeralToken
|
||||
Short-lived, single-use tokens for:
|
||||
- pre_auth : issued after password check, consumed when 2FA is verified
|
||||
- oidc_state : CSRF nonce for the OIDC authorization-code flow
|
||||
- oidc_exchange : short bridge token from the OIDC callback to the SPA
|
||||
|
||||
AuthRateLimitEvent
|
||||
Timestamped events used for sliding-window rate limiting:
|
||||
- 2fa_attempt : each failed 2FA verification attempt
|
||||
- email_send : each OTP email sent (prevents email flooding)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class TokenType(str, Enum):
|
||||
"""T3: Enumerated token types for AuthEphemeralToken.token_type.
|
||||
|
||||
Using str-based Enum keeps the stored values human-readable and
|
||||
backward-compatible with existing rows.
|
||||
"""
|
||||
|
||||
PRE_AUTH = "pre_auth"
|
||||
OIDC_STATE = "oidc_state"
|
||||
OIDC_EXCHANGE = "oidc_exchange"
|
||||
PASSWORD_RESET = "password_reset"
|
||||
EMAIL_OTP_SETUP = "email_otp_setup"
|
||||
SLICER_DOWNLOAD = "slicer_download"
|
||||
|
||||
|
||||
class EventType(str, Enum):
|
||||
"""T3: Enumerated event types for AuthRateLimitEvent.event_type.
|
||||
|
||||
Using str-based Enum keeps the stored values human-readable and
|
||||
backward-compatible with existing rows.
|
||||
"""
|
||||
|
||||
TWO_FA_ATTEMPT = "2fa_attempt"
|
||||
EMAIL_SEND = "email_send"
|
||||
LOGIN_ATTEMPT = "login_attempt"
|
||||
LOGIN_IP = "login_ip"
|
||||
PASSWORD_RESET_SEND = "password_reset_send"
|
||||
PASSWORD_RESET_IP = "password_reset_ip"
|
||||
|
||||
|
||||
class AuthEphemeralToken(Base):
|
||||
"""Single-use, time-limited token for pre-auth / OIDC flows."""
|
||||
|
||||
__tablename__ = "auth_ephemeral_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
token: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
token_type: Mapped[str] = mapped_column(String(20), nullable=False) # 'pre_auth' | 'oidc_state' | 'oidc_exchange'
|
||||
|
||||
# pre_auth + oidc_exchange: which user this session belongs to
|
||||
username: Mapped[str | None] = mapped_column(String(150), nullable=True)
|
||||
|
||||
# oidc_state: which provider initiated the flow
|
||||
provider_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# oidc_state: replay-protection nonce embedded in the ID token
|
||||
nonce: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
# oidc_state: PKCE code verifier (S256 method)
|
||||
code_verifier: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
# pre_auth: HttpOnly cookie value bound to this token to prevent token theft
|
||||
# (XSS can read JS memory but cannot read HttpOnly cookies).
|
||||
challenge_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# T1: Classmethod factories — enforce required fields per token type
|
||||
# and prevent accidentally leaving optional fields at their defaults.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def new_pre_auth(
|
||||
cls,
|
||||
token: str,
|
||||
username: str,
|
||||
expires_at: datetime,
|
||||
challenge_id: str | None = None,
|
||||
) -> AuthEphemeralToken:
|
||||
"""Create a pre-auth token (issued after password check, before 2FA)."""
|
||||
return cls(
|
||||
token=token,
|
||||
token_type=TokenType.PRE_AUTH,
|
||||
username=username,
|
||||
expires_at=expires_at,
|
||||
challenge_id=challenge_id,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def new_oidc_state(
|
||||
cls,
|
||||
token: str,
|
||||
provider_id: int,
|
||||
nonce: str,
|
||||
code_verifier: str,
|
||||
expires_at: datetime,
|
||||
) -> AuthEphemeralToken:
|
||||
"""Create an OIDC state token (CSRF protection + PKCE for authorize redirect)."""
|
||||
return cls(
|
||||
token=token,
|
||||
token_type=TokenType.OIDC_STATE,
|
||||
provider_id=provider_id,
|
||||
nonce=nonce,
|
||||
code_verifier=code_verifier,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def new_oidc_exchange(
|
||||
cls,
|
||||
token: str,
|
||||
username: str,
|
||||
expires_at: datetime,
|
||||
) -> AuthEphemeralToken:
|
||||
"""Create an OIDC exchange token (bridge from callback to SPA)."""
|
||||
return cls(
|
||||
token=token,
|
||||
token_type=TokenType.OIDC_EXCHANGE,
|
||||
username=username,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def new_password_reset(
|
||||
cls,
|
||||
token: str,
|
||||
username: str,
|
||||
expires_at: datetime,
|
||||
) -> AuthEphemeralToken:
|
||||
"""Create a password-reset token (single-use link emailed to the user)."""
|
||||
return cls(
|
||||
token=token,
|
||||
token_type=TokenType.PASSWORD_RESET,
|
||||
username=username,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def new_email_otp_setup(
|
||||
cls,
|
||||
token: str,
|
||||
username: str,
|
||||
code_hash: str,
|
||||
expires_at: datetime,
|
||||
) -> AuthEphemeralToken:
|
||||
"""Create an email-OTP setup token.
|
||||
|
||||
The ``code_hash`` is stored in the ``nonce`` column (field reuse
|
||||
documented inline in the enable_email_otp endpoint).
|
||||
"""
|
||||
return cls(
|
||||
token=token,
|
||||
token_type=TokenType.EMAIL_OTP_SETUP,
|
||||
username=username,
|
||||
nonce=code_hash,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
class AuthRateLimitEvent(Base):
|
||||
"""Timestamped events used for sliding-window rate limiting."""
|
||||
|
||||
__tablename__ = "auth_rate_limit_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(150), nullable=False, index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(20), nullable=False) # '2fa_attempt' | 'email_send'
|
||||
occurred_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
93
backend/app/models/oidc_provider.py
Normal file
93
backend/app/models/oidc_provider.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.core.database import Base
|
||||
from backend.app.core.encryption import mfa_decrypt, mfa_encrypt
|
||||
|
||||
|
||||
class OIDCProvider(Base):
|
||||
"""OpenID Connect provider configuration.
|
||||
|
||||
Supports any standards-compliant OIDC provider such as PocketID,
|
||||
Authentik, Keycloak, Authelia, Google, etc.
|
||||
|
||||
The issuer_url must point to the root issuer (e.g. ``https://id.example.com``).
|
||||
The OIDC discovery document is fetched from
|
||||
``{issuer_url}/.well-known/openid-configuration`` at runtime.
|
||||
"""
|
||||
|
||||
__tablename__ = "oidc_providers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
# Human-readable name shown on the login button (e.g. "PocketID", "Google")
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
# Full OIDC issuer URL (e.g. "https://id.example.com")
|
||||
issuer_url: Mapped[str] = mapped_column(String(500))
|
||||
client_id: Mapped[str] = mapped_column(String(255))
|
||||
# Encrypted at rest when MFA_ENCRYPTION_KEY is set.
|
||||
# Use .client_secret / .client_secret setter rather than _client_secret_enc directly.
|
||||
_client_secret_enc: Mapped[str] = mapped_column("client_secret", String(512))
|
||||
|
||||
@property
|
||||
def client_secret(self) -> str:
|
||||
return mfa_decrypt(self._client_secret_enc)
|
||||
|
||||
@client_secret.setter
|
||||
def client_secret(self, value: str) -> None:
|
||||
self._client_secret_enc = mfa_encrypt(value)
|
||||
|
||||
# Space-separated scopes; must include "openid"
|
||||
scopes: Mapped[str] = mapped_column(String(500), default="openid email profile")
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
# When True, a new local user is created automatically on first OIDC login
|
||||
auto_create_users: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# When True, an existing local user whose email matches the OIDC claim is
|
||||
# automatically linked on first SSO login. Default is False (conservative):
|
||||
# operators must explicitly opt-in to prevent an attacker-controlled IdP from
|
||||
# silently hijacking local accounts via email matching (M-2 fix).
|
||||
auto_link_existing_accounts: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# Optional icon URL (SVG/PNG) shown on the login button
|
||||
icon_url: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationship to linked user accounts
|
||||
user_links: Mapped[list[UserOIDCLink]] = relationship(
|
||||
"UserOIDCLink",
|
||||
back_populates="provider",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<OIDCProvider {self.name!r}>"
|
||||
|
||||
|
||||
class UserOIDCLink(Base):
|
||||
"""Links a local Bambuddy user account to an identity at an OIDC provider."""
|
||||
|
||||
__tablename__ = "user_oidc_links"
|
||||
__table_args__ = (
|
||||
# T2: Prevent duplicate OIDC identities and duplicate provider links.
|
||||
# (provider_id, provider_user_id) — one OIDC sub per provider maps to at most one local user.
|
||||
UniqueConstraint("provider_id", "provider_user_id", name="uq_oidc_link_provider_sub"),
|
||||
# (user_id, provider_id) — one local user can link to each provider at most once.
|
||||
UniqueConstraint("user_id", "provider_id", name="uq_oidc_link_user_provider"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
provider_id: Mapped[int] = mapped_column(Integer, ForeignKey("oidc_providers.id", ondelete="CASCADE"), index=True)
|
||||
# The "sub" claim from the OIDC ID token — stable identifier for the user
|
||||
provider_user_id: Mapped[str] = mapped_column(String(500))
|
||||
# Email returned by the provider (informational; may differ from local email)
|
||||
provider_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
provider: Mapped[OIDCProvider] = relationship("OIDCProvider", back_populates="user_links")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<UserOIDCLink user_id={self.user_id} provider_id={self.provider_id}>"
|
||||
|
|
@ -30,11 +30,15 @@ class User(Base):
|
|||
role: Mapped[str] = mapped_column(
|
||||
String(20), default="user"
|
||||
) # "admin" or "user" (legacy, kept for backward compat)
|
||||
auth_source: Mapped[str] = mapped_column(String(20), default="local") # "local" or "ldap"
|
||||
auth_source: Mapped[str] = mapped_column(String(20), default="local") # "local", "ldap", or "oidc"
|
||||
is_active: Mapped[bool] = mapped_column(default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Set whenever the local password is changed/reset — used to invalidate JWTs
|
||||
# issued before the change (M-R7-B). NULL means no password change recorded yet.
|
||||
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)
|
||||
cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
|
||||
cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
|
|
|
|||
55
backend/app/models/user_otp_code.py
Normal file
55
backend/app/models/user_otp_code.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class UserOTPCode(Base):
|
||||
"""Temporary email OTP (One-Time Password) code for 2FA verification.
|
||||
|
||||
Each record represents a single sent OTP code. Codes expire after
|
||||
OTP_TTL_MINUTES and are invalidated after MAX_ATTEMPTS failed attempts
|
||||
or after successful verification.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_otp_codes"
|
||||
|
||||
OTP_TTL_MINUTES = 10
|
||||
MAX_ATTEMPTS = 5
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
# pbkdf2_sha256 hash of the 6-digit code
|
||||
code_hash: Mapped[str] = mapped_column(String(255))
|
||||
# Number of failed verification attempts for this code
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# True once the code has been successfully used or explicitly invalidated
|
||||
used: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
def consume(self) -> None:
|
||||
"""T4: Mark this OTP as used, enforcing preconditions.
|
||||
|
||||
Raises ``ValueError`` if the code is already used or expired so callers
|
||||
cannot silently re-use an invalidated code. The caller is responsible
|
||||
for flushing/committing the change to the DB.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
exp = self.expires_at
|
||||
if exp.tzinfo is None:
|
||||
from datetime import timezone as _tz
|
||||
|
||||
exp = exp.replace(tzinfo=_tz.utc)
|
||||
if self.used:
|
||||
raise ValueError("OTP code has already been used")
|
||||
if exp < now:
|
||||
raise ValueError("OTP code has expired")
|
||||
self.used = True
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<UserOTPCode user_id={self.user_id} used={self.used}>"
|
||||
84
backend/app/models/user_totp.py
Normal file
84
backend/app/models/user_totp.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.core.database import Base
|
||||
from backend.app.core.encryption import mfa_decrypt, mfa_encrypt
|
||||
|
||||
|
||||
class UserTOTP(Base):
|
||||
"""TOTP (Time-based One-Time Password) secret for a user.
|
||||
|
||||
Stores the TOTP secret used by authenticator apps (Google Authenticator,
|
||||
Proton Authenticator, Aegis, etc.). One record per user; is_enabled=False
|
||||
while the setup is pending confirmation.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_totp"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True)
|
||||
# TOTP secret — encrypted at rest when MFA_ENCRYPTION_KEY is set.
|
||||
# Use .secret / .set_secret() rather than accessing _secret_enc directly.
|
||||
_secret_enc: Mapped[str] = mapped_column("secret", String(512))
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# Hashed backup codes stored as JSON array of strings
|
||||
# Each entry is a hashed one-time-use recovery code
|
||||
backup_codes_json: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||
# TOTP replay protection: stores the 30-second time-step counter of the last
|
||||
# accepted code so the same code cannot be used twice within one window.
|
||||
last_totp_counter: Mapped[int | None] = mapped_column(BigInteger, nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@property
|
||||
def secret(self) -> str:
|
||||
"""Return the decrypted TOTP secret."""
|
||||
return mfa_decrypt(self._secret_enc)
|
||||
|
||||
@secret.setter
|
||||
def secret(self, value: str) -> None:
|
||||
"""Store the TOTP secret, encrypting it when MFA_ENCRYPTION_KEY is set."""
|
||||
self._secret_enc = mfa_encrypt(value)
|
||||
|
||||
@property
|
||||
def backup_code_hashes(self) -> list[str]:
|
||||
"""T5: Get stored backup-code hashes as a list.
|
||||
|
||||
The name makes clear that these are *hashes*, not plaintext codes,
|
||||
so callers know they must verify with a password-hashing library
|
||||
rather than compare directly.
|
||||
"""
|
||||
if not self.backup_codes_json:
|
||||
return []
|
||||
return json.loads(self.backup_codes_json)
|
||||
|
||||
@backup_code_hashes.setter
|
||||
def backup_code_hashes(self, hashes: list[str]) -> None:
|
||||
"""Persist backup-code hashes as a JSON array."""
|
||||
self.backup_codes_json = json.dumps(hashes)
|
||||
|
||||
def accept_counter(self, new_counter: int) -> None:
|
||||
"""T4: Record an accepted TOTP time-step counter, rejecting backward movement.
|
||||
|
||||
Raises ``HTTPException(400)`` if ``new_counter`` is not strictly greater
|
||||
than ``last_totp_counter``, preventing counter roll-back attacks (e.g. an
|
||||
attacker who replays a previously accepted code after the counter wraps or
|
||||
the clock is skewed backward).
|
||||
|
||||
The caller is responsible for flushing/committing the change to the DB.
|
||||
"""
|
||||
if self.last_totp_counter is not None and new_counter <= self.last_totp_counter:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="TOTP code already used",
|
||||
)
|
||||
self.last_totp_counter = new_counter
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<UserTOTP user_id={self.user_id} enabled={self.is_enabled}>"
|
||||
|
|
@ -1,4 +1,24 @@
|
|||
from pydantic import BaseModel
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
def _validate_password_complexity(v: str) -> str:
|
||||
"""Enforce minimum password complexity (M-C).
|
||||
|
||||
Requires at least one uppercase letter, one lowercase letter, one digit,
|
||||
and one special character in addition to the min_length=8 Field constraint.
|
||||
"""
|
||||
if not re.search(r"[A-Z]", v):
|
||||
raise ValueError("Password must contain at least one uppercase letter")
|
||||
if not re.search(r"[a-z]", v):
|
||||
raise ValueError("Password must contain at least one lowercase letter")
|
||||
if not re.search(r"\d", v):
|
||||
raise ValueError("Password must contain at least one digit")
|
||||
if not re.search(r"[^A-Za-z0-9]", v):
|
||||
raise ValueError("Password must contain at least one special character")
|
||||
return v
|
||||
|
||||
|
||||
class GroupBrief(BaseModel):
|
||||
|
|
@ -12,32 +32,50 @@ class GroupBrief(BaseModel):
|
|||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
username: str = Field(..., max_length=150)
|
||||
password: str = Field(..., max_length=256)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
access_token: str | None = None
|
||||
token_type: str = "bearer"
|
||||
user: "UserResponse"
|
||||
user: "UserResponse | None" = None
|
||||
# Set when 2FA is required; the frontend must call /auth/2fa/verify
|
||||
requires_2fa: bool = False
|
||||
pre_auth_token: str | None = None
|
||||
two_fa_methods: list[str] = []
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
password: str | None = None # Optional when advanced auth is enabled
|
||||
email: str | None = None
|
||||
username: str = Field(..., max_length=150)
|
||||
password: str | None = Field(default=None, max_length=256) # M-NEW-4: cap before pbkdf2
|
||||
email: str | None = Field(default=None, max_length=254) # L-NEW-5: RFC 5321 max
|
||||
role: str = "user"
|
||||
group_ids: list[int] | None = None
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, v: str | None) -> str | None:
|
||||
if v is not None:
|
||||
_validate_password_complexity(v)
|
||||
return v
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
email: str | None = None
|
||||
username: str | None = Field(default=None, max_length=150)
|
||||
password: str | None = Field(default=None, max_length=256) # M-NEW-4: cap before pbkdf2
|
||||
email: str | None = Field(default=None, max_length=254) # L-NEW-5: RFC 5321 max
|
||||
role: str | None = None
|
||||
is_active: bool | None = None
|
||||
group_ids: list[int] | None = None
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, v: str | None) -> str | None:
|
||||
if v is not None:
|
||||
_validate_password_complexity(v)
|
||||
return v
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
|
|
@ -56,14 +94,26 @@ class UserResponse(BaseModel):
|
|||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
current_password: str = Field(..., max_length=256) # M-NEW-3: cap before pbkdf2
|
||||
new_password: str = Field(..., min_length=8, max_length=256)
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def validate_new_password(cls, v: str) -> str:
|
||||
return _validate_password_complexity(v)
|
||||
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
auth_enabled: bool
|
||||
admin_username: str | None = None
|
||||
admin_password: str | None = None
|
||||
admin_username: str | None = Field(default=None, max_length=150)
|
||||
admin_password: str | None = Field(default=None, max_length=256)
|
||||
|
||||
@field_validator("admin_password")
|
||||
@classmethod
|
||||
def validate_admin_password(cls, v: str | None) -> str | None:
|
||||
if v is not None:
|
||||
_validate_password_complexity(v)
|
||||
return v
|
||||
|
||||
|
||||
class SetupResponse(BaseModel):
|
||||
|
|
@ -72,7 +122,17 @@ class SetupResponse(BaseModel):
|
|||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: str
|
||||
email: str = Field(..., max_length=254) # L-NEW-1: RFC 5321 max; caps memory/CPU before lookup
|
||||
|
||||
|
||||
class ForgotPasswordConfirmRequest(BaseModel):
|
||||
token: str = Field(..., max_length=128)
|
||||
new_password: str = Field(..., min_length=8, max_length=256)
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def validate_new_password(cls, v: str) -> str:
|
||||
return _validate_password_complexity(v)
|
||||
|
||||
|
||||
class ForgotPasswordResponse(BaseModel):
|
||||
|
|
@ -107,3 +167,271 @@ class TestSMTPRequest(BaseModel):
|
|||
class TestSMTPResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2FA / MFA schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TwoFAStatusResponse(BaseModel):
|
||||
totp_enabled: bool
|
||||
email_otp_enabled: bool
|
||||
backup_codes_remaining: int
|
||||
|
||||
|
||||
class TOTPSetupResponse(BaseModel):
|
||||
"""Returned when a user initiates TOTP setup. The frontend should display
|
||||
the QR code image (base64 PNG) and ask the user to scan it, then call
|
||||
/auth/2fa/totp/enable with a valid code to confirm."""
|
||||
|
||||
secret: str # base32 secret (shown as fallback text)
|
||||
qr_code_b64: str # base64-encoded PNG of the QR code
|
||||
issuer: str
|
||||
|
||||
|
||||
class TOTPSetupRequest(BaseModel):
|
||||
"""Optional body for POST /auth/2fa/totp/setup.
|
||||
|
||||
Only required when re-initialising setup while an active TOTP record exists.
|
||||
Provide the current TOTP code (from the existing authenticator app) to
|
||||
confirm intent — mirrors the verification requirement in disable_totp.
|
||||
"""
|
||||
|
||||
code: str | None = Field(default=None, max_length=8) # L-NEW-2: bound before pyotp
|
||||
|
||||
|
||||
class TOTPEnableRequest(BaseModel):
|
||||
code: str # 6-digit TOTP code from the authenticator app
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v.isdigit() or len(v) != 6:
|
||||
raise ValueError("TOTP code must be exactly 6 digits")
|
||||
return v
|
||||
|
||||
|
||||
class TOTPEnableResponse(BaseModel):
|
||||
message: str
|
||||
backup_codes: list[str] # plain-text codes shown once; user must save them
|
||||
|
||||
|
||||
class TOTPDisableRequest(BaseModel):
|
||||
"""Requires a valid TOTP code OR a backup code to disable TOTP."""
|
||||
|
||||
code: str = Field(..., max_length=128)
|
||||
|
||||
|
||||
class BackupCodesResponse(BaseModel):
|
||||
backup_codes: list[str]
|
||||
message: str
|
||||
|
||||
|
||||
class EmailOTPEnableRequest(BaseModel):
|
||||
"""No body required — email is taken from the authenticated user's profile."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TwoFAVerifyRequest(BaseModel):
|
||||
pre_auth_token: str = Field(..., max_length=128)
|
||||
# TOTP/email codes are 6 digits; backup codes are 8 uppercase alphanumeric chars.
|
||||
# max_length=8 prevents excessively long inputs from reaching pbkdf2/pyotp.
|
||||
code: str = Field(..., min_length=6, max_length=8)
|
||||
method: Literal["totp", "email", "backup"] = "totp"
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def validate_code_format(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r"^[A-Za-z0-9]{6,8}$", v):
|
||||
raise ValueError("Code must be 6–8 alphanumeric characters")
|
||||
return v.upper() # normalise backup codes to uppercase
|
||||
|
||||
|
||||
class TwoFAVerifyResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: "UserResponse"
|
||||
|
||||
|
||||
class EmailOTPSendRequest(BaseModel):
|
||||
pre_auth_token: str = Field(..., max_length=128)
|
||||
|
||||
|
||||
class EmailOTPEnableConfirmRequest(BaseModel):
|
||||
"""Body for the second step of email OTP enable: verify the proof-of-possession code."""
|
||||
|
||||
setup_token: str = Field(..., max_length=128)
|
||||
# L-NEW-3: email OTP setup codes are always exactly 6 digits; reject anything else.
|
||||
code: str = Field(..., min_length=6, max_length=6)
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def validate_code_digits(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v.isdigit() or len(v) != 6:
|
||||
raise ValueError("Email OTP setup code must be exactly 6 digits")
|
||||
return v
|
||||
|
||||
|
||||
class EmailOTPDisableRequest(BaseModel):
|
||||
"""Requires the account password to disable email OTP."""
|
||||
|
||||
password: str = Field(..., max_length=256)
|
||||
|
||||
|
||||
class AdminDisable2FARequest(BaseModel):
|
||||
"""Admin must supply their own password as re-auth before disabling 2FA for another user.
|
||||
|
||||
OIDC/LDAP-only admins (no local password_hash) are exempt from this check.
|
||||
"""
|
||||
|
||||
admin_password: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_icon_url(v: str | None) -> str | None:
|
||||
"""Reject non-HTTPS icon URLs to prevent SSRF / mixed-content issues."""
|
||||
if v is None:
|
||||
return v
|
||||
if not v.startswith("https://"):
|
||||
raise ValueError("icon_url must start with https://")
|
||||
return v
|
||||
|
||||
|
||||
def _validate_issuer_url(v: str | None) -> str | None:
|
||||
"""Nit4: Reject non-HTTPS issuer URLs and private/loopback/link-local hosts.
|
||||
|
||||
HTTP is no longer accepted — OIDC providers must be reachable over TLS.
|
||||
Private-network and loopback addresses are rejected to prevent SSRF attacks
|
||||
where an admin-supplied URL could reach internal services.
|
||||
"""
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if v is None:
|
||||
return v
|
||||
if not v.startswith("https://"):
|
||||
raise ValueError("issuer_url must start with https://")
|
||||
host = urlparse(v).hostname or ""
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
if addr.is_private or addr.is_loopback or addr.is_link_local:
|
||||
raise ValueError("issuer_url must not point to a private, loopback, or link-local address")
|
||||
except ValueError as exc:
|
||||
if "issuer_url" in str(exc):
|
||||
raise
|
||||
# hostname is a domain name, not a bare IP — that's fine
|
||||
return v
|
||||
|
||||
|
||||
def _validate_scopes(v: str | None) -> str | None:
|
||||
"""Nit5: Require that the 'openid' scope is present.
|
||||
|
||||
The OpenID Connect spec mandates the 'openid' scope; without it the
|
||||
response is plain OAuth2, not OIDC, and claims like sub/email are not
|
||||
guaranteed.
|
||||
"""
|
||||
if v is None:
|
||||
return v
|
||||
scope_list = v.split()
|
||||
if "openid" not in scope_list:
|
||||
raise ValueError("scopes must include 'openid'")
|
||||
return v
|
||||
|
||||
|
||||
class OIDCProviderCreate(BaseModel):
|
||||
name: str = Field(..., max_length=100) # L-NEW-4
|
||||
issuer_url: str
|
||||
client_id: str = Field(..., max_length=256) # L-NEW-4
|
||||
client_secret: str = Field(..., max_length=512) # L-NEW-4: Fernet input bounded
|
||||
scopes: str = Field(default="openid email profile", max_length=256) # L-NEW-4
|
||||
is_enabled: bool = True
|
||||
auto_create_users: bool = False
|
||||
auto_link_existing_accounts: bool = False # M-2: conservative default, opt-in only
|
||||
icon_url: str | None = None
|
||||
|
||||
@field_validator("issuer_url")
|
||||
@classmethod
|
||||
def validate_issuer_url(cls, v: str) -> str:
|
||||
result = _validate_issuer_url(v)
|
||||
assert result is not None
|
||||
return result
|
||||
|
||||
@field_validator("scopes")
|
||||
@classmethod
|
||||
def validate_scopes(cls, v: str) -> str:
|
||||
result = _validate_scopes(v)
|
||||
assert result is not None
|
||||
return result
|
||||
|
||||
@field_validator("icon_url")
|
||||
@classmethod
|
||||
def validate_icon_url(cls, v: str | None) -> str | None:
|
||||
return _validate_icon_url(v)
|
||||
|
||||
|
||||
class OIDCProviderUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=100)
|
||||
issuer_url: str | None = None
|
||||
|
||||
@field_validator("issuer_url")
|
||||
@classmethod
|
||||
def validate_issuer_url(cls, v: str | None) -> str | None:
|
||||
return _validate_issuer_url(v)
|
||||
|
||||
client_id: str | None = Field(default=None, max_length=256)
|
||||
client_secret: str | None = Field(default=None, max_length=512)
|
||||
scopes: str | None = Field(default=None, max_length=256)
|
||||
is_enabled: bool | None = None
|
||||
auto_create_users: bool | None = None
|
||||
auto_link_existing_accounts: bool | None = None
|
||||
icon_url: str | None = None
|
||||
|
||||
@field_validator("scopes")
|
||||
@classmethod
|
||||
def validate_scopes(cls, v: str | None) -> str | None:
|
||||
return _validate_scopes(v)
|
||||
|
||||
@field_validator("icon_url")
|
||||
@classmethod
|
||||
def validate_icon_url(cls, v: str | None) -> str | None:
|
||||
return _validate_icon_url(v)
|
||||
|
||||
|
||||
class OIDCProviderResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
issuer_url: str
|
||||
client_id: str
|
||||
scopes: str
|
||||
is_enabled: bool
|
||||
auto_create_users: bool
|
||||
auto_link_existing_accounts: bool = False
|
||||
icon_url: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class OIDCAuthorizeResponse(BaseModel):
|
||||
auth_url: str
|
||||
|
||||
|
||||
class OIDCExchangeRequest(BaseModel):
|
||||
oidc_token: str = Field(..., max_length=128)
|
||||
|
||||
|
||||
class OIDCLinkResponse(BaseModel):
|
||||
id: int
|
||||
provider_id: int
|
||||
provider_name: str
|
||||
provider_email: str | None = None
|
||||
created_at: str
|
||||
|
|
|
|||
|
|
@ -32,8 +32,6 @@ def generate_secure_password(length: int = 16) -> str:
|
|||
Returns:
|
||||
A secure random password containing uppercase, lowercase, digits, and special characters
|
||||
"""
|
||||
import random
|
||||
|
||||
# Define character sets
|
||||
lowercase = string.ascii_lowercase
|
||||
uppercase = string.ascii_uppercase
|
||||
|
|
@ -52,8 +50,8 @@ def generate_secure_password(length: int = 16) -> str:
|
|||
all_chars = lowercase + uppercase + digits + special
|
||||
password_chars.extend(secrets.choice(all_chars) for _ in range(length - 4))
|
||||
|
||||
# Shuffle to avoid predictable patterns
|
||||
random.shuffle(password_chars)
|
||||
# Shuffle with CSPRNG — random.shuffle() is seeded from time and not cryptographically safe
|
||||
secrets.SystemRandom().shuffle(password_chars)
|
||||
|
||||
return "".join(password_chars)
|
||||
|
||||
|
|
@ -381,6 +379,71 @@ BamBuddy Team
|
|||
return subject, text_body, html_body
|
||||
|
||||
|
||||
def create_password_reset_link_email(username: str, reset_url: str) -> tuple[str, str, str]:
|
||||
"""Create a password-reset email that contains a secure link (not a plaintext password)."""
|
||||
subject = "BamBuddy - Password Reset Request"
|
||||
|
||||
text_body = f"""A password reset was requested for your BamBuddy account.
|
||||
|
||||
Username: {username}
|
||||
|
||||
Click the link below to set a new password (valid for 1 hour):
|
||||
{reset_url}
|
||||
|
||||
If you did not request this reset, you can safely ignore this email.
|
||||
|
||||
Best regards,
|
||||
BamBuddy Team
|
||||
"""
|
||||
|
||||
html_body = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 20px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Password Reset Request</h1>
|
||||
</div>
|
||||
<div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
|
||||
<p style="font-size: 16px;">A password reset was requested for your BamBuddy account (<strong>{username}</strong>).</p>
|
||||
<p>Click the button below to set a new password. This link is valid for <strong>1 hour</strong>.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{reset_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reset Password</a>
|
||||
</div>
|
||||
<div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
|
||||
<p style="margin: 0; font-size: 14px; color: #856404;">
|
||||
<strong>Did not request this?</strong> You can safely ignore this email. Your password has not been changed.
|
||||
</p>
|
||||
</div>
|
||||
<p style="font-size: 14px; color: #999; margin-top: 30px;">
|
||||
Best regards,<br>BamBuddy Team
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return subject, text_body, html_body
|
||||
|
||||
|
||||
async def create_password_reset_link_email_from_template(
|
||||
db: AsyncSession, username: str, reset_url: str
|
||||
) -> tuple[str, str, str]:
|
||||
"""Create password-reset link email, using DB template if configured."""
|
||||
template = await get_notification_template(db, "password_reset_link")
|
||||
if template:
|
||||
variables = {"username": username, "reset_url": reset_url}
|
||||
subject = render_template(template.subject or "BamBuddy - Password Reset Request", variables)
|
||||
text_body = render_template(template.body or "", variables)
|
||||
html_body = render_template(template.html_body or "", variables) if template.html_body else None
|
||||
if not html_body:
|
||||
_, text_body, html_body = create_password_reset_link_email(username, reset_url)
|
||||
return subject, text_body, html_body
|
||||
return subject, text_body, html_body
|
||||
return create_password_reset_link_email(username, reset_url)
|
||||
|
||||
|
||||
async def create_welcome_email_from_template(
|
||||
db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
|
||||
) -> tuple[str, str, str]:
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ async def test_engine():
|
|||
ams_label,
|
||||
api_key,
|
||||
archive,
|
||||
auth_ephemeral,
|
||||
color_catalog,
|
||||
external_link,
|
||||
filament,
|
||||
|
|
@ -78,6 +79,7 @@ async def test_engine():
|
|||
maintenance,
|
||||
notification,
|
||||
notification_template,
|
||||
oidc_provider,
|
||||
print_queue,
|
||||
printer,
|
||||
project,
|
||||
|
|
@ -94,6 +96,8 @@ async def test_engine():
|
|||
spoolbuddy_device,
|
||||
user,
|
||||
user_email_pref,
|
||||
user_otp_code,
|
||||
user_totp,
|
||||
virtual_printer,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ SMTP_DATA = {
|
|||
}
|
||||
|
||||
|
||||
async def _setup_admin(async_client: AsyncClient, username: str = "admin", password: str = "adminpass123"):
|
||||
async def _setup_admin(async_client: AsyncClient, username: str = "admin", password: str = "AdminPass1!"):
|
||||
"""Enable auth and create admin user, return admin token."""
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
|
|
@ -47,7 +47,7 @@ async def _setup_smtp_and_advanced_auth(async_client: AsyncClient, token: str):
|
|||
|
||||
|
||||
async def _create_regular_user(
|
||||
async_client: AsyncClient, token: str, username: str = "regular", password: str = "regularpass123"
|
||||
async_client: AsyncClient, token: str, username: str = "regular", password: str = "Regularpass1!"
|
||||
):
|
||||
"""Create a regular (non-admin) user and return their token."""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
|
@ -68,7 +68,7 @@ class TestSMTPConfigAPI:
|
|||
|
||||
@pytest.fixture
|
||||
async def admin_token(self, async_client: AsyncClient):
|
||||
return await _setup_admin(async_client, "smtpadmin", "adminpass123")
|
||||
return await _setup_admin(async_client, "smtpadmin", "AdminPass1!")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
|
|
@ -100,7 +100,7 @@ class TestSMTPConfigAPI:
|
|||
@pytest.mark.integration
|
||||
async def test_smtp_settings_requires_admin(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Non-admin user gets 403 on SMTP endpoints."""
|
||||
user_token = await _create_regular_user(async_client, admin_token, "smtpregular", "pass123456")
|
||||
user_token = await _create_regular_user(async_client, admin_token, "smtpregular", "Pass12345!")
|
||||
headers = {"Authorization": f"Bearer {user_token}"}
|
||||
|
||||
response = await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
|
|
@ -143,7 +143,7 @@ class TestAdvancedAuthToggleAPI:
|
|||
|
||||
@pytest.fixture
|
||||
async def admin_token(self, async_client: AsyncClient):
|
||||
return await _setup_admin(async_client, "toggleadmin", "adminpass123")
|
||||
return await _setup_admin(async_client, "toggleadmin", "AdminPass1!")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
|
|
@ -193,7 +193,7 @@ class TestAdvancedAuthToggleAPI:
|
|||
@pytest.mark.integration
|
||||
async def test_enable_requires_admin(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Non-admin user gets 403 on enable/disable."""
|
||||
user_token = await _create_regular_user(async_client, admin_token, "toggleregular", "pass123456")
|
||||
user_token = await _create_regular_user(async_client, admin_token, "toggleregular", "Pass12345!")
|
||||
headers = {"Authorization": f"Bearer {user_token}"}
|
||||
|
||||
response = await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
|
|
@ -208,7 +208,7 @@ class TestEmailLoginAPI:
|
|||
|
||||
@pytest.fixture
|
||||
async def admin_token(self, async_client: AsyncClient):
|
||||
return await _setup_admin(async_client, "emailadmin", "adminpass123")
|
||||
return await _setup_admin(async_client, "emailadmin", "AdminPass1!")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
|
|
@ -233,13 +233,13 @@ class TestEmailLoginAPI:
|
|||
await async_client.patch(
|
||||
f"/api/v1/users/{user_id}",
|
||||
headers=headers,
|
||||
json={"password": "knownpassword123"},
|
||||
json={"password": "Knownpassword1!"},
|
||||
)
|
||||
|
||||
# Login with email
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "emailuser@test.com", "password": "knownpassword123"},
|
||||
json={"username": "emailuser@test.com", "password": "Knownpassword1!"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
|
|
@ -262,12 +262,12 @@ class TestEmailLoginAPI:
|
|||
await async_client.patch(
|
||||
f"/api/v1/users/{user_id}",
|
||||
headers=headers,
|
||||
json={"password": "casepassword123"},
|
||||
json={"password": "Casepassword1!"},
|
||||
)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "CASEUSER@TEST.COM", "password": "casepassword123"},
|
||||
json={"username": "CASEUSER@TEST.COM", "password": "Casepassword1!"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
|
|
@ -282,13 +282,13 @@ class TestEmailLoginAPI:
|
|||
await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "noemail", "password": "noEmailPass1", "email": "noemail@test.com", "role": "user"},
|
||||
json={"username": "noemail", "password": "NoEmailPass1!", "email": "noemail@test.com", "role": "user"},
|
||||
)
|
||||
|
||||
# Try to login with email — should fail since advanced auth is off
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "noemail@test.com", "password": "noEmailPass1"},
|
||||
json={"username": "noemail@test.com", "password": "NoEmailPass1!"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
|
@ -310,13 +310,13 @@ class TestEmailLoginAPI:
|
|||
await async_client.patch(
|
||||
f"/api/v1/users/{user_id}",
|
||||
headers=headers,
|
||||
json={"password": "usernamepass123"},
|
||||
json={"password": "Usernamepass1!"},
|
||||
)
|
||||
|
||||
# Login with username (not email)
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "usernameuser", "password": "usernamepass123"},
|
||||
json={"username": "usernameuser", "password": "Usernamepass1!"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
|
|
@ -327,7 +327,7 @@ class TestForgotPasswordAPI:
|
|||
|
||||
@pytest.fixture
|
||||
async def admin_token(self, async_client: AsyncClient):
|
||||
return await _setup_admin(async_client, "forgotadmin", "adminpass123")
|
||||
return await _setup_admin(async_client, "forgotadmin", "AdminPass1!")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
|
|
@ -388,7 +388,13 @@ class TestForgotPasswordAPI:
|
|||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_forgot_password_changes_password(self, async_client: AsyncClient, admin_token: str):
|
||||
"""After forgot-password, old password stops working."""
|
||||
"""After forgot-password + confirm, old password stops working and new one works.
|
||||
|
||||
H-6: The flow is now token-based: /forgot-password issues a reset link and
|
||||
/forgot-password/confirm consumes the token and sets the new password.
|
||||
"""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
with patch("backend.app.api.routes.users.send_email"):
|
||||
|
|
@ -403,37 +409,66 @@ class TestForgotPasswordAPI:
|
|||
await async_client.patch(
|
||||
f"/api/v1/users/{user_id}",
|
||||
headers=headers,
|
||||
json={"password": "originalpass123"},
|
||||
json={"password": "Originalpass1!"},
|
||||
)
|
||||
|
||||
# Verify login works with original password
|
||||
login_resp = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "resetme", "password": "originalpass123"},
|
||||
json={"username": "resetme", "password": "Originalpass1!"},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
# Trigger forgot password
|
||||
with patch("backend.app.api.routes.auth.send_email"):
|
||||
await async_client.post(
|
||||
# Trigger forgot-password and capture the reset URL (contains the token)
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
async def _capture_link_email(db, username, reset_url):
|
||||
captured["reset_url"] = reset_url
|
||||
return ("subject", "body", "<body/>")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.auth.create_password_reset_link_email_from_template",
|
||||
side_effect=_capture_link_email,
|
||||
),
|
||||
patch("backend.app.api.routes.auth.send_email"),
|
||||
):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": "resetme@test.com"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "reset_url" in captured, "Reset URL not captured — email function was not called"
|
||||
|
||||
# Extract the token from the captured URL and confirm the reset
|
||||
reset_token = captured["reset_url"].split("reset_token=")[1]
|
||||
confirm_resp = await async_client.post(
|
||||
"/api/v1/auth/forgot-password/confirm",
|
||||
json={"token": reset_token, "new_password": "Newpass456!"},
|
||||
)
|
||||
assert confirm_resp.status_code == 200
|
||||
|
||||
# Old password should no longer work
|
||||
login_resp = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "resetme", "password": "originalpass123"},
|
||||
json={"username": "resetme", "password": "Originalpass1!"},
|
||||
)
|
||||
assert login_resp.status_code == 401
|
||||
|
||||
# New password must work
|
||||
login_resp = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "resetme", "password": "Newpass456!"},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
|
||||
class TestAdminResetPasswordAPI:
|
||||
"""Integration tests for admin password reset endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_token(self, async_client: AsyncClient):
|
||||
return await _setup_admin(async_client, "resetadmin", "adminpass123")
|
||||
return await _setup_admin(async_client, "resetadmin", "AdminPass1!")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
|
|
@ -467,7 +502,7 @@ class TestAdminResetPasswordAPI:
|
|||
async def test_reset_password_requires_admin(self, async_client: AsyncClient, admin_token: str):
|
||||
"""Non-admin user gets 403 on reset-password."""
|
||||
# Create regular user before enabling advanced auth (no email required)
|
||||
user_token = await _create_regular_user(async_client, admin_token, "resetregular", "pass123456")
|
||||
user_token = await _create_regular_user(async_client, admin_token, "resetregular", "Pass12345!")
|
||||
|
||||
with patch("backend.app.api.routes.users.send_email"):
|
||||
await _setup_smtp_and_advanced_auth(async_client, admin_token)
|
||||
|
|
@ -522,7 +557,7 @@ class TestAdminResetPasswordAPI:
|
|||
create_resp = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers=headers,
|
||||
json={"username": "noemailuser", "password": "noemail123456", "role": "user"},
|
||||
json={"username": "noemailuser", "password": "Noemail12345!", "role": "user"},
|
||||
)
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
|
|
@ -543,7 +578,7 @@ class TestUserCreationAdvancedAuth:
|
|||
|
||||
@pytest.fixture
|
||||
async def admin_token(self, async_client: AsyncClient):
|
||||
return await _setup_admin(async_client, "createadmin", "adminpass123")
|
||||
return await _setup_admin(async_client, "createadmin", "AdminPass1!")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
|
|
@ -628,3 +663,52 @@ class TestUserCreationAdvancedAuth:
|
|||
result = response.json()
|
||||
assert "email" in result
|
||||
assert result["email"] == "emailresp@test.com"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# M-1: OIDC/LDAP users must not be able to use the password reset flow
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestAuthSourcePasswordResetBlocking:
|
||||
"""Forgot-password must silently skip OIDC and LDAP users (M-1)."""
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_token(self, async_client: AsyncClient):
|
||||
return await _setup_admin(async_client, "authsrcadmin", "AdminPass1!")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_forgot_password_silently_skips_oidc_user(
|
||||
self, async_client: AsyncClient, admin_token: str, db_session
|
||||
):
|
||||
"""forgot-password for an OIDC user returns 200 but does NOT send email."""
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.user import User
|
||||
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
|
||||
await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
|
||||
|
||||
# Directly insert an OIDC-sourced user into the DB
|
||||
oidc_user = User(
|
||||
username="oidcpwreset",
|
||||
email="oidcpwreset@test.com",
|
||||
auth_source="oidc",
|
||||
password_hash=get_password_hash("irrelevant"),
|
||||
role="user",
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(oidc_user)
|
||||
await db_session.commit()
|
||||
|
||||
with patch("backend.app.api.routes.auth.send_email") as mock_send:
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": "oidcpwreset@test.com"},
|
||||
)
|
||||
|
||||
# Anti-enumeration: still returns 200
|
||||
assert response.status_code == 200
|
||||
# But no email is sent for OIDC users
|
||||
mock_send.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class TestAuthSetupAPI:
|
|||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "testadmin",
|
||||
"admin_password": "testpassword123",
|
||||
"admin_password": "TestPass1!",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -96,14 +96,14 @@ class TestAuthLoginAPI:
|
|||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "logintest",
|
||||
"admin_password": "loginpassword123",
|
||||
"admin_password": "LoginPass1!",
|
||||
},
|
||||
)
|
||||
|
||||
# Now login
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "logintest", "password": "loginpassword123"},
|
||||
json={"username": "logintest", "password": "LoginPass1!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
|
@ -123,7 +123,7 @@ class TestAuthLoginAPI:
|
|||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "invalidtest",
|
||||
"admin_password": "correctpassword",
|
||||
"admin_password": "CorrectPass1!",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -158,13 +158,13 @@ class TestAuthMeAPI:
|
|||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "metest",
|
||||
"admin_password": "mepassword123",
|
||||
"admin_password": "MePass1!",
|
||||
},
|
||||
)
|
||||
|
||||
login_response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "metest", "password": "mepassword123"},
|
||||
json={"username": "metest", "password": "MePass1!"},
|
||||
)
|
||||
token = login_response.json()["access_token"]
|
||||
|
||||
|
|
@ -254,13 +254,13 @@ class TestUsersAPI:
|
|||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "usersadmin",
|
||||
"admin_password": "adminpassword123",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
|
||||
login_response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "usersadmin", "password": "adminpassword123"},
|
||||
json={"username": "usersadmin", "password": "AdminPass1!"},
|
||||
)
|
||||
return login_response.json()["access_token"]
|
||||
|
||||
|
|
@ -274,7 +274,7 @@ class TestUsersAPI:
|
|||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "authreqadmin",
|
||||
"admin_password": "adminpassword123",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -306,7 +306,7 @@ class TestUsersAPI:
|
|||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={
|
||||
"username": "newuser",
|
||||
"password": "newuserpassword",
|
||||
"password": "Newuserpass1!",
|
||||
"role": "user",
|
||||
},
|
||||
)
|
||||
|
|
@ -327,7 +327,7 @@ class TestUsersAPI:
|
|||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={
|
||||
"username": "duplicateuser",
|
||||
"password": "password123",
|
||||
"password": "Password123!",
|
||||
"role": "user",
|
||||
},
|
||||
)
|
||||
|
|
@ -338,7 +338,7 @@ class TestUsersAPI:
|
|||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={
|
||||
"username": "duplicateuser",
|
||||
"password": "password456",
|
||||
"password": "Password456!",
|
||||
"role": "user",
|
||||
},
|
||||
)
|
||||
|
|
@ -356,7 +356,7 @@ class TestUsersAPI:
|
|||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={
|
||||
"username": "updateuser",
|
||||
"password": "password123",
|
||||
"password": "Password123!",
|
||||
"role": "user",
|
||||
},
|
||||
)
|
||||
|
|
@ -382,7 +382,7 @@ class TestUsersAPI:
|
|||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={
|
||||
"username": "deleteuser",
|
||||
"password": "password123",
|
||||
"password": "Password123!",
|
||||
"role": "user",
|
||||
},
|
||||
)
|
||||
|
|
@ -410,14 +410,14 @@ class TestAuthDisableAPI:
|
|||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "disableadmin",
|
||||
"admin_password": "adminpassword123",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
|
||||
# Login to get token
|
||||
login_response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "disableadmin", "password": "adminpassword123"},
|
||||
json={"username": "disableadmin", "password": "AdminPass1!"},
|
||||
)
|
||||
token = login_response.json()["access_token"]
|
||||
|
||||
|
|
@ -446,13 +446,13 @@ class TestGroupsAPI:
|
|||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "groupsadmin",
|
||||
"admin_password": "adminpassword123",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
|
||||
login_response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "groupsadmin", "password": "adminpassword123"},
|
||||
json={"username": "groupsadmin", "password": "AdminPass1!"},
|
||||
)
|
||||
return login_response.json()["access_token"]
|
||||
|
||||
|
|
@ -592,13 +592,13 @@ class TestUserGroupsAPI:
|
|||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "usergroupadmin",
|
||||
"admin_password": "adminpassword123",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
|
||||
login_response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "usergroupadmin", "password": "adminpassword123"},
|
||||
json={"username": "usergroupadmin", "password": "AdminPass1!"},
|
||||
)
|
||||
return login_response.json()["access_token"]
|
||||
|
||||
|
|
@ -619,7 +619,7 @@ class TestUserGroupsAPI:
|
|||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={
|
||||
"username": "groupuser",
|
||||
"password": "password123",
|
||||
"password": "Password123!",
|
||||
"group_ids": [operators_group["id"]],
|
||||
},
|
||||
)
|
||||
|
|
@ -636,7 +636,7 @@ class TestUserGroupsAPI:
|
|||
user_response = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"username": "addtogroup", "password": "password123"},
|
||||
json={"username": "addtogroup", "password": "Password123!"},
|
||||
)
|
||||
user_id = user_response.json()["id"]
|
||||
|
||||
|
|
@ -675,13 +675,13 @@ class TestChangePasswordAPI:
|
|||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "pwchangeadmin",
|
||||
"admin_password": "adminpassword123",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
|
||||
admin_login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "pwchangeadmin", "password": "adminpassword123"},
|
||||
json={"username": "pwchangeadmin", "password": "AdminPass1!"},
|
||||
)
|
||||
admin_token = admin_login.json()["access_token"]
|
||||
|
||||
|
|
@ -689,13 +689,13 @@ class TestChangePasswordAPI:
|
|||
await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"username": "pwchangeuser", "password": "oldpassword123"},
|
||||
json={"username": "pwchangeuser", "password": "Oldpassword123!"},
|
||||
)
|
||||
|
||||
# Login as regular user
|
||||
user_login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "pwchangeuser", "password": "oldpassword123"},
|
||||
json={"username": "pwchangeuser", "password": "Oldpassword123!"},
|
||||
)
|
||||
return user_login.json()["access_token"]
|
||||
|
||||
|
|
@ -707,8 +707,8 @@ class TestChangePasswordAPI:
|
|||
"/api/v1/users/me/change-password",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={
|
||||
"current_password": "oldpassword123",
|
||||
"new_password": "newpassword456",
|
||||
"current_password": "Oldpassword123!",
|
||||
"new_password": "Newpassword456!",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -718,7 +718,7 @@ class TestChangePasswordAPI:
|
|||
# Verify can login with new password
|
||||
login_response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "pwchangeuser", "password": "newpassword456"},
|
||||
json={"username": "pwchangeuser", "password": "Newpassword456!"},
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
|
||||
|
|
@ -731,7 +731,7 @@ class TestChangePasswordAPI:
|
|||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
json={
|
||||
"current_password": "wrongpassword",
|
||||
"new_password": "newpassword456",
|
||||
"new_password": "Newpassword456!",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -746,7 +746,7 @@ class TestChangePasswordAPI:
|
|||
"/api/v1/users/me/change-password",
|
||||
json={
|
||||
"current_password": "oldpassword",
|
||||
"new_password": "newpassword",
|
||||
"new_password": "Strongpass456!",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -768,7 +768,7 @@ class TestAuthMiddlewarePublicRoutes:
|
|||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "middlewareadmin",
|
||||
"admin_password": "adminpassword123",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -786,7 +786,7 @@ class TestAuthMiddlewarePublicRoutes:
|
|||
"""Verify /api/v1/auth/login is accessible without auth."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "middlewareadmin", "password": "adminpassword123"},
|
||||
json={"username": "middlewareadmin", "password": "AdminPass1!"},
|
||||
)
|
||||
# Should not return 401 (unauthorized) - it should either succeed or return
|
||||
# a different error (like 400 for wrong credentials)
|
||||
|
|
@ -826,7 +826,7 @@ class TestAuthMiddlewarePublicRoutes:
|
|||
# Login to get token
|
||||
login_response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "middlewareadmin", "password": "adminpassword123"},
|
||||
json={"username": "middlewareadmin", "password": "AdminPass1!"},
|
||||
)
|
||||
token = login_response.json()["access_token"]
|
||||
|
||||
|
|
@ -863,3 +863,57 @@ class TestAuthMiddlewarePublicRoutes:
|
|||
# Will likely be 400 (advanced auth not enabled) but that's okay -
|
||||
# the important thing is it's not blocked by auth middleware
|
||||
assert response.status_code in [200, 400]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# H-1: Input length validation
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestInputLengthValidation:
|
||||
"""LoginRequest and SetupRequest must reject oversized inputs (H-1)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_password_too_long_rejected(self, async_client: AsyncClient):
|
||||
"""Password exceeding 256 characters must be rejected with 422."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "x" * 257},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_username_too_long_rejected(self, async_client: AsyncClient):
|
||||
"""Username exceeding 150 characters must be rejected with 422."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "u" * 151, "password": "password"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_setup_password_too_long_rejected(self, async_client: AsyncClient):
|
||||
"""SetupRequest admin_password exceeding 256 characters must be rejected with 422."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "admin",
|
||||
"admin_password": "x" * 257,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_password_at_limit_accepted(self, async_client: AsyncClient):
|
||||
"""Password of exactly 256 characters must pass schema validation (may fail auth)."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "x" * 256},
|
||||
)
|
||||
# Schema accepts it; auth may reject with 401 (auth disabled) or 400
|
||||
assert response.status_code != 422
|
||||
|
|
|
|||
130
backend/tests/integration/test_client_ip.py
Normal file
130
backend/tests/integration/test_client_ip.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Unit tests for _get_client_ip (M-R9-A / M-R10-A).
|
||||
|
||||
Covers:
|
||||
- Direct connection without TRUSTED_PROXY_IPS → returns client.host
|
||||
- Trusted proxy with XFF → walks right-to-left, returns first non-proxy IP
|
||||
- Spoofed XFF from an untrusted client → client.host is returned
|
||||
- Multiple trusted proxies in chain → returns leftmost non-proxy entry
|
||||
- All XFF entries are trusted proxies → falls back to leftmost
|
||||
- Empty XFF header with trusted proxy → returns direct_ip
|
||||
- No client (client=None) → returns unique per-request token
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _make_request(client_host: str | None, xff: str = "") -> MagicMock:
|
||||
"""Create a minimal mock Request with given client.host and X-Forwarded-For."""
|
||||
req = MagicMock()
|
||||
if client_host is None:
|
||||
req.client = None
|
||||
else:
|
||||
req.client = MagicMock()
|
||||
req.client.host = client_host
|
||||
req.headers = MagicMock()
|
||||
req.headers.get = lambda key, default="": xff if key == "X-Forwarded-For" else default
|
||||
return req
|
||||
|
||||
|
||||
def _call(request, trusted: frozenset[str]) -> str:
|
||||
from backend.app.api.routes.auth import _get_client_ip
|
||||
|
||||
with patch("backend.app.api.routes.auth._TRUSTED_PROXY_IPS", trusted):
|
||||
return _get_client_ip(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No proxy configured (TRUSTED_PROXY_IPS empty)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_proxy_returns_client_host():
|
||||
req = _make_request("1.2.3.4")
|
||||
assert _call(req, frozenset()) == "1.2.3.4"
|
||||
|
||||
|
||||
def test_no_proxy_xff_ignored():
|
||||
"""XFF must be ignored when TRUSTED_PROXY_IPS is not set."""
|
||||
req = _make_request("1.2.3.4", xff="9.9.9.9")
|
||||
assert _call(req, frozenset()) == "1.2.3.4"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trusted proxy present; direct peer is the proxy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_trusted_proxy_returns_rightmost_non_proxy():
|
||||
"""Single proxy: XFF = client_ip; direct_ip = proxy_ip → return client."""
|
||||
proxy = "10.0.0.1"
|
||||
client = "203.0.113.5"
|
||||
req = _make_request(proxy, xff=client)
|
||||
assert _call(req, frozenset({proxy})) == client
|
||||
|
||||
|
||||
def test_trusted_proxy_chain_skips_proxy_ips():
|
||||
"""Multi-hop: client → proxy1 → proxy2 (direct) → app.
|
||||
XFF = 'client, proxy1'; direct = proxy2. Should return client."""
|
||||
proxy1 = "10.0.0.1"
|
||||
proxy2 = "10.0.0.2"
|
||||
client = "198.51.100.7"
|
||||
req = _make_request(proxy2, xff=f"{client}, {proxy1}")
|
||||
assert _call(req, frozenset({proxy1, proxy2})) == client
|
||||
|
||||
|
||||
def test_all_xff_entries_are_proxies_falls_back_to_leftmost():
|
||||
"""When every XFF entry is a trusted proxy, return the leftmost (original) entry."""
|
||||
proxy1 = "10.0.0.1"
|
||||
proxy2 = "10.0.0.2"
|
||||
req = _make_request(proxy2, xff=f"{proxy1}, {proxy2}")
|
||||
assert _call(req, frozenset({proxy1, proxy2})) == proxy1
|
||||
|
||||
|
||||
def test_empty_xff_with_trusted_proxy_returns_direct_ip():
|
||||
"""Trusted proxy but no XFF header → fall through to direct_ip."""
|
||||
proxy = "10.0.0.1"
|
||||
req = _make_request(proxy, xff="")
|
||||
assert _call(req, frozenset({proxy})) == proxy
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spoofed XFF from an untrusted client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_spoofed_xff_from_untrusted_client_ignored():
|
||||
"""Client not in TRUSTED_PROXY_IPS → XFF is ignored; client.host returned."""
|
||||
untrusted_client = "203.0.113.99"
|
||||
req = _make_request(untrusted_client, xff="1.1.1.1")
|
||||
assert _call(req, frozenset({"10.0.0.1"})) == untrusted_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No client (transport layer provides no address)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_client_returns_unique_token():
|
||||
"""When request.client is None, each call returns a unique rate-limit sentinel."""
|
||||
req1 = _make_request(None)
|
||||
req2 = _make_request(None)
|
||||
ip1 = _call(req1, frozenset())
|
||||
ip2 = _call(req2, frozenset())
|
||||
assert ip1.startswith("__no_ip_")
|
||||
assert ip2.startswith("__no_ip_")
|
||||
assert ip1 != ip2, "Each missing-client request must get a distinct sentinel"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whitespace in XFF values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_xff_with_extra_whitespace_trimmed():
|
||||
"""IPs in XFF with leading/trailing spaces are handled correctly."""
|
||||
proxy = "10.0.0.1"
|
||||
client = "192.0.2.33"
|
||||
req = _make_request(proxy, xff=f" {client} , {proxy} ")
|
||||
assert _call(req, frozenset({proxy})) == client
|
||||
3016
backend/tests/integration/test_mfa_api.py
Normal file
3016
backend/tests/integration/test_mfa_api.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -22,14 +22,14 @@ class TestOwnershipPermissionsSetup:
|
|||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "ownershipadmin",
|
||||
"admin_password": "adminpassword123",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
|
||||
# Login as admin
|
||||
admin_login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "ownershipadmin", "password": "adminpassword123"},
|
||||
json={"username": "ownershipadmin", "password": "AdminPass1!"},
|
||||
)
|
||||
admin_token = admin_login.json()["access_token"]
|
||||
admin_user = admin_login.json()["user"]
|
||||
|
|
@ -49,7 +49,7 @@ class TestOwnershipPermissionsSetup:
|
|||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"username": "operator1",
|
||||
"password": "operatorpass123",
|
||||
"password": "Operatorpass1!",
|
||||
"group_ids": [operators_group["id"]],
|
||||
},
|
||||
)
|
||||
|
|
@ -58,7 +58,7 @@ class TestOwnershipPermissionsSetup:
|
|||
# Login as operator
|
||||
operator_login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "operator1", "password": "operatorpass123"},
|
||||
json={"username": "operator1", "password": "Operatorpass1!"},
|
||||
)
|
||||
operator_token = operator_login.json()["access_token"]
|
||||
|
||||
|
|
@ -68,7 +68,7 @@ class TestOwnershipPermissionsSetup:
|
|||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"username": "operator2",
|
||||
"password": "operatorpass123",
|
||||
"password": "Operatorpass1!",
|
||||
"group_ids": [operators_group["id"]],
|
||||
},
|
||||
)
|
||||
|
|
@ -76,7 +76,7 @@ class TestOwnershipPermissionsSetup:
|
|||
|
||||
operator2_login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "operator2", "password": "operatorpass123"},
|
||||
json={"username": "operator2", "password": "Operatorpass1!"},
|
||||
)
|
||||
operator2_token = operator2_login.json()["access_token"]
|
||||
|
||||
|
|
@ -86,14 +86,14 @@ class TestOwnershipPermissionsSetup:
|
|||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"username": "viewer1",
|
||||
"password": "viewerpass123",
|
||||
"password": "Viewerpass1!",
|
||||
"group_ids": [viewers_group["id"]],
|
||||
},
|
||||
)
|
||||
|
||||
viewer_login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "viewer1", "password": "viewerpass123"},
|
||||
json={"username": "viewer1", "password": "Viewerpass1!"},
|
||||
)
|
||||
viewer_token = viewer_login.json()["access_token"]
|
||||
|
||||
|
|
@ -721,7 +721,7 @@ class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup):
|
|||
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
||||
json={
|
||||
"username": "deletewithitems",
|
||||
"password": "password123",
|
||||
"password": "Password123!",
|
||||
},
|
||||
)
|
||||
user_id = create_response.json()["id"]
|
||||
|
|
|
|||
796
backend/tests/integration/test_security.py
Normal file
796
backend/tests/integration/test_security.py
Normal file
|
|
@ -0,0 +1,796 @@
|
|||
"""Security tests for the 8 coverage gaps identified in the maintainer review.
|
||||
|
||||
Gap 1: encryption.py has zero tests
|
||||
Gap 2: JWT revocation (revoke_jti, is_jti_revoked, _is_token_fresh) untested
|
||||
Gap 3: OIDC exchange token replay untested
|
||||
Gap 4: OIDC email_verified claim handling untested
|
||||
Gap 5: Email OTP max-attempts invalidation untested
|
||||
Gap 6: OIDC callback error redirects (SSRF protection) undertested
|
||||
Gap 7: Login rate limiting untested
|
||||
Gap 8: challenge_id cookie binding untested
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import jwt as pyjwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.auth_ephemeral import AuthEphemeralToken
|
||||
from backend.app.models.user import User
|
||||
|
||||
AUTH_SETUP_URL = "/api/v1/auth/setup"
|
||||
LOGIN_URL = "/api/v1/auth/login"
|
||||
LOGOUT_URL = "/api/v1/auth/logout"
|
||||
ME_URL = "/api/v1/auth/me"
|
||||
|
||||
|
||||
def _auth_header(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _norm_pw(password: str) -> str:
|
||||
"""Ensure password meets complexity requirements (I4: SetupRequest now validates)."""
|
||||
if not any(c.isupper() for c in password):
|
||||
password = password[0].upper() + password[1:]
|
||||
if not any(c not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" for c in password):
|
||||
password = password + "!"
|
||||
return password
|
||||
|
||||
|
||||
async def _setup_and_login(client: AsyncClient, username: str, password: str) -> str:
|
||||
password = _norm_pw(password)
|
||||
await client.post(
|
||||
AUTH_SETUP_URL,
|
||||
json={"auth_enabled": True, "admin_username": username, "admin_password": password},
|
||||
)
|
||||
resp = await client.post(LOGIN_URL, json={"username": username, "password": password})
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def _make_test_rsa_key():
|
||||
def _b64url(n: int, length: int) -> str:
|
||||
return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_pem = private_key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
pub_numbers = private_key.public_key().public_numbers()
|
||||
jwks = {
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": "test-kid-1",
|
||||
"n": _b64url(pub_numbers.n, 256),
|
||||
"e": _b64url(pub_numbers.e, 3),
|
||||
}
|
||||
]
|
||||
}
|
||||
return private_pem, jwks
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Gap 1: encryption.py unit tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestEncryption:
|
||||
"""encrypt/decrypt round-trips, plaintext passthrough, RuntimeError on missing key."""
|
||||
|
||||
def test_encrypt_decrypt_roundtrip_with_key(self):
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
test_key = Fernet.generate_key().decode()
|
||||
|
||||
import backend.app.core.encryption as enc_mod
|
||||
|
||||
original = enc_mod._fernet_instance
|
||||
original_warn = enc_mod._warn_shown
|
||||
try:
|
||||
enc_mod._fernet_instance = None
|
||||
enc_mod._warn_shown = False
|
||||
with patch.dict("os.environ", {"MFA_ENCRYPTION_KEY": test_key}):
|
||||
ciphertext = enc_mod.mfa_encrypt("my-totp-secret")
|
||||
assert ciphertext.startswith("fernet:")
|
||||
assert enc_mod.mfa_decrypt(ciphertext) == "my-totp-secret"
|
||||
finally:
|
||||
enc_mod._fernet_instance = original
|
||||
enc_mod._warn_shown = original_warn
|
||||
|
||||
def test_plaintext_passthrough_without_key(self):
|
||||
import backend.app.core.encryption as enc_mod
|
||||
|
||||
original = enc_mod._fernet_instance
|
||||
original_warn = enc_mod._warn_shown
|
||||
try:
|
||||
enc_mod._fernet_instance = None
|
||||
enc_mod._warn_shown = False
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
env = {k: v for k, v in __import__("os").environ.items() if k != "MFA_ENCRYPTION_KEY"}
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
result = enc_mod.mfa_encrypt("plaintext-secret")
|
||||
assert result == "plaintext-secret"
|
||||
assert enc_mod.mfa_decrypt("plaintext-secret") == "plaintext-secret"
|
||||
finally:
|
||||
enc_mod._fernet_instance = original
|
||||
enc_mod._warn_shown = original_warn
|
||||
|
||||
def test_decrypt_raises_runtime_error_without_key_for_encrypted_value(self):
|
||||
import backend.app.core.encryption as enc_mod
|
||||
|
||||
original = enc_mod._fernet_instance
|
||||
original_warn = enc_mod._warn_shown
|
||||
try:
|
||||
enc_mod._fernet_instance = None
|
||||
enc_mod._warn_shown = False
|
||||
# A value with the fernet: prefix but no key configured
|
||||
env = {k: v for k, v in __import__("os").environ.items() if k != "MFA_ENCRYPTION_KEY"}
|
||||
with (
|
||||
patch.dict("os.environ", env, clear=True),
|
||||
pytest.raises(RuntimeError, match="MFA_ENCRYPTION_KEY must be set"),
|
||||
):
|
||||
enc_mod.mfa_decrypt("fernet:gAAAAA-fake-ciphertext")
|
||||
finally:
|
||||
enc_mod._fernet_instance = original
|
||||
enc_mod._warn_shown = original_warn
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Gap 2: JWT revocation — revoke_jti, is_jti_revoked, _is_token_fresh, /me
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestJWTRevocation:
|
||||
"""JWT revocation and token freshness checks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_revoke_jti_and_is_jti_revoked(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
"""revoke_jti stores the JTI; is_jti_revoked returns True afterwards."""
|
||||
from backend.app.core.auth import is_jti_revoked, revoke_jti
|
||||
|
||||
test_jti = secrets.token_urlsafe(16)
|
||||
expires = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
|
||||
assert not await is_jti_revoked(test_jti)
|
||||
await revoke_jti(test_jti, expires, username="testuser")
|
||||
assert await is_jti_revoked(test_jti)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_revoke_jti_idempotent(self, async_client: AsyncClient):
|
||||
"""Double-revocation of the same JTI should not raise."""
|
||||
from backend.app.core.auth import is_jti_revoked, revoke_jti
|
||||
|
||||
jti = secrets.token_urlsafe(16)
|
||||
expires = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
await revoke_jti(jti, expires)
|
||||
await revoke_jti(jti, expires) # must not raise
|
||||
assert await is_jti_revoked(jti)
|
||||
|
||||
def test_is_token_fresh_rejects_none_iat(self):
|
||||
"""_is_token_fresh returns False when iat is None (I1 hard cutoff)."""
|
||||
from backend.app.core.auth import _is_token_fresh
|
||||
|
||||
user = MagicMock()
|
||||
user.password_changed_at = None
|
||||
assert _is_token_fresh(None, user) is False
|
||||
|
||||
def test_is_token_fresh_rejects_token_before_password_change(self):
|
||||
"""_is_token_fresh returns False when iat predates password_changed_at."""
|
||||
from backend.app.core.auth import _is_token_fresh
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
user = MagicMock()
|
||||
user.password_changed_at = now
|
||||
old_iat = (now - timedelta(hours=1)).timestamp()
|
||||
assert _is_token_fresh(old_iat, user) is False
|
||||
|
||||
def test_is_token_fresh_accepts_token_after_password_change(self):
|
||||
"""_is_token_fresh returns True when iat is after password_changed_at."""
|
||||
from backend.app.core.auth import _is_token_fresh
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
user = MagicMock()
|
||||
user.password_changed_at = now - timedelta(hours=1)
|
||||
recent_iat = now.timestamp()
|
||||
assert _is_token_fresh(recent_iat, user) is True
|
||||
|
||||
def test_is_token_fresh_returns_true_when_no_password_change(self):
|
||||
"""_is_token_fresh returns True when password_changed_at is None (I2 migration not yet run)."""
|
||||
from backend.app.core.auth import _is_token_fresh
|
||||
|
||||
user = MagicMock()
|
||||
user.password_changed_at = None
|
||||
assert _is_token_fresh(time.time(), user) is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_me_endpoint_rejects_token_after_logout(self, async_client: AsyncClient):
|
||||
"""After logout, the bearer token must be rejected by /me (B1 + revocation)."""
|
||||
token = await _setup_and_login(async_client, "sec_logout_me", "sec_logout_me1")
|
||||
|
||||
# Token works before logout
|
||||
me_resp = await async_client.get(ME_URL, headers=_auth_header(token))
|
||||
assert me_resp.status_code == 200
|
||||
|
||||
# Logout
|
||||
logout_resp = await async_client.post(LOGOUT_URL, headers=_auth_header(token))
|
||||
assert logout_resp.status_code == 200
|
||||
|
||||
# Token must now be rejected
|
||||
me_after = await async_client.get(ME_URL, headers=_auth_header(token))
|
||||
assert me_after.status_code == 401
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Gap 3: OIDC exchange token replay
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestOIDCExchangeReplay:
|
||||
"""A single-use OIDC exchange token cannot be redeemed twice."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_exchange_token_is_single_use(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
"""The second call to /oidc/exchange with the same token returns 401."""
|
||||
exchange_token = secrets.token_urlsafe(32)
|
||||
db_session.add(
|
||||
AuthEphemeralToken(
|
||||
token=exchange_token,
|
||||
token_type="oidc_exchange",
|
||||
username="oidc_replay_user",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
# Seed the user so the exchange can resolve it
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.core.database import async_session, seed_default_groups
|
||||
|
||||
async with async_session() as db:
|
||||
result = await db.execute(__import__("sqlalchemy").select(User).where(User.username == "oidc_replay_user"))
|
||||
if result.scalar_one_or_none() is None:
|
||||
db.add(
|
||||
User(
|
||||
username="oidc_replay_user",
|
||||
password_hash=get_password_hash("pw"),
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
first = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": exchange_token})
|
||||
assert first.status_code == 200
|
||||
|
||||
second = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": exchange_token})
|
||||
assert second.status_code == 401
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Gap 4: OIDC email_verified claim handling
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestOIDCEmailVerified:
|
||||
"""email_verified: False/absent must not link OIDC identity to an existing email."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_unverified_email_does_not_link_to_existing_user(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
"""If email_verified is False, the OIDC callback must not auto-link by email."""
|
||||
private_pem, jwks_data = _make_test_rsa_key()
|
||||
issuer = "https://idp.evtest.example.com"
|
||||
client_id = "ev-client"
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
now = int(time.time())
|
||||
|
||||
id_token = pyjwt.encode(
|
||||
{
|
||||
"sub": "ev-sub-new",
|
||||
"iss": issuer,
|
||||
"aud": client_id,
|
||||
"nonce": nonce,
|
||||
"email": "existing@example.com",
|
||||
"email_verified": False, # <-- must be ignored
|
||||
"iat": now,
|
||||
"exp": now + 300,
|
||||
},
|
||||
private_pem,
|
||||
algorithm="RS256",
|
||||
headers={"kid": "test-kid-1"},
|
||||
)
|
||||
|
||||
admin_token = await _setup_and_login(async_client, "ev_admin", "ev_admin1")
|
||||
|
||||
# Create existing user with the same email (use strong password for validator)
|
||||
create_user_resp = await async_client.post(
|
||||
"/api/v1/users",
|
||||
json={"username": "existing_email_user", "password": "Str0ng!Pass", "email": "existing@example.com"},
|
||||
headers=_auth_header(admin_token),
|
||||
)
|
||||
assert create_user_resp.status_code in (200, 201), create_user_resp.json()
|
||||
|
||||
# Create OIDC provider
|
||||
create_resp = await async_client.post(
|
||||
"/api/v1/auth/oidc/providers",
|
||||
json={
|
||||
"name": "EV-IdP",
|
||||
"issuer_url": issuer,
|
||||
"client_id": client_id,
|
||||
"client_secret": "secret",
|
||||
"scopes": "openid email",
|
||||
"is_enabled": True,
|
||||
"auto_create_users": True,
|
||||
},
|
||||
headers=_auth_header(admin_token),
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
provider_id = create_resp.json()["id"]
|
||||
|
||||
state = secrets.token_urlsafe(32)
|
||||
code_verifier = secrets.token_urlsafe(48)
|
||||
db_session.add(
|
||||
AuthEphemeralToken(
|
||||
token=state,
|
||||
token_type="oidc_state",
|
||||
provider_id=provider_id,
|
||||
nonce=nonce,
|
||||
code_verifier=code_verifier,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
discovery_doc = {
|
||||
"issuer": issuer,
|
||||
"authorization_endpoint": f"{issuer}/auth",
|
||||
"token_endpoint": f"{issuer}/token",
|
||||
"jwks_uri": f"{issuer}/.well-known/jwks.json",
|
||||
}
|
||||
|
||||
class _MockResp:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
self.status_code = 200
|
||||
self.is_success = True
|
||||
self.text = str(data)
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
class _MockHttpxClientEV:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
async def get(self, url, **kwargs):
|
||||
if "jwks" in url:
|
||||
return _MockResp(jwks_data)
|
||||
return _MockResp(discovery_doc)
|
||||
|
||||
async def post(self, url, **kwargs):
|
||||
return _MockResp({"access_token": "mock", "token_type": "Bearer", "id_token": id_token})
|
||||
|
||||
with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClientEV):
|
||||
await async_client.get(
|
||||
f"/api/v1/auth/oidc/callback?code=test-code&state={state}",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# Callback must NOT link to the existing_email_user — a new user is created
|
||||
# instead (because the email claim was ignored due to email_verified=False).
|
||||
# Either a new user is provisioned (redirect with oidc_token) or the callback
|
||||
# fails. In either case, the existing user must not have an OIDC link.
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from backend.app.models.oidc_provider import UserOIDCLink
|
||||
|
||||
link_result = await db_session.execute(
|
||||
sa_select(UserOIDCLink)
|
||||
.join(User, UserOIDCLink.user_id == User.id)
|
||||
.where(User.email == "existing@example.com")
|
||||
)
|
||||
link = link_result.scalar_one_or_none()
|
||||
assert link is None, "Existing user must not be auto-linked when email_verified is False"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Gap 5: Email OTP max-attempts invalidation
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestEmailOTPMaxAttempts:
|
||||
"""After MAX_ATTEMPTS wrong codes, the OTP is permanently invalidated."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_email_otp_invalidated_after_max_attempts(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from backend.app.models.user_otp_code import UserOTPCode
|
||||
|
||||
_pwd_ctx = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
admin_token = await _setup_and_login(async_client, "otp_max_admin", "otp_max_admin1")
|
||||
|
||||
# Enable email OTP for admin user
|
||||
result = await db_session.execute(sa_select(User).where(User.username == "otp_max_admin"))
|
||||
user = result.scalar_one()
|
||||
user.email = "otpmax@example.com"
|
||||
await db_session.commit()
|
||||
|
||||
setup_code = "123456"
|
||||
from backend.app.models.auth_ephemeral import AuthEphemeralToken as AET
|
||||
|
||||
setup_token = secrets.token_urlsafe(32)
|
||||
db_session.add(
|
||||
AET(
|
||||
token=setup_token,
|
||||
token_type="email_otp_setup",
|
||||
username="otp_max_admin",
|
||||
nonce=_pwd_ctx.hash(setup_code),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
await async_client.post(
|
||||
"/api/v1/auth/2fa/email/enable/confirm",
|
||||
json={"setup_token": setup_token, "code": setup_code},
|
||||
headers=_auth_header(admin_token),
|
||||
)
|
||||
|
||||
# Login to get pre_auth_token
|
||||
login_resp = await async_client.post(
|
||||
LOGIN_URL, json={"username": "otp_max_admin", "password": "Otp_max_admin1"}
|
||||
)
|
||||
pre_auth_token = login_resp.json()["pre_auth_token"]
|
||||
|
||||
# Insert an OTP record directly (bypassing SMTP)
|
||||
real_code = "654321"
|
||||
otp = UserOTPCode(
|
||||
user_id=user.id,
|
||||
code_hash=_pwd_ctx.hash(real_code),
|
||||
attempts=0,
|
||||
used=False,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
|
||||
)
|
||||
db_session.add(otp)
|
||||
await db_session.commit()
|
||||
|
||||
# Submit MAX_ATTEMPTS wrong codes
|
||||
from backend.app.api.routes.mfa import MAX_2FA_ATTEMPTS
|
||||
|
||||
for _ in range(MAX_2FA_ATTEMPTS):
|
||||
r = await async_client.post(
|
||||
"/api/v1/auth/2fa/verify",
|
||||
json={"pre_auth_token": pre_auth_token, "code": "000000", "method": "email"},
|
||||
)
|
||||
# Each attempt must fail with 401
|
||||
assert r.status_code == 401
|
||||
|
||||
# After max attempts, the correct code is also rejected (either OTP
|
||||
# invalidated → 401, or rate limit hit → 429). Either means locked out.
|
||||
final = await async_client.post(
|
||||
"/api/v1/auth/2fa/verify",
|
||||
json={"pre_auth_token": pre_auth_token, "code": real_code, "method": "email"},
|
||||
)
|
||||
assert final.status_code in (401, 429), f"Expected lockout, got {final.status_code}: {final.json()}"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Gap 6: OIDC callback SSRF protection — invalid authorization_endpoint scheme
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestOIDCSSRFProtection:
|
||||
"""authorization_endpoint with non-http(s) scheme must be rejected."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_invalid_authorization_endpoint_scheme_rejected(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
issuer = "https://idp.ssrf.example.com"
|
||||
client_id = "ssrf-client"
|
||||
|
||||
admin_token = await _setup_and_login(async_client, "ssrf_admin", "ssrf_admin1")
|
||||
create_resp = await async_client.post(
|
||||
"/api/v1/auth/oidc/providers",
|
||||
json={
|
||||
"name": "SSRF-IdP",
|
||||
"issuer_url": issuer,
|
||||
"client_id": client_id,
|
||||
"client_secret": "secret",
|
||||
"scopes": "openid",
|
||||
"is_enabled": True,
|
||||
"auto_create_users": False,
|
||||
},
|
||||
headers=_auth_header(admin_token),
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
provider_id = create_resp.json()["id"]
|
||||
|
||||
# Discovery doc returns a javascript: authorization_endpoint
|
||||
malicious_discovery = {
|
||||
"issuer": issuer,
|
||||
"authorization_endpoint": "javascript:alert(1)", # <-- malicious
|
||||
"token_endpoint": f"{issuer}/token",
|
||||
"jwks_uri": f"{issuer}/.well-known/jwks.json",
|
||||
}
|
||||
|
||||
class _MockResp:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
self.status_code = 200
|
||||
self.is_success = True
|
||||
self.text = str(data)
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
class _MockHttpxClientSSRF:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
async def get(self, url, **kwargs):
|
||||
return _MockResp(malicious_discovery)
|
||||
|
||||
async def post(self, url, **kwargs):
|
||||
return _MockResp({})
|
||||
|
||||
with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClientSSRF):
|
||||
# oidc_authorize uses a path parameter, not query param
|
||||
authorize_resp = await async_client.get(
|
||||
f"/api/v1/auth/oidc/authorize/{provider_id}",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# Must be rejected with 502 — B2 guard rejects invalid authorization_endpoint scheme
|
||||
assert authorize_resp.status_code == 502, authorize_resp.json()
|
||||
detail = authorize_resp.json().get("detail", "").lower()
|
||||
assert "authorization_endpoint" in detail or "invalid" in detail
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Gap 7: Login rate limiting
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestLoginRateLimiting:
|
||||
"""10+ failed logins for the same username must return 429."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_excessive_failed_logins_return_429(self, async_client: AsyncClient):
|
||||
from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS
|
||||
|
||||
# Setup auth but do NOT log in
|
||||
await async_client.post(
|
||||
AUTH_SETUP_URL,
|
||||
json={"auth_enabled": True, "admin_username": "ratelimit_user", "admin_password": "Ratelimit_pw1"},
|
||||
)
|
||||
|
||||
status_codes = []
|
||||
for _ in range(MAX_LOGIN_ATTEMPTS + 2):
|
||||
resp = await async_client.post(
|
||||
LOGIN_URL,
|
||||
json={"username": "ratelimit_user", "password": "wrong_password"},
|
||||
)
|
||||
status_codes.append(resp.status_code)
|
||||
|
||||
# The last attempts must be 429 (Too Many Requests)
|
||||
assert status_codes[-1] == 429, f"Expected 429 after {MAX_LOGIN_ATTEMPTS} failures, got: {status_codes}"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Gap 8: challenge_id cookie binding
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestChallengeIdCookieBinding:
|
||||
"""A pre-auth token stolen from session A cannot be used from session B."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_pre_auth_token_rejected_without_matching_cookie(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession
|
||||
):
|
||||
import pyotp
|
||||
from passlib.context import CryptContext
|
||||
|
||||
_pwd_ctx = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
# Set up user with TOTP
|
||||
await _setup_and_login(async_client, "cookie_bind_user", "cookie_bind_pw1")
|
||||
|
||||
secret = pyotp.random_base32()
|
||||
totp_obj = pyotp.TOTP(secret)
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from backend.app.models.user_totp import UserTOTP
|
||||
|
||||
result = await db_session.execute(sa_select(User).where(User.username == "cookie_bind_user"))
|
||||
user = result.scalar_one()
|
||||
db_session.add(UserTOTP(user_id=user.id, secret=secret, is_enabled=True))
|
||||
await db_session.commit()
|
||||
|
||||
# Login from "session A" — gets a pre_auth_token and a 2fa_challenge cookie
|
||||
login_resp = await async_client.post(
|
||||
LOGIN_URL, json={"username": "cookie_bind_user", "password": "Cookie_bind_pw1"}
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
assert login_resp.json()["requires_2fa"] is True
|
||||
pre_auth_token = login_resp.json()["pre_auth_token"]
|
||||
# The async_client jar now holds the 2fa_challenge cookie for session A
|
||||
|
||||
# Simulate session B by creating a new client WITHOUT the cookie
|
||||
from httpx import ASGITransport, AsyncClient as FreshClient
|
||||
|
||||
from backend.app.main import app
|
||||
|
||||
async with FreshClient(transport=ASGITransport(app=app), base_url="http://test") as session_b:
|
||||
# Attempt to use session A's pre_auth_token from session B (no cookie)
|
||||
verify_resp = await session_b.post(
|
||||
"/api/v1/auth/2fa/verify",
|
||||
json={
|
||||
"pre_auth_token": pre_auth_token,
|
||||
"code": totp_obj.now(),
|
||||
"method": "totp",
|
||||
},
|
||||
)
|
||||
# Must be rejected — pre_auth_token is bound to session A's cookie
|
||||
assert verify_resp.status_code == 401, (
|
||||
f"Expected 401 for token replay from cookieless session, got {verify_resp.status_code}: "
|
||||
f"{verify_resp.json()}"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# C2: Security-header middleware
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSecurityHeaders:
|
||||
"""Every HTTP response must include standard security headers (C2)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_security_headers_present(self, async_client: AsyncClient):
|
||||
"""GET /api/v1/auth/me (unauthenticated → 401) still carries security headers."""
|
||||
resp = await async_client.get(ME_URL)
|
||||
assert resp.status_code == 401 # sanity — no auth token
|
||||
|
||||
assert resp.headers.get("x-content-type-options") == "nosniff"
|
||||
assert resp.headers.get("x-frame-options") == "SAMEORIGIN"
|
||||
assert resp.headers.get("referrer-policy") == "strict-origin-when-cross-origin"
|
||||
|
||||
csp = resp.headers.get("content-security-policy", "")
|
||||
assert "default-src 'self'" in csp
|
||||
assert "script-src 'self'" in csp
|
||||
assert "frame-ancestors 'none'" in csp
|
||||
assert "object-src 'none'" in csp
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_hsts_absent_for_http(self, async_client: AsyncClient):
|
||||
"""HSTS must NOT be set over plain HTTP (test transport uses http)."""
|
||||
resp = await async_client.get(ME_URL)
|
||||
assert "strict-transport-security" not in resp.headers
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# I3: Rate-limit bucket interaction — IP spray vs. username spray
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestRateLimitBuckets:
|
||||
"""IP-spray and username-spray must each trip the correct independent bucket."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_ip_spray_trips_ip_bucket(self, async_client: AsyncClient):
|
||||
"""20 failed logins from one IP across 20 different usernames trips the IP bucket.
|
||||
|
||||
Each per-username bucket only has 1 failure (well below MAX_LOGIN_ATTEMPTS=10),
|
||||
so the username bucket is never the reason for the 429.
|
||||
"""
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
unique_ip = "10.99.1.1"
|
||||
|
||||
# Ensure auth is enabled
|
||||
await async_client.post(
|
||||
AUTH_SETUP_URL,
|
||||
json={"auth_enabled": True, "admin_username": "spray_ip_admin", "admin_password": "SprayIp_admin1"},
|
||||
)
|
||||
|
||||
status_codes: list[int] = []
|
||||
with _patch("backend.app.api.routes.auth._get_client_ip", return_value=unique_ip):
|
||||
for i in range(22):
|
||||
resp = await async_client.post(
|
||||
LOGIN_URL,
|
||||
json={"username": f"spray_ip_victim_{i}", "password": "wrong"},
|
||||
)
|
||||
status_codes.append(resp.status_code)
|
||||
|
||||
# The first 20 attempts fail with 401; the 21st+ must be 429 (IP bucket full)
|
||||
assert status_codes[-1] == 429, f"Expected 429 after 20 IP-spray failures, got: {status_codes}"
|
||||
# No single username saw more than one attempt → username buckets not tripped
|
||||
non_429 = [c for c in status_codes[:-2] if c == 429]
|
||||
assert not non_429, f"Username bucket triggered early: {status_codes}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_username_spray_trips_username_bucket(self, async_client: AsyncClient):
|
||||
"""One username targeted from 10+ different IPs trips the username bucket.
|
||||
|
||||
Each per-IP bucket only sees 1 failure, so no IP bucket is tripped.
|
||||
The username bucket (max 10) is what fires the 429.
|
||||
"""
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS
|
||||
|
||||
# Ensure auth is enabled
|
||||
await async_client.post(
|
||||
AUTH_SETUP_URL,
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "spray_uname_admin",
|
||||
"admin_password": "SprayUname_admin1",
|
||||
},
|
||||
)
|
||||
|
||||
target_username = "spray_uname_victim"
|
||||
status_codes: list[int] = []
|
||||
for i in range(MAX_LOGIN_ATTEMPTS + 2):
|
||||
rotating_ip = f"10.99.2.{i + 1}"
|
||||
with _patch("backend.app.api.routes.auth._get_client_ip", return_value=rotating_ip):
|
||||
resp = await async_client.post(
|
||||
LOGIN_URL,
|
||||
json={"username": target_username, "password": "wrong"},
|
||||
)
|
||||
status_codes.append(resp.status_code)
|
||||
|
||||
# After MAX_LOGIN_ATTEMPTS failures for same username the bucket fires
|
||||
assert status_codes[-1] == 429, (
|
||||
f"Expected 429 after {MAX_LOGIN_ATTEMPTS} username-spray failures, got: {status_codes}"
|
||||
)
|
||||
49
backend/tests/unit/test_mfa_helpers.py
Normal file
49
backend/tests/unit/test_mfa_helpers.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Unit tests for 2FA helper functions in mfa.py."""
|
||||
|
||||
import base64
|
||||
import string
|
||||
|
||||
import pytest
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from backend.app.api.routes.mfa import _generate_backup_codes, _generate_totp_qr_b64
|
||||
|
||||
|
||||
class TestBackupCodeGeneration:
|
||||
"""Tests for backup code helpers."""
|
||||
|
||||
def test_generates_ten_codes(self):
|
||||
plain, hashed = _generate_backup_codes()
|
||||
assert len(plain) == 10
|
||||
assert len(hashed) == 10
|
||||
|
||||
def test_codes_are_eight_chars(self):
|
||||
plain, _ = _generate_backup_codes()
|
||||
for code in plain:
|
||||
assert len(code) == 8
|
||||
|
||||
def test_codes_are_alphanumeric(self):
|
||||
allowed = set(string.ascii_uppercase + string.digits)
|
||||
plain, _ = _generate_backup_codes()
|
||||
for code in plain:
|
||||
assert all(c in allowed for c in code)
|
||||
|
||||
def test_hashes_verify_against_plain(self):
|
||||
ctx = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
plain, hashed = _generate_backup_codes()
|
||||
for p, h in zip(plain, hashed, strict=True):
|
||||
assert ctx.verify(p, h)
|
||||
|
||||
def test_codes_are_unique(self):
|
||||
plain, _ = _generate_backup_codes()
|
||||
assert len(set(plain)) == 10
|
||||
|
||||
|
||||
class TestTOTPQRCode:
|
||||
"""Tests for QR code generation helper."""
|
||||
|
||||
def test_generates_base64_png(self):
|
||||
uri = "otpauth://totp/Bambuddy:testuser?secret=BASE32SECRET&issuer=Bambuddy"
|
||||
result = _generate_totp_qr_b64(uri)
|
||||
decoded = base64.b64decode(result)
|
||||
assert decoded[:4] == b"\x89PNG"
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<!-- L-4: Restrict Referer header to origin-only on cross-origin navigation so
|
||||
sensitive tokens in query parameters are not leaked to third-party servers. -->
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
<title>Bambuddy</title>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
|
|
|
|||
|
|
@ -7,23 +7,23 @@ import { http, HttpResponse } from 'msw';
|
|||
import { setupServer } from 'msw/node';
|
||||
import { setAuthToken, getAuthToken, api } from '../../api/client';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
// Mock sessionStorage (H-5: tokens are stored in sessionStorage, not localStorage)
|
||||
const sessionStorageMock = {
|
||||
store: {} as Record<string, string>,
|
||||
getItem: vi.fn((key: string) => localStorageMock.store[key] || null),
|
||||
getItem: vi.fn((key: string) => sessionStorageMock.store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
localStorageMock.store[key] = value;
|
||||
sessionStorageMock.store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete localStorageMock.store[key];
|
||||
delete sessionStorageMock.store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
localStorageMock.store = {};
|
||||
sessionStorageMock.store = {};
|
||||
}),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: sessionStorageMock,
|
||||
});
|
||||
|
||||
// Create MSW server
|
||||
|
|
@ -32,22 +32,22 @@ const server = setupServer();
|
|||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
localStorageMock.clear();
|
||||
sessionStorageMock.clear();
|
||||
setAuthToken(null);
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe('Auth Token Management', () => {
|
||||
it('setAuthToken stores token in localStorage', () => {
|
||||
it('setAuthToken stores token in sessionStorage', () => {
|
||||
setAuthToken('test-token-123');
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'test-token-123');
|
||||
expect(sessionStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'test-token-123');
|
||||
expect(getAuthToken()).toBe('test-token-123');
|
||||
});
|
||||
|
||||
it('setAuthToken removes token from localStorage when null', () => {
|
||||
it('setAuthToken removes token from sessionStorage when null', () => {
|
||||
setAuthToken('test-token-123');
|
||||
setAuthToken(null);
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||
expect(getAuthToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -115,7 +115,7 @@ describe('API Client Auth Header', () => {
|
|||
}
|
||||
|
||||
expect(getAuthToken()).toBeNull();
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||
});
|
||||
|
||||
it('does not clear token on 401 with generic auth error', async () => {
|
||||
|
|
|
|||
|
|
@ -159,4 +159,145 @@ describe('LoginPage', () => {
|
|||
resolveLogin!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('2FA flow', () => {
|
||||
// Helper: login as a 2FA user and get to the 2FA step
|
||||
async function loginWith2FA(twoFAMethods = ['totp', 'backup']) {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
http.post('/api/v1/auth/login', () =>
|
||||
HttpResponse.json({
|
||||
requires_2fa: true,
|
||||
pre_auth_token: 'test-pre-auth-token',
|
||||
two_fa_methods: twoFAMethods,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByLabelText(/Username/i), 'mfa-user');
|
||||
await user.type(screen.getByLabelText(/Password/i), 'mfa-password');
|
||||
await user.click(screen.getByRole('button', { name: /Sign in/i }));
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
it('shows 2FA step when login returns requires_2fa', async () => {
|
||||
await loginWith2FA();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows code input on the 2FA step', async () => {
|
||||
await loginWith2FA();
|
||||
|
||||
await waitFor(() => {
|
||||
// The code input field is rendered
|
||||
expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits 2FA verify request with code and pre_auth_token', async () => {
|
||||
let verifyCalled = false;
|
||||
let verifyBody: unknown;
|
||||
|
||||
server.use(
|
||||
http.post('/api/v1/auth/2fa/verify', async ({ request }) => {
|
||||
verifyCalled = true;
|
||||
verifyBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
access_token: 'final-jwt',
|
||||
token_type: 'bearer',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'mfa-user',
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const user = await loginWith2FA();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
|
||||
await user.click(screen.getByRole('button', { name: /Verify/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(verifyCalled).toBe(true);
|
||||
});
|
||||
|
||||
expect(verifyBody).toMatchObject({
|
||||
pre_auth_token: 'test-pre-auth-token',
|
||||
code: '123456',
|
||||
method: 'totp',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns to credentials step when back button is clicked', async () => {
|
||||
await loginWith2FA();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
const backButton = screen.getByRole('button', { name: /Back to login/i });
|
||||
await user.click(backButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows method selector when multiple 2FA methods are available', async () => {
|
||||
await loginWith2FA(['totp', 'email', 'backup']);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Multiple method buttons should be visible
|
||||
expect(screen.getByRole('button', { name: /Authenticator/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Email/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Backup/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show method selector with only one 2FA method', async () => {
|
||||
await loginWith2FA(['totp']);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Single-method: no method selector buttons
|
||||
expect(screen.queryByRole('button', { name: /Authenticator/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows send code button when email method is selected', async () => {
|
||||
const _user = await loginWith2FA(['email']);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// For email method the "Send code" button should be shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Send Code/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,13 +3,21 @@ import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/
|
|||
const API_BASE = '/api/v1';
|
||||
|
||||
// Auth token storage
|
||||
let authToken: string | null = localStorage.getItem('auth_token');
|
||||
// By default tokens are stored in sessionStorage (tab-scoped, cleared on close).
|
||||
// When the token originates from the ?token= URL param (kiosk bootstrap), it is
|
||||
// additionally persisted in localStorage so the kiosk survives page reloads.
|
||||
let authToken: string | null =
|
||||
sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
export function setAuthToken(token: string | null, persist = false) {
|
||||
authToken = token;
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
sessionStorage.setItem('auth_token', token);
|
||||
if (persist) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
} else {
|
||||
sessionStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +74,7 @@ async function request<T>(
|
|||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
cache: 'no-store', // Prevent browser caching of API responses
|
||||
credentials: 'include', // Required for HttpOnly cookies (e.g. 2fa_challenge)
|
||||
headers,
|
||||
});
|
||||
|
||||
|
|
@ -2346,9 +2355,13 @@ export interface LoginRequest {
|
|||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user: UserResponse;
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
user?: UserResponse;
|
||||
/** Set when 2FA verification is required before a full token is issued. */
|
||||
requires_2fa?: boolean;
|
||||
pre_auth_token?: string;
|
||||
two_fa_methods?: string[];
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
|
|
@ -2414,6 +2427,66 @@ export interface SMTPSettings {
|
|||
smtp_from_name: string;
|
||||
}
|
||||
|
||||
// 2FA / MFA interfaces
|
||||
export interface TwoFAStatus {
|
||||
totp_enabled: boolean;
|
||||
email_otp_enabled: boolean;
|
||||
backup_codes_remaining: number;
|
||||
}
|
||||
|
||||
export interface TOTPSetupResponse {
|
||||
secret: string;
|
||||
qr_code_b64: string;
|
||||
issuer: string;
|
||||
}
|
||||
|
||||
export interface TOTPEnableResponse {
|
||||
message: string;
|
||||
backup_codes: string[];
|
||||
}
|
||||
|
||||
export interface BackupCodesResponse {
|
||||
backup_codes: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TwoFAVerifyRequest {
|
||||
pre_auth_token: string;
|
||||
code: string;
|
||||
method: 'totp' | 'email' | 'backup';
|
||||
}
|
||||
|
||||
// OIDC interfaces
|
||||
export interface OIDCProvider {
|
||||
id: number;
|
||||
name: string;
|
||||
issuer_url: string;
|
||||
client_id: string;
|
||||
scopes: string;
|
||||
is_enabled: boolean;
|
||||
auto_create_users: boolean;
|
||||
icon_url?: string | null;
|
||||
}
|
||||
|
||||
export interface OIDCProviderCreate {
|
||||
name: string;
|
||||
issuer_url: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
scopes?: string;
|
||||
is_enabled?: boolean;
|
||||
auto_create_users?: boolean;
|
||||
icon_url?: string | null;
|
||||
}
|
||||
|
||||
export interface OIDCLink {
|
||||
id: number;
|
||||
provider_id: number;
|
||||
provider_name: string;
|
||||
provider_email?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TestSMTPRequest {
|
||||
test_recipient: string;
|
||||
}
|
||||
|
|
@ -2504,12 +2577,106 @@ export const api = {
|
|||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
// H-6: Confirm password reset using the token from the emailed link
|
||||
forgotPasswordConfirm: (token: string, newPassword: string) =>
|
||||
request<ForgotPasswordResponse>('/auth/forgot-password/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, new_password: newPassword }),
|
||||
}),
|
||||
resetUserPassword: (data: ResetPasswordRequest) =>
|
||||
request<ResetPasswordResponse>('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// 2FA - status
|
||||
get2FAStatus: () => request<TwoFAStatus>('/auth/2fa/status'),
|
||||
|
||||
// 2FA - TOTP
|
||||
setupTOTP: () => request<TOTPSetupResponse>('/auth/2fa/totp/setup', { method: 'POST' }),
|
||||
enableTOTP: (code: string) =>
|
||||
request<TOTPEnableResponse>('/auth/2fa/totp/enable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
}),
|
||||
disableTOTP: (code: string) =>
|
||||
request<{ message: string }>('/auth/2fa/totp/disable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
}),
|
||||
regenerateBackupCodes: (code: string) =>
|
||||
request<BackupCodesResponse>('/auth/2fa/totp/regenerate-backup-codes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
}),
|
||||
|
||||
// 2FA - Email OTP
|
||||
// Step 1: send a verification code to the user's email (proof of possession)
|
||||
enableEmailOTP: () =>
|
||||
request<{ message: string; setup_token: string }>('/auth/2fa/email/enable', { method: 'POST' }),
|
||||
// Step 2: confirm with the code received by email
|
||||
confirmEnableEmailOTP: (setup_token: string, code: string) =>
|
||||
request<{ message: string }>('/auth/2fa/email/enable/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ setup_token, code }),
|
||||
}),
|
||||
// Disable requires account password for re-auth
|
||||
disableEmailOTP: (password: string) =>
|
||||
request<{ message: string }>('/auth/2fa/email/disable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
sendEmailOTP: (preAuthToken: string) =>
|
||||
request<{ message: string; pre_auth_token?: string }>('/auth/2fa/email/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pre_auth_token: preAuthToken }),
|
||||
}),
|
||||
|
||||
// 2FA - verify (completes login)
|
||||
verify2FA: (data: TwoFAVerifyRequest) =>
|
||||
request<LoginResponse>('/auth/2fa/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// 2FA - admin
|
||||
admin2FADisable: (userId: number) =>
|
||||
request<{ message: string }>(`/auth/2fa/admin/${userId}`, { method: 'DELETE' }),
|
||||
|
||||
// OIDC providers (public list)
|
||||
getOIDCProviders: () => request<OIDCProvider[]>('/auth/oidc/providers'),
|
||||
|
||||
// OIDC providers (admin)
|
||||
getOIDCProvidersAll: () => request<OIDCProvider[]>('/auth/oidc/providers/all'),
|
||||
createOIDCProvider: (data: OIDCProviderCreate) =>
|
||||
request<OIDCProvider>('/auth/oidc/providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateOIDCProvider: (id: number, data: Partial<OIDCProviderCreate>) =>
|
||||
request<OIDCProvider>(`/auth/oidc/providers/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteOIDCProvider: (id: number) =>
|
||||
request<{ message: string }>(`/auth/oidc/providers/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// OIDC authorize URL
|
||||
getOIDCAuthorizeUrl: (providerId: number) =>
|
||||
request<{ auth_url: string }>(`/auth/oidc/authorize/${providerId}`),
|
||||
|
||||
// OIDC exchange token for JWT
|
||||
exchangeOIDCToken: (oidcToken: string) =>
|
||||
request<LoginResponse>('/auth/oidc/exchange', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ oidc_token: oidcToken }),
|
||||
}),
|
||||
|
||||
// OIDC links for current user
|
||||
getOIDCLinks: () => request<OIDCLink[]>('/auth/oidc/links'),
|
||||
deleteOIDCLink: (providerId: number) =>
|
||||
request<{ message: string }>(`/auth/oidc/links/${providerId}`, { method: 'DELETE' }),
|
||||
|
||||
// Users
|
||||
getUsers: () => request<UserResponse[]>('/users/'),
|
||||
getUser: (id: number) => request<UserResponse>(`/users/${id}`),
|
||||
|
|
|
|||
344
frontend/src/components/OIDCProviderSettings.tsx
Normal file
344
frontend/src/components/OIDCProviderSettings.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import type { OIDCProvider, OIDCProviderCreate } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { Toggle } from './Toggle';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
const EMPTY_FORM: OIDCProviderCreate = {
|
||||
name: '',
|
||||
issuer_url: '',
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
scopes: 'openid email profile',
|
||||
is_enabled: true,
|
||||
auto_create_users: false,
|
||||
icon_url: undefined,
|
||||
};
|
||||
|
||||
// ─── Provider form (create / edit) ───────────────────────────────────────────
|
||||
function ProviderForm({
|
||||
initial,
|
||||
isEdit = false,
|
||||
onSave,
|
||||
onCancel,
|
||||
isPending,
|
||||
}: {
|
||||
initial: OIDCProviderCreate;
|
||||
isEdit?: boolean;
|
||||
onSave: (data: OIDCProviderCreate) => void;
|
||||
onCancel: () => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<OIDCProviderCreate>(initial);
|
||||
const [secretChanged, setSecretChanged] = useState(false);
|
||||
const set = (key: keyof OIDCProviderCreate, value: unknown) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const inputCls =
|
||||
'w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors text-sm';
|
||||
const labelCls = 'block text-sm font-medium text-white mb-1';
|
||||
|
||||
const handleSave = () => {
|
||||
const payload = { ...form };
|
||||
if (isEdit && !secretChanged) {
|
||||
delete (payload as Partial<OIDCProviderCreate>).client_secret;
|
||||
}
|
||||
onSave(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.oidc.form.name')} <span className="text-red-400">*</span></label>
|
||||
<input className={inputCls} value={form.name} onChange={(e) => set('name', e.target.value)} placeholder="Google" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.oidc.form.issuerUrl')} <span className="text-red-400">*</span></label>
|
||||
<input className={inputCls} value={form.issuer_url} onChange={(e) => set('issuer_url', e.target.value)} placeholder="https://accounts.google.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.oidc.form.clientId')} <span className="text-red-400">*</span></label>
|
||||
<input className={inputCls} value={form.client_id} onChange={(e) => set('client_id', e.target.value)} placeholder="your-client-id" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>
|
||||
{t('settings.oidc.form.clientSecret')}
|
||||
{!isEdit && <span className="text-red-400"> *</span>}
|
||||
{isEdit && <span className="text-bambu-gray text-xs ml-1">({t('settings.oidc.form.secretHint')})</span>}
|
||||
</label>
|
||||
<input
|
||||
className={inputCls}
|
||||
type="password"
|
||||
value={secretChanged ? form.client_secret : ''}
|
||||
placeholder={isEdit && !secretChanged ? '••••••••' : t('settings.oidc.form.secretPlaceholder')}
|
||||
onChange={(e) => {
|
||||
setSecretChanged(true);
|
||||
set('client_secret', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.oidc.form.scopes')}</label>
|
||||
<input className={inputCls} value={form.scopes} onChange={(e) => set('scopes', e.target.value)} placeholder="openid email profile" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.oidc.form.iconUrl')}</label>
|
||||
<input className={inputCls} value={form.icon_url ?? ''} onChange={(e) => set('icon_url', e.target.value || undefined)} placeholder="https://..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-6 pt-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<Toggle checked={form.is_enabled ?? true} onChange={(v) => set('is_enabled', v)} />
|
||||
<span className="text-white text-sm">{t('settings.oidc.form.enabled')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<Toggle checked={form.auto_create_users ?? false} onChange={(v) => set('auto_create_users', v)} />
|
||||
<div>
|
||||
<p className="text-white text-sm">{t('settings.oidc.form.autoCreate')}</p>
|
||||
<p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoCreateDesc')}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="secondary" onClick={onCancel} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={!form.name || !form.issuer_url || !form.client_id || (!isEdit && !form.client_secret) || (isEdit && secretChanged && !form.client_secret) || isPending}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isPending ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
export function OIDCProviderSettings() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<OIDCProvider | null>(null);
|
||||
|
||||
const { data: providers, isLoading } = useQuery({
|
||||
queryKey: ['oidc-providers-all'],
|
||||
queryFn: () => api.getOIDCProvidersAll(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: OIDCProviderCreate) => api.createOIDCProvider(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
|
||||
setShowCreate(false);
|
||||
showToast(t('settings.oidc.created'), 'success');
|
||||
},
|
||||
onError: (e: Error) => showToast(e.message, 'error'),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<OIDCProviderCreate> }) =>
|
||||
api.updateOIDCProvider(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
|
||||
setEditingId(null);
|
||||
showToast(t('settings.oidc.updated'), 'success');
|
||||
},
|
||||
onError: (e: Error) => showToast(e.message, 'error'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.deleteOIDCProvider(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
|
||||
setDeleteTarget(null);
|
||||
showToast(t('settings.oidc.deleted'), 'success');
|
||||
},
|
||||
onError: (e: Error) => showToast(e.message, 'error'),
|
||||
});
|
||||
|
||||
const toggleEnabled = (provider: OIDCProvider) =>
|
||||
updateMutation.mutate({ id: provider.id, data: { is_enabled: !provider.is_enabled } });
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">{t('settings.oidc.title')}</h3>
|
||||
<p className="text-bambu-gray text-sm">{t('settings.oidc.desc')}</p>
|
||||
</div>
|
||||
{!showCreate && (
|
||||
<Button variant="primary" size="sm" onClick={() => setShowCreate(true)} className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('settings.oidc.addProvider')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{showCreate && (
|
||||
<CardContent>
|
||||
<div className="border-t border-bambu-dark-tertiary pt-4">
|
||||
<h4 className="text-white font-medium mb-4">{t('settings.oidc.newProvider')}</h4>
|
||||
<ProviderForm
|
||||
initial={EMPTY_FORM}
|
||||
onSave={(data) => createMutation.mutate(data)}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
isPending={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Provider list */}
|
||||
{providers && providers.length === 0 && !showCreate && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-center py-8 space-y-3">
|
||||
<Globe className="w-12 h-12 text-bambu-gray mx-auto" />
|
||||
<p className="text-bambu-gray">{t('settings.oidc.empty')}</p>
|
||||
<Button variant="primary" size="sm" onClick={() => setShowCreate(true)} className="inline-flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('settings.oidc.addProvider')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{providers?.map((provider) => (
|
||||
<Card key={provider.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
{provider.icon_url ? (
|
||||
<img src={provider.icon_url} alt={provider.name} className="w-8 h-8 rounded object-contain" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-bambu-dark-tertiary flex items-center justify-center">
|
||||
<Globe className="w-4 h-4 text-bambu-gray" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-white font-medium">{provider.name}</h4>
|
||||
{provider.is_enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full">
|
||||
<Check className="w-3 h-3" /> {t('common.enabled')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-bambu-gray bg-bambu-dark-tertiary px-2 py-0.5 rounded-full">
|
||||
<X className="w-3 h-3" /> {t('common.disabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-bambu-gray text-xs mt-0.5">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
<span>{provider.issuer_url}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
checked={provider.is_enabled}
|
||||
onChange={() => toggleEnabled(provider)}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(provider)}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{editingId === provider.id && (
|
||||
<CardContent>
|
||||
<div className="border-t border-bambu-dark-tertiary pt-4">
|
||||
<ProviderForm
|
||||
isEdit={true}
|
||||
initial={{
|
||||
name: provider.name,
|
||||
issuer_url: provider.issuer_url,
|
||||
client_id: provider.client_id,
|
||||
client_secret: '',
|
||||
scopes: provider.scopes,
|
||||
is_enabled: provider.is_enabled,
|
||||
auto_create_users: provider.auto_create_users,
|
||||
icon_url: provider.icon_url ?? undefined,
|
||||
}}
|
||||
onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
|
||||
onCancel={() => setEditingId(null)}
|
||||
isPending={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{editingId !== provider.id && (
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-bambu-gray">{t('settings.oidc.form.clientId')}</dt>
|
||||
<dd className="text-white font-mono truncate">{provider.client_id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-bambu-gray">{t('settings.oidc.form.scopes')}</dt>
|
||||
<dd className="text-white">{provider.scopes}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-bambu-gray">{t('settings.oidc.form.autoCreate')}</dt>
|
||||
<dd className={provider.auto_create_users ? 'text-green-400' : 'text-bambu-gray'}>
|
||||
{provider.auto_create_users ? t('common.yes') : t('common.no')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Delete confirm */}
|
||||
{deleteTarget && (
|
||||
<ConfirmModal
|
||||
title={t('settings.oidc.deleteTitle')}
|
||||
message={t('settings.oidc.deleteMessage', { name: deleteTarget.name })}
|
||||
confirmText={t('common.delete')}
|
||||
variant="danger"
|
||||
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
547
frontend/src/components/TwoFactorSettings.tsx
Normal file
547
frontend/src/components/TwoFactorSettings.tsx
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ShieldCheck, ShieldOff, Mail, Smartphone, Key, RefreshCw, Trash2, X, Eye, EyeOff, Copy } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
// ─── Small reusable code input ────────────────────────────────────────────────
|
||||
function CodeInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
maxLength = 6,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.toUpperCase().replace(/\s/g, ''))}
|
||||
maxLength={maxLength}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors font-mono tracking-widest text-center"
|
||||
placeholder={placeholder}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Backup codes display ─────────────────────────────────────────────────────
|
||||
function BackupCodesDisplay({ codes, onDone }: { codes: string[]; onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(codes.join('\n'));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-4">
|
||||
<p className="text-amber-400 text-sm font-medium">{t('settings.twoFa.backupCodesWarning')}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{codes.map((code, index) => (
|
||||
<code key={index} className="bg-bambu-dark-secondary rounded px-3 py-2 text-center font-mono text-sm text-white tracking-widest">
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" size="sm" onClick={handleCopy} className="flex items-center gap-2">
|
||||
<Copy className="w-4 h-4" />
|
||||
{copied ? t('common.copied') : t('common.copy')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={onDone} className="flex-1">
|
||||
{t('settings.twoFa.savedCodes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TOTP setup wizard ────────────────────────────────────────────────────────
|
||||
function TOTPSetupWizard({ onDone }: { onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const [step, setStep] = useState<'qr' | 'confirm' | 'backup'>('qr');
|
||||
const [code, setCode] = useState('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
const { data: setupData, isLoading } = useQuery({
|
||||
queryKey: ['totp-setup'],
|
||||
queryFn: () => api.setupTOTP(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: (c: string) => api.enableTOTP(c),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backup_codes);
|
||||
setStep('backup');
|
||||
queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
|
||||
},
|
||||
onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),
|
||||
});
|
||||
|
||||
if (isLoading || !setupData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'qr') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-bambu-gray-light text-sm">{t('settings.twoFa.setupInstructions')}</p>
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={`data:image/png;base64,${setupData.qr_code_b64}`}
|
||||
alt="TOTP QR Code"
|
||||
className="w-48 h-48 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray mb-1">{t('settings.twoFa.manualEntry')}</p>
|
||||
<div className="flex items-center gap-2 bg-bambu-dark-secondary rounded-lg px-3 py-2">
|
||||
<code className="text-white text-xs font-mono flex-1 break-all">
|
||||
{showSecret ? setupData.secret : '••••••••••••••••'}
|
||||
</code>
|
||||
<button onClick={() => setShowSecret(!showSecret)} className="text-bambu-gray hover:text-white">
|
||||
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(setupData.secret); }}
|
||||
className="text-bambu-gray hover:text-white"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" className="w-full" onClick={() => setStep('confirm')}>
|
||||
{t('settings.twoFa.scannedContinue')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'confirm') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-bambu-gray-light text-sm">{t('settings.twoFa.enterCodeToConfirm')}</p>
|
||||
<CodeInput value={code} onChange={setCode} placeholder="000000" />
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={() => setStep('qr')} className="flex-1">
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={code.length !== 6 || enableMutation.isPending}
|
||||
onClick={() => enableMutation.mutate(code)}
|
||||
>
|
||||
{enableMutation.isPending ? t('common.saving') : t('settings.twoFa.activate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// step === 'backup'
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-white font-medium">{t('settings.twoFa.backupCodesTitle')}</h3>
|
||||
<BackupCodesDisplay codes={backupCodes} onDone={onDone} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
export function TwoFactorSettings() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [showTOTPSetup, setShowTOTPSetup] = useState(false);
|
||||
const [showDisableTOTP, setShowDisableTOTP] = useState(false);
|
||||
const [showRegenBackup, setShowRegenBackup] = useState(false);
|
||||
const [disableCode, setDisableCode] = useState('');
|
||||
const [regenCode, setRegenCode] = useState('');
|
||||
const [newBackupCodes, setNewBackupCodes] = useState<string[] | null>(null);
|
||||
|
||||
// Email OTP enable: two-step proof-of-possession flow
|
||||
const [emailSetupToken, setEmailSetupToken] = useState<string | null>(null);
|
||||
const [emailSetupCode, setEmailSetupCode] = useState('');
|
||||
|
||||
// Email OTP disable: requires account password
|
||||
const [showDisableEmail, setShowDisableEmail] = useState(false);
|
||||
const [emailDisablePassword, setEmailDisablePassword] = useState('');
|
||||
const [showEmailDisablePassword, setShowEmailDisablePassword] = useState(false);
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['2fa-status'],
|
||||
queryFn: () => api.get2FAStatus(),
|
||||
});
|
||||
|
||||
const { data: oidcLinks } = useQuery({
|
||||
queryKey: ['oidc-links'],
|
||||
queryFn: () => api.getOIDCLinks(),
|
||||
});
|
||||
|
||||
// Step 1: request verification code (proof of possession)
|
||||
const enableEmailRequestMutation = useMutation({
|
||||
mutationFn: () => api.enableEmailOTP(),
|
||||
onSuccess: (data: { message: string; setup_token: string }) => {
|
||||
setEmailSetupToken(data.setup_token);
|
||||
showToast(data.message, 'success');
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
const msg = e.message ?? '';
|
||||
if (msg.toLowerCase().includes('smtp')) {
|
||||
showToast(t('settings.twoFa.smtpRequired'), 'error');
|
||||
} else {
|
||||
showToast(msg, 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Step 2: confirm with the code received by email
|
||||
const enableEmailConfirmMutation = useMutation({
|
||||
mutationFn: () => api.confirmEnableEmailOTP(emailSetupToken!, emailSetupCode),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
|
||||
setEmailSetupToken(null);
|
||||
setEmailSetupCode('');
|
||||
showToast(t('settings.twoFa.emailOtpEnabled'), 'success');
|
||||
},
|
||||
onError: (e: Error) => showToast(e.message, 'error'),
|
||||
});
|
||||
|
||||
const disableEmailMutation = useMutation({
|
||||
mutationFn: (password: string) => api.disableEmailOTP(password),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
|
||||
setShowDisableEmail(false);
|
||||
setEmailDisablePassword('');
|
||||
showToast(t('settings.twoFa.emailOtpDisabled'), 'success');
|
||||
},
|
||||
onError: (e: Error) => showToast(e.message, 'error'),
|
||||
});
|
||||
|
||||
const disableTOTPMutation = useMutation({
|
||||
mutationFn: (code: string) => api.disableTOTP(code),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
|
||||
setShowDisableTOTP(false);
|
||||
setDisableCode('');
|
||||
showToast(t('settings.twoFa.totpDisabled'), 'success');
|
||||
},
|
||||
onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),
|
||||
});
|
||||
|
||||
const regenMutation = useMutation({
|
||||
mutationFn: (code: string) => api.regenerateBackupCodes(code),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
|
||||
setShowRegenBackup(false);
|
||||
setRegenCode('');
|
||||
setNewBackupCodes(data.backup_codes);
|
||||
},
|
||||
onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),
|
||||
});
|
||||
|
||||
const unlinkOIDCMutation = useMutation({
|
||||
mutationFn: (providerId: number) => api.deleteOIDCLink(providerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['oidc-links'] });
|
||||
showToast(t('settings.twoFa.oidcUnlinked'), 'success');
|
||||
},
|
||||
onError: (e: Error) => showToast(e.message, 'error'),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasEmail = !!user?.email;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── TOTP ─────────────────────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${status?.totp_enabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
|
||||
<Smartphone className={`w-5 h-5 ${status?.totp_enabled ? 'text-green-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">{t('settings.twoFa.totpTitle')}</h3>
|
||||
<p className="text-bambu-gray text-sm">{t('settings.twoFa.totpDesc')}</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
{status?.totp_enabled ? (
|
||||
<span className="flex items-center gap-1 text-green-400 text-sm font-medium">
|
||||
<ShieldCheck className="w-4 h-4" /> {t('common.enabled')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-bambu-gray text-sm">
|
||||
<ShieldOff className="w-4 h-4" /> {t('common.disabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* TOTP Setup wizard */}
|
||||
{showTOTPSetup ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-white font-medium">{t('settings.twoFa.setupAuthApp')}</h4>
|
||||
<button onClick={() => { setShowTOTPSetup(false); queryClient.removeQueries({ queryKey: ['totp-setup'] }); }} className="text-bambu-gray hover:text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<TOTPSetupWizard onDone={() => { setShowTOTPSetup(false); queryClient.removeQueries({ queryKey: ['totp-setup'] }); }} />
|
||||
</div>
|
||||
) : showDisableTOTP ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-bambu-gray-light text-sm">{t('settings.twoFa.disableConfirmHint')}</p>
|
||||
<CodeInput value={disableCode} onChange={setDisableCode} placeholder="000000 or XXXXXXXX" maxLength={8} />
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={() => { setShowDisableTOTP(false); setDisableCode(''); }} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="flex-1"
|
||||
disabled={disableCode.length < 6 || disableTOTPMutation.isPending}
|
||||
onClick={() => disableTOTPMutation.mutate(disableCode)}
|
||||
>
|
||||
{disableTOTPMutation.isPending ? t('common.saving') : t('settings.twoFa.disableTotp')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : showRegenBackup ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-bambu-gray-light text-sm">{t('settings.twoFa.regenBackupHint')}</p>
|
||||
<CodeInput value={regenCode} onChange={setRegenCode} placeholder="000000 or XXXXXXXX" maxLength={8} />
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={() => { setShowRegenBackup(false); setRegenCode(''); }} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={regenCode.length < 6 || regenMutation.isPending}
|
||||
onClick={() => regenMutation.mutate(regenCode)}
|
||||
>
|
||||
{regenMutation.isPending ? t('common.saving') : t('settings.twoFa.regenBackup')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : newBackupCodes ? (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-white font-medium">{t('settings.twoFa.newBackupCodes')}</h4>
|
||||
<BackupCodesDisplay codes={newBackupCodes} onDone={() => setNewBackupCodes(null)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{!status?.totp_enabled ? (
|
||||
<Button variant="primary" onClick={() => setShowTOTPSetup(true)} className="flex items-center gap-2">
|
||||
<Smartphone className="w-4 h-4" />
|
||||
{t('settings.twoFa.setupTotp')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-bambu-gray-light">
|
||||
<Key className="w-4 h-4" />
|
||||
{t('settings.twoFa.backupCodesRemaining', { count: status.backup_codes_remaining })}
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowRegenBackup(true)} className="flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
{t('settings.twoFa.regenBackup')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => setShowDisableTOTP(true)} className="flex items-center gap-2">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('settings.twoFa.disableTotp')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Email OTP ─────────────────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${status?.email_otp_enabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
|
||||
<Mail className={`w-5 h-5 ${status?.email_otp_enabled ? 'text-green-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white font-semibold">{t('settings.twoFa.emailOtpTitle')}</h3>
|
||||
<p className="text-bambu-gray text-sm">
|
||||
{hasEmail
|
||||
? t('settings.twoFa.emailOtpDesc', { email: user?.email })
|
||||
: t('settings.twoFa.emailOtpNoEmail')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Show status badge; enable/disable handled in CardContent */}
|
||||
<div className="ml-auto">
|
||||
{status?.email_otp_enabled ? (
|
||||
<span className="flex items-center gap-1 text-green-400 text-sm font-medium">
|
||||
<ShieldCheck className="w-4 h-4" /> {t('common.enabled')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-bambu-gray text-sm">
|
||||
<ShieldOff className="w-4 h-4" /> {t('common.disabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasEmail ? (
|
||||
<p className="text-amber-400 text-sm">{t('settings.twoFa.addEmailFirst')}</p>
|
||||
) : emailSetupToken ? (
|
||||
/* Step 2: enter the code that was sent to the email */
|
||||
<div className="space-y-4">
|
||||
<p className="text-bambu-gray-light text-sm">{t('settings.twoFa.emailSetupEnterCode')}</p>
|
||||
<CodeInput value={emailSetupCode} onChange={setEmailSetupCode} placeholder="000000" />
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => { setEmailSetupToken(null); setEmailSetupCode(''); }}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={emailSetupCode.length !== 6 || enableEmailConfirmMutation.isPending}
|
||||
onClick={() => enableEmailConfirmMutation.mutate()}
|
||||
>
|
||||
{enableEmailConfirmMutation.isPending ? t('common.saving') : t('settings.twoFa.verifyAndEnable')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : showDisableEmail ? (
|
||||
/* Disable: require account password for re-auth */
|
||||
<div className="space-y-4">
|
||||
<p className="text-bambu-gray-light text-sm">{t('settings.twoFa.emailDisablePasswordHint')}</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showEmailDisablePassword ? 'text' : 'password'}
|
||||
value={emailDisablePassword}
|
||||
onChange={(e) => setEmailDisablePassword(e.target.value)}
|
||||
placeholder={t('settings.twoFa.passwordPlaceholder')}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmailDisablePassword(!showEmailDisablePassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
|
||||
>
|
||||
{showEmailDisablePassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => { setShowDisableEmail(false); setEmailDisablePassword(''); }}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="flex-1"
|
||||
disabled={!emailDisablePassword || disableEmailMutation.isPending}
|
||||
onClick={() => disableEmailMutation.mutate(emailDisablePassword)}
|
||||
>
|
||||
{disableEmailMutation.isPending ? t('common.saving') : t('settings.twoFa.disableEmailOtp')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
{!status?.email_otp_enabled ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!hasEmail || enableEmailRequestMutation.isPending}
|
||||
onClick={() => enableEmailRequestMutation.mutate()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
{enableEmailRequestMutation.isPending ? t('common.saving') : t('settings.twoFa.enableEmailOtp')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setShowDisableEmail(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('settings.twoFa.disableEmailOtp')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Linked SSO accounts ───────────────────────────────────────────── */}
|
||||
{oidcLinks && oidcLinks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-white font-semibold">{t('settings.twoFa.linkedAccounts')}</h3>
|
||||
<p className="text-bambu-gray text-sm">{t('settings.twoFa.linkedAccountsDesc')}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{oidcLinks.map((link) => (
|
||||
<div key={link.id} className="flex items-center justify-between py-2 border-b border-bambu-dark-tertiary last:border-0">
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">{link.provider_name}</p>
|
||||
{link.provider_email && (
|
||||
<p className="text-bambu-gray text-xs">{link.provider_email}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => unlinkOIDCMutation.mutate(link.provider_id)}
|
||||
disabled={unlinkOIDCMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, getAuthToken, setAuthToken } from '../api/client';
|
||||
import type { Permission, UserResponse } from '../api/client';
|
||||
import type { LoginResponse, Permission, UserResponse } from '../api/client';
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserResponse | null;
|
||||
|
|
@ -8,7 +8,10 @@ interface AuthContextType {
|
|||
requiresSetup: boolean;
|
||||
loading: boolean;
|
||||
isAdmin: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
/** Login with username/password. Returns LoginResponse (may include requires_2fa). */
|
||||
login: (username: string, password: string) => Promise<LoginResponse>;
|
||||
/** Finalise login after 2FA or OIDC — store token and set user directly. */
|
||||
loginWithToken: (token: string, user: UserResponse) => void;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
refreshAuth: () => Promise<void>;
|
||||
|
|
@ -30,12 +33,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
// Bootstrap: if URL has ?token= param, store it and strip from URL.
|
||||
// Allows SpoolBuddy kiosk to pass API key via URL on first load.
|
||||
// Bootstrap: if URL has ?token= param, store it session-only first and
|
||||
// strip it from the URL. Allows SpoolBuddy kiosk to pass an API key via
|
||||
// URL on first load. Persistence to localStorage is deferred until the
|
||||
// token has been verified by the server (L-4: prevents session fixation
|
||||
// where an attacker-crafted URL immediately persists a forged/stolen token).
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlToken = urlParams.get('token');
|
||||
if (urlToken) {
|
||||
setAuthToken(urlToken);
|
||||
setAuthToken(urlToken, false); // session-only until server confirms it's valid
|
||||
urlParams.delete('token');
|
||||
const cleanSearch = urlParams.toString();
|
||||
const cleanUrl = window.location.pathname
|
||||
|
|
@ -56,8 +62,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
const currentUser = await api.getCurrentUser();
|
||||
if (!mountedRef.current) return;
|
||||
setUser(currentUser);
|
||||
// Persist kiosk token only after the server confirms it is valid.
|
||||
if (urlToken && token === urlToken) {
|
||||
setAuthToken(urlToken, true);
|
||||
}
|
||||
} catch {
|
||||
// Token invalid, clear it
|
||||
// Token invalid, clear it (removes from both sessionStorage and localStorage)
|
||||
setAuthToken(null);
|
||||
if (!mountedRef.current) return;
|
||||
setUser(null);
|
||||
|
|
@ -106,10 +116,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
}, [loading, requiresSetup, authEnabled]);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const login = async (username: string, password: string): Promise<LoginResponse> => {
|
||||
const response = await api.login({ username, password });
|
||||
setAuthToken(response.access_token);
|
||||
await checkAuthStatus();
|
||||
if (!response.requires_2fa && response.access_token) {
|
||||
setAuthToken(response.access_token);
|
||||
await checkAuthStatus();
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
const loginWithToken = (token: string, userObj: UserResponse) => {
|
||||
setAuthToken(token);
|
||||
setUser(userObj);
|
||||
setAuthEnabled(true);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
|
|
@ -205,6 +224,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
loading,
|
||||
isAdmin,
|
||||
login,
|
||||
loginWithToken,
|
||||
logout,
|
||||
refreshUser,
|
||||
refreshAuth,
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ export default {
|
|||
more: '+{{count}} weitere',
|
||||
ascending: 'Aufsteigend',
|
||||
descending: 'Absteigend',
|
||||
back: 'Zurück',
|
||||
copy: 'Kopieren',
|
||||
copied: 'Kopiert!',
|
||||
printer: 'Drucker',
|
||||
remove: 'Entfernen',
|
||||
type: 'Typ',
|
||||
|
|
@ -1325,6 +1328,8 @@ export default {
|
|||
backup: 'Sicherung',
|
||||
emailAuth: 'E-Mail-Authentifizierung',
|
||||
ldap: 'LDAP',
|
||||
twoFa: 'Zwei-Faktor-Auth',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
spoolbuddy: {
|
||||
infoTitle: 'SpoolBuddy-Geräte',
|
||||
|
|
@ -2078,6 +2083,74 @@ export default {
|
|||
deleteUserAndItems: 'Benutzer UND dessen Elemente löschen',
|
||||
deleteUserKeepItems: 'Benutzer löschen, Elemente behalten (werden herrenlos)',
|
||||
ok: 'OK',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: 'Authenticator-App (TOTP)',
|
||||
totpDesc: 'Verwende eine Authenticator-App wie Google Authenticator, Aegis oder Authy.',
|
||||
emailOtpTitle: 'E-Mail OTP',
|
||||
emailOtpDesc: 'Sende einen Einmalcode an {{email}} beim Einloggen.',
|
||||
emailOtpNoEmail: 'Füge eine E-Mail-Adresse zu deinem Konto hinzu, um diese Methode zu aktivieren.',
|
||||
addEmailFirst: 'Dein Konto hat keine E-Mail-Adresse. Bitte einen Administrator, eine hinzuzufügen.',
|
||||
setupTotp: 'Authenticator-App einrichten',
|
||||
setupAuthApp: 'Authenticator-App einrichten',
|
||||
setupInstructions: 'Scanne den QR-Code mit deiner Authenticator-App und bestätige mit einem Code.',
|
||||
manualEntry: 'Kein Scanner? Gib dieses Secret manuell ein:',
|
||||
scannedContinue: 'Code gescannt — weiter',
|
||||
enterCodeToConfirm: 'Gib den 6-stelligen Code aus deiner Authenticator-App ein, um die Einrichtung zu bestätigen.',
|
||||
activate: 'Aktivieren',
|
||||
disableTotp: 'Authenticator deaktivieren',
|
||||
disableConfirmHint: 'Gib einen gültigen TOTP-Code oder einen Backup-Code ein, um den Authenticator zu deaktivieren.',
|
||||
totpDisabled: 'Authenticator-App deaktiviert.',
|
||||
emailOtpEnabled: 'E-Mail OTP aktiviert.',
|
||||
emailOtpDisabled: 'E-Mail OTP deaktiviert.',
|
||||
smtpRequired: 'Bitte konfigurieren und testen Sie zuerst die SMTP-Einstellungen.',
|
||||
invalidCode: 'Ungültiger Code. Bitte erneut versuchen.',
|
||||
enableEmailOtp: 'E-Mail OTP aktivieren',
|
||||
disableEmailOtp: 'E-Mail OTP deaktivieren',
|
||||
emailSetupEnterCode: 'Ein Bestätigungscode wurde an Ihre E-Mail-Adresse gesendet. Geben Sie ihn unten ein, um zu bestätigen, dass Ihnen dieses Postfach gehört.',
|
||||
verifyAndEnable: 'Verifizieren & Aktivieren',
|
||||
emailDisablePasswordHint: 'Geben Sie Ihr Kontopasswort ein, um die Deaktivierung des E-Mail OTP zu bestätigen.',
|
||||
passwordPlaceholder: 'Passwort eingeben',
|
||||
backupCodesTitle: 'Backup-Codes sichern',
|
||||
backupCodesWarning: 'Speichere diese Codes sicher. Jeder Code kann nur einmal verwendet werden und wird nicht erneut angezeigt.',
|
||||
backupCodesRemaining: '{{count}} Backup-Codes verbleibend',
|
||||
savedCodes: 'Codes gespeichert',
|
||||
regenBackup: 'Backup-Codes neu generieren',
|
||||
regenBackupHint: 'Gib deinen aktuellen TOTP-Code ein, um 10 neue Backup-Codes zu generieren. Alle bestehenden Codes werden ungültig.',
|
||||
newBackupCodes: 'Neue Backup-Codes',
|
||||
linkedAccounts: 'Verknüpfte SSO-Konten',
|
||||
linkedAccountsDesc: 'Diese externen Identitätsanbieter sind mit deinem Konto verknüpft.',
|
||||
oidcUnlinked: 'Konto getrennt.',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'SSO / OIDC-Anbieter',
|
||||
desc: 'Konfiguriere OpenID Connect-Anbieter für Single Sign-On.',
|
||||
addProvider: 'Anbieter hinzufügen',
|
||||
newProvider: 'Neuer Anbieter',
|
||||
empty: 'Noch keine OIDC-Anbieter konfiguriert.',
|
||||
created: 'Anbieter erstellt.',
|
||||
updated: 'Anbieter aktualisiert.',
|
||||
deleted: 'Anbieter gelöscht.',
|
||||
deleteTitle: 'Anbieter löschen',
|
||||
deleteMessage: '"{{name}}" löschen? Alle verknüpften Benutzerkonten werden getrennt.',
|
||||
form: {
|
||||
name: 'Anzeigename',
|
||||
issuerUrl: 'Aussteller-URL',
|
||||
clientId: 'Client-ID',
|
||||
clientSecret: 'Client-Secret',
|
||||
scopes: 'Scopes',
|
||||
iconUrl: 'Symbol-URL (optional)',
|
||||
enabled: 'Aktiviert',
|
||||
autoCreate: 'Benutzer automatisch anlegen',
|
||||
autoCreateDesc: 'Erstellt beim ersten Login automatisch ein lokales Konto.',
|
||||
secretHint: 'leer lassen zum Beibehalten',
|
||||
secretPlaceholder: 'neues Secret',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
|
|
@ -2208,6 +2281,28 @@ export default {
|
|||
loginSuccess: 'Erfolgreich angemeldet',
|
||||
loginFailed: 'Anmeldung fehlgeschlagen',
|
||||
enterCredentials: 'Bitte Benutzername und Passwort eingeben',
|
||||
enterEmail: 'Bitte geben Sie Ihre E-Mail-Adresse ein',
|
||||
oidcLoginFailed: 'OIDC-Anmeldung fehlgeschlagen',
|
||||
oidcErrors: {
|
||||
providerError: 'Der Identity-Provider hat einen Fehler zurückgegeben',
|
||||
missingParameters: 'Dem OIDC-Callback fehlen erforderliche Parameter',
|
||||
invalidState: 'OIDC-State ist ungültig oder wurde bereits verwendet',
|
||||
stateExpired: 'OIDC-Sitzung abgelaufen — bitte erneut versuchen',
|
||||
providerNotFound: 'OIDC-Provider nicht gefunden',
|
||||
discoveryFailed: 'OIDC-Discovery-Dokument konnte nicht abgerufen werden',
|
||||
invalidDiscovery: 'OIDC-Discovery-Dokument ist ungültig',
|
||||
networkError: 'Netzwerkfehler beim OIDC-Token-Austausch',
|
||||
badResponse: 'Unerwartete Antwort beim OIDC-Token-Austausch',
|
||||
noIdToken: 'OIDC-Provider hat kein ID-Token zurückgegeben',
|
||||
validationFailed: 'OIDC-Token-Validierung fehlgeschlagen',
|
||||
nonceMismatch: 'OIDC-Nonce stimmt nicht überein — möglicher Replay-Angriff',
|
||||
missingSubClaim: 'OIDC-Token enthält keinen Sub-Claim',
|
||||
noLinkedAccount: 'Kein lokales Konto mit dieser OIDC-Identität verknüpft',
|
||||
accountInactive: 'Ihr Konto ist inaktiv',
|
||||
userResolutionFailed: 'Ihr Konto konnte nicht aufgelöst werden',
|
||||
internalError: 'Interner Fehler beim OIDC-Login',
|
||||
tokenExchangeFailed: 'OIDC-Token-Austausch fehlgeschlagen',
|
||||
},
|
||||
forgotPasswordTitle: 'Passwort vergessen',
|
||||
forgotPasswordMessage: 'Wenn Sie Ihr Passwort vergessen haben, wenden Sie sich bitte an Ihren Systemadministrator.',
|
||||
forgotPasswordEmailMessage: 'Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen ein neues Passwort.',
|
||||
|
|
@ -2222,6 +2317,47 @@ export default {
|
|||
resetStep3: 'Er kann ein neues temporäres Passwort für Sie festlegen',
|
||||
resetStep4: 'Melden Sie sich mit dem neuen Passwort an und ändern Sie es in den Einstellungen',
|
||||
gotIt: 'Verstanden',
|
||||
resetPassword: {
|
||||
title: 'Neues Passwort festlegen',
|
||||
subtitle: 'Geben Sie unten Ihr neues Passwort ein und bestätigen Sie es.',
|
||||
newPassword: 'Neues Passwort',
|
||||
newPasswordPlaceholder: 'Mindestens 8 Zeichen',
|
||||
confirmPassword: 'Passwort bestätigen',
|
||||
confirmPasswordPlaceholder: 'Neues Passwort wiederholen',
|
||||
saving: 'Wird gespeichert\u2026',
|
||||
submit: 'Neues Passwort festlegen',
|
||||
backToLogin: 'Zurück zur Anmeldung',
|
||||
passwordsDoNotMatch: 'Passwörter stimmen nicht überein',
|
||||
passwordTooShort: 'Passwort muss mindestens 8 Zeichen lang sein',
|
||||
resetFailed: 'Passwort zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.',
|
||||
},
|
||||
twoFA: {
|
||||
title: 'Zwei-Faktor-Authentifizierung',
|
||||
subtitle: 'Ihr Konto ist mit 2FA geschützt. Geben Sie unten den Bestätigungscode ein.',
|
||||
methodAuthenticator: 'Authenticator-App',
|
||||
methodEmail: 'E-Mail-Code',
|
||||
methodBackup: 'Wiederherstellungscode',
|
||||
instructionsTotp: 'Öffnen Sie Ihre Authenticator-App und geben Sie den 6-stelligen Code für Bambuddy ein.',
|
||||
instructionsEmail: 'Ein 6-stelliger Code wurde an Ihre E-Mail-Adresse gesendet. Er ist 10 Minuten gültig.',
|
||||
instructionsEmailNotSent: 'Klicken Sie unten, um einen Bestätigungscode per E-Mail zu erhalten.',
|
||||
instructionsBackup: 'Geben Sie einen Ihrer 8-stelligen Wiederherstellungscodes ein. Jeder Code kann nur einmal verwendet werden.',
|
||||
sendCodeButton: 'Code per E-Mail senden',
|
||||
sendingCode: 'Wird gesendet...',
|
||||
resendCode: 'Code erneut senden',
|
||||
codeLabel: 'Bestätigungscode',
|
||||
backupCodeLabel: 'Wiederherstellungscode',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: 'Bestätigen',
|
||||
verifyingButton: 'Wird überprüft...',
|
||||
backToLogin: '← Zurück zur Anmeldung',
|
||||
orContinueWith: 'oder anmelden mit',
|
||||
signInWith: 'Anmelden mit {{provider}}',
|
||||
enterCode: 'Bitte geben Sie den Bestätigungscode ein',
|
||||
sendCodeFailed: 'Bestätigungscode konnte nicht gesendet werden',
|
||||
invalidCode: 'Ungültiger Code. Bitte erneut versuchen.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ export default {
|
|||
more: '+{{count}} more',
|
||||
ascending: 'Ascending',
|
||||
descending: 'Descending',
|
||||
back: 'Back',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied!',
|
||||
printer: 'Printer',
|
||||
remove: 'Remove',
|
||||
type: 'Type',
|
||||
|
|
@ -1326,6 +1329,8 @@ export default {
|
|||
backup: 'Backup',
|
||||
emailAuth: 'Email Authentication',
|
||||
ldap: 'LDAP',
|
||||
twoFa: 'Two-Factor Auth',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
spoolbuddy: {
|
||||
infoTitle: 'SpoolBuddy devices',
|
||||
|
|
@ -2080,6 +2085,74 @@ export default {
|
|||
deleteUserAndItems: 'Delete user AND their items',
|
||||
deleteUserKeepItems: 'Delete user, keep items (become ownerless)',
|
||||
ok: 'OK',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: 'Authenticator App (TOTP)',
|
||||
totpDesc: 'Use an authenticator app like Google Authenticator, Aegis or Authy.',
|
||||
emailOtpTitle: 'Email OTP',
|
||||
emailOtpDesc: 'Send a one-time code to {{email}} when you log in.',
|
||||
emailOtpNoEmail: 'Add an email address to your account to enable this method.',
|
||||
addEmailFirst: 'Your account has no email address. Ask an admin to add one before enabling Email OTP.',
|
||||
setupTotp: 'Set up Authenticator App',
|
||||
setupAuthApp: 'Set up Authenticator App',
|
||||
setupInstructions: 'Scan the QR code below with your authenticator app, then confirm with a code.',
|
||||
manualEntry: 'Can\'t scan? Enter this secret manually:',
|
||||
scannedContinue: 'I\'ve scanned the code — continue',
|
||||
enterCodeToConfirm: 'Enter the 6-digit code from your authenticator app to confirm setup.',
|
||||
activate: 'Activate',
|
||||
disableTotp: 'Disable Authenticator',
|
||||
disableConfirmHint: 'Enter a valid TOTP code or a backup code to disable the authenticator.',
|
||||
totpDisabled: 'Authenticator app disabled.',
|
||||
emailOtpEnabled: 'Email OTP enabled.',
|
||||
emailOtpDisabled: 'Email OTP disabled.',
|
||||
smtpRequired: 'Please configure and test SMTP settings first.',
|
||||
invalidCode: 'Invalid code. Please try again.',
|
||||
enableEmailOtp: 'Enable Email OTP',
|
||||
disableEmailOtp: 'Disable Email OTP',
|
||||
emailSetupEnterCode: 'A verification code has been sent to your email address. Enter it below to confirm you own this inbox.',
|
||||
verifyAndEnable: 'Verify & Enable',
|
||||
emailDisablePasswordHint: 'Enter your account password to confirm disabling email OTP.',
|
||||
passwordPlaceholder: 'Enter your password',
|
||||
backupCodesTitle: 'Save your backup codes',
|
||||
backupCodesWarning: 'Save these codes somewhere safe. Each code can only be used once and they will not be shown again.',
|
||||
backupCodesRemaining: '{{count}} backup codes remaining',
|
||||
savedCodes: 'I\'ve saved my codes',
|
||||
regenBackup: 'Regenerate Backup Codes',
|
||||
regenBackupHint: 'Enter your current TOTP code to generate 10 new backup codes. All existing backup codes will be invalidated.',
|
||||
newBackupCodes: 'New backup codes',
|
||||
linkedAccounts: 'Linked SSO Accounts',
|
||||
linkedAccountsDesc: 'These external identity providers are linked to your account.',
|
||||
oidcUnlinked: 'Account unlinked.',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'SSO / OIDC Providers',
|
||||
desc: 'Configure OpenID Connect providers to allow single sign-on via external identity providers.',
|
||||
addProvider: 'Add Provider',
|
||||
newProvider: 'New Provider',
|
||||
empty: 'No OIDC providers configured yet.',
|
||||
created: 'Provider created.',
|
||||
updated: 'Provider updated.',
|
||||
deleted: 'Provider deleted.',
|
||||
deleteTitle: 'Delete Provider',
|
||||
deleteMessage: 'Delete "{{name}}"? All linked user accounts will be disconnected.',
|
||||
form: {
|
||||
name: 'Display Name',
|
||||
issuerUrl: 'Issuer URL',
|
||||
clientId: 'Client ID',
|
||||
clientSecret: 'Client Secret',
|
||||
scopes: 'Scopes',
|
||||
iconUrl: 'Icon URL (optional)',
|
||||
enabled: 'Enabled',
|
||||
autoCreate: 'Auto-create users',
|
||||
autoCreateDesc: 'Automatically create a local account on first login.',
|
||||
secretHint: 'leave blank to keep current',
|
||||
secretPlaceholder: 'new secret',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
|
|
@ -2210,6 +2283,28 @@ export default {
|
|||
loginSuccess: 'Logged in successfully',
|
||||
loginFailed: 'Login failed',
|
||||
enterCredentials: 'Please enter username and password',
|
||||
enterEmail: 'Please enter your email address',
|
||||
oidcLoginFailed: 'OIDC login failed',
|
||||
oidcErrors: {
|
||||
providerError: 'The identity provider returned an error',
|
||||
missingParameters: 'OIDC callback is missing required parameters',
|
||||
invalidState: 'OIDC state is invalid or has already been used',
|
||||
stateExpired: 'OIDC login session expired — please try again',
|
||||
providerNotFound: 'OIDC provider not found',
|
||||
discoveryFailed: 'Failed to fetch OIDC discovery document',
|
||||
invalidDiscovery: 'OIDC discovery document is invalid',
|
||||
networkError: 'Network error during OIDC token exchange',
|
||||
badResponse: 'Unexpected response during OIDC token exchange',
|
||||
noIdToken: 'OIDC provider did not return an ID token',
|
||||
validationFailed: 'OIDC token validation failed',
|
||||
nonceMismatch: 'OIDC nonce mismatch — possible replay attack',
|
||||
missingSubClaim: 'OIDC token is missing the sub claim',
|
||||
noLinkedAccount: 'No local account is linked to this OIDC identity',
|
||||
accountInactive: 'Your account is inactive',
|
||||
userResolutionFailed: 'Failed to resolve your account',
|
||||
internalError: 'An internal error occurred during OIDC login',
|
||||
tokenExchangeFailed: 'OIDC token exchange failed',
|
||||
},
|
||||
forgotPasswordTitle: 'Forgot Password',
|
||||
forgotPasswordMessage: "If you've forgotten your password, please contact your system administrator to reset it.",
|
||||
forgotPasswordEmailMessage: "Enter your email address and we'll send you a new password.",
|
||||
|
|
@ -2224,6 +2319,47 @@ export default {
|
|||
resetStep3: 'They can set a new temporary password for you',
|
||||
resetStep4: 'Log in with the new password and change it in Settings',
|
||||
gotIt: 'Got it',
|
||||
resetPassword: {
|
||||
title: 'Set New Password',
|
||||
subtitle: 'Enter and confirm your new password below.',
|
||||
newPassword: 'New Password',
|
||||
newPasswordPlaceholder: 'At least 8 characters',
|
||||
confirmPassword: 'Confirm Password',
|
||||
confirmPasswordPlaceholder: 'Repeat new password',
|
||||
saving: 'Saving\u2026',
|
||||
submit: 'Set New Password',
|
||||
backToLogin: 'Back to login',
|
||||
passwordsDoNotMatch: 'Passwords do not match',
|
||||
passwordTooShort: 'Password must be at least 8 characters',
|
||||
resetFailed: 'Password reset failed. The link may have expired.',
|
||||
},
|
||||
twoFA: {
|
||||
title: 'Two-Factor Authentication',
|
||||
subtitle: 'Your account is protected with 2FA. Enter the verification code below.',
|
||||
methodAuthenticator: 'Authenticator App',
|
||||
methodEmail: 'Email Code',
|
||||
methodBackup: 'Backup Code',
|
||||
instructionsTotp: 'Open your authenticator app and enter the 6-digit code for Bambuddy.',
|
||||
instructionsEmail: 'A 6-digit code has been sent to your email address. It expires in 10 minutes.',
|
||||
instructionsEmailNotSent: 'Click the button below to receive a verification code via email.',
|
||||
instructionsBackup: 'Enter one of your 8-character backup recovery codes. Each code can only be used once.',
|
||||
sendCodeButton: 'Send Code via Email',
|
||||
sendingCode: 'Sending...',
|
||||
resendCode: 'Resend code',
|
||||
codeLabel: 'Verification Code',
|
||||
backupCodeLabel: 'Backup Code',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: 'Verify',
|
||||
verifyingButton: 'Verifying...',
|
||||
backToLogin: '← Back to login',
|
||||
orContinueWith: 'or continue with',
|
||||
signInWith: 'Sign in with {{provider}}',
|
||||
enterCode: 'Please enter the verification code',
|
||||
sendCodeFailed: 'Failed to send verification code',
|
||||
invalidCode: 'Invalid code. Please try again.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ export default {
|
|||
more: '+{{count}} de plus',
|
||||
ascending: 'Croissant',
|
||||
descending: 'Décroissant',
|
||||
back: 'Retour',
|
||||
copy: 'Copier',
|
||||
copied: 'Copié !',
|
||||
printer: 'Imprimante',
|
||||
remove: 'Retirer',
|
||||
type: 'Type',
|
||||
|
|
@ -1324,6 +1327,8 @@ export default {
|
|||
backup: 'Sauvegarde',
|
||||
emailAuth: 'Authentification Email',
|
||||
ldap: 'LDAP',
|
||||
twoFa: 'Authentification 2FA',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
ldap: {
|
||||
title: 'Authentification LDAP',
|
||||
|
|
@ -2039,6 +2044,74 @@ export default {
|
|||
deleteUserAndItems: 'Supprimer l\'utilisateur ET ses éléments',
|
||||
deleteUserKeepItems: 'Supprimer l\'utilisateur, garder les éléments (deviennent sans propriétaire)',
|
||||
ok: 'OK',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: 'Application Authenticator (TOTP)',
|
||||
totpDesc: 'Utilisez une application comme Google Authenticator, Aegis ou Authy.',
|
||||
emailOtpTitle: 'OTP par e-mail',
|
||||
emailOtpDesc: 'Envoyez un code à usage unique à {{email}} lors de la connexion.',
|
||||
emailOtpNoEmail: 'Ajoutez une adresse e-mail à votre compte pour activer cette méthode.',
|
||||
addEmailFirst: 'Votre compte n\'a pas d\'adresse e-mail. Demandez à un administrateur d\'en ajouter une.',
|
||||
setupTotp: 'Configurer l\'application Authenticator',
|
||||
setupAuthApp: 'Configurer l\'application Authenticator',
|
||||
setupInstructions: 'Scannez le QR code avec votre application authenticator, puis confirmez avec un code.',
|
||||
manualEntry: 'Impossible de scanner ? Entrez ce secret manuellement :',
|
||||
scannedContinue: 'Code scanné — continuer',
|
||||
enterCodeToConfirm: 'Entrez le code à 6 chiffres de votre application authenticator pour confirmer.',
|
||||
activate: 'Activer',
|
||||
disableTotp: 'Désactiver l\'Authenticator',
|
||||
disableConfirmHint: 'Entrez un code TOTP valide ou un code de secours pour désactiver l\'authenticator.',
|
||||
totpDisabled: 'Application Authenticator désactivée.',
|
||||
emailOtpEnabled: 'OTP par e-mail activé.',
|
||||
emailOtpDisabled: 'OTP par e-mail désactivé.',
|
||||
smtpRequired: 'Veuillez d\'abord configurer et tester les paramètres SMTP.',
|
||||
invalidCode: 'Code invalide. Veuillez réessayer.',
|
||||
enableEmailOtp: 'Activer OTP par e-mail',
|
||||
disableEmailOtp: 'Désactiver OTP par e-mail',
|
||||
emailSetupEnterCode: 'Un code de vérification a été envoyé à votre adresse e-mail. Entrez-le ci-dessous pour confirmer que vous possédez cette boîte de réception.',
|
||||
verifyAndEnable: 'Vérifier et activer',
|
||||
emailDisablePasswordHint: 'Entrez le mot de passe de votre compte pour confirmer la désactivation de l\'OTP par e-mail.',
|
||||
passwordPlaceholder: 'Entrez votre mot de passe',
|
||||
backupCodesTitle: 'Sauvegardez vos codes de secours',
|
||||
backupCodesWarning: 'Conservez ces codes en lieu sûr. Chaque code ne peut être utilisé qu\'une seule fois et ne sera plus affiché.',
|
||||
backupCodesRemaining: '{{count}} codes de secours restants',
|
||||
savedCodes: 'Codes sauvegardés',
|
||||
regenBackup: 'Régénérer les codes de secours',
|
||||
regenBackupHint: 'Entrez votre code TOTP actuel pour générer 10 nouveaux codes de secours. Tous les codes existants seront invalidés.',
|
||||
newBackupCodes: 'Nouveaux codes de secours',
|
||||
linkedAccounts: 'Comptes SSO liés',
|
||||
linkedAccountsDesc: 'Ces fournisseurs d\'identité externes sont liés à votre compte.',
|
||||
oidcUnlinked: 'Compte dissocié.',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'Fournisseurs SSO / OIDC',
|
||||
desc: 'Configurez des fournisseurs OpenID Connect pour l\'authentification unique.',
|
||||
addProvider: 'Ajouter un fournisseur',
|
||||
newProvider: 'Nouveau fournisseur',
|
||||
empty: 'Aucun fournisseur OIDC configuré.',
|
||||
created: 'Fournisseur créé.',
|
||||
updated: 'Fournisseur mis à jour.',
|
||||
deleted: 'Fournisseur supprimé.',
|
||||
deleteTitle: 'Supprimer le fournisseur',
|
||||
deleteMessage: 'Supprimer "{{name}}" ? Tous les comptes liés seront déconnectés.',
|
||||
form: {
|
||||
name: 'Nom d\'affichage',
|
||||
issuerUrl: 'URL de l\'émetteur',
|
||||
clientId: 'ID client',
|
||||
clientSecret: 'Secret client',
|
||||
scopes: 'Scopes',
|
||||
iconUrl: 'URL de l\'icône (optionnel)',
|
||||
enabled: 'Activé',
|
||||
autoCreate: 'Créer les utilisateurs automatiquement',
|
||||
autoCreateDesc: 'Crée automatiquement un compte local lors de la première connexion.',
|
||||
secretHint: 'laisser vide pour conserver',
|
||||
secretPlaceholder: 'nouveau secret',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
|
|
@ -2169,6 +2242,28 @@ export default {
|
|||
loginSuccess: 'Connecté avec succès',
|
||||
loginFailed: 'Échec de connexion',
|
||||
enterCredentials: 'Entrez vos identifiants',
|
||||
enterEmail: 'Veuillez entrer votre adresse e-mail',
|
||||
oidcLoginFailed: 'Échec de la connexion OIDC',
|
||||
oidcErrors: {
|
||||
providerError: "Le fournisseur d'identité a renvoyé une erreur",
|
||||
missingParameters: 'Il manque des paramètres requis dans le callback OIDC',
|
||||
invalidState: "L'état OIDC est invalide ou a déjà été utilisé",
|
||||
stateExpired: 'La session OIDC a expiré — veuillez réessayer',
|
||||
providerNotFound: 'Fournisseur OIDC introuvable',
|
||||
discoveryFailed: 'Impossible de récupérer le document de découverte OIDC',
|
||||
invalidDiscovery: 'Le document de découverte OIDC est invalide',
|
||||
networkError: "Erreur réseau lors de l'échange de jeton OIDC",
|
||||
badResponse: "Réponse inattendue lors de l'échange de jeton OIDC",
|
||||
noIdToken: "Le fournisseur OIDC n'a pas renvoyé de jeton d'identité",
|
||||
validationFailed: 'La validation du jeton OIDC a échoué',
|
||||
nonceMismatch: 'Le nonce OIDC ne correspond pas — possible attaque par rejeu',
|
||||
missingSubClaim: 'Le jeton OIDC est dépourvu de la revendication sub',
|
||||
noLinkedAccount: 'Aucun compte local est lié à cette identité OIDC',
|
||||
accountInactive: 'Votre compte est inactif',
|
||||
userResolutionFailed: 'Impossible de résoudre votre compte',
|
||||
internalError: 'Une erreur interne est survenue lors de la connexion OIDC',
|
||||
tokenExchangeFailed: "L'échange de jeton OIDC a échoué",
|
||||
},
|
||||
forgotPasswordTitle: 'Mot de passe oublié',
|
||||
forgotPasswordMessage: 'Contactez votre administrateur pour réinitialiser votre accès.',
|
||||
forgotPasswordEmailMessage: 'Entrez votre email pour recevoir un nouveau mot de passe.',
|
||||
|
|
@ -2183,6 +2278,33 @@ export default {
|
|||
resetStep3: 'Il vous donnera un mot de passe temporaire',
|
||||
resetStep4: 'Connectez-vous et changez-le dans les Paramètres',
|
||||
gotIt: 'Compris',
|
||||
twoFA: {
|
||||
title: 'Authentification à deux facteurs',
|
||||
subtitle: 'Votre compte est protégé par la 2FA. Saisissez le code de vérification ci-dessous.',
|
||||
methodAuthenticator: "Application d'authentification",
|
||||
methodEmail: 'Code par e-mail',
|
||||
methodBackup: 'Code de récupération',
|
||||
instructionsTotp: "Ouvrez votre application d'authentification et saisissez le code à 6 chiffres pour Bambuddy.",
|
||||
instructionsEmail: 'Un code à 6 chiffres a été envoyé à votre adresse e-mail. Il est valable 10 minutes.',
|
||||
instructionsEmailNotSent: 'Cliquez ci-dessous pour recevoir un code de vérification par e-mail.',
|
||||
instructionsBackup: "Saisissez l'un de vos codes de récupération à 8 caractères. Chaque code ne peut être utilisé qu'une seule fois.",
|
||||
sendCodeButton: 'Envoyer le code par e-mail',
|
||||
sendingCode: 'Envoi en cours...',
|
||||
resendCode: 'Renvoyer le code',
|
||||
codeLabel: 'Code de vérification',
|
||||
backupCodeLabel: 'Code de récupération',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: 'Vérifier',
|
||||
verifyingButton: 'Vérification en cours...',
|
||||
backToLogin: '← Retour à la connexion',
|
||||
orContinueWith: 'ou continuer avec',
|
||||
signInWith: 'Se connecter avec {{provider}}',
|
||||
enterCode: 'Veuillez entrer le code de vérification',
|
||||
sendCodeFailed: 'Échec de l\'envoi du code de vérification',
|
||||
invalidCode: 'Code invalide. Veuillez réessayer.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ export default {
|
|||
more: '+{{count}} altre',
|
||||
ascending: 'Crescente',
|
||||
descending: 'Decrescente',
|
||||
back: 'Indietro',
|
||||
copy: 'Copia',
|
||||
copied: 'Copiato!',
|
||||
printer: 'Stampante',
|
||||
remove: 'Rimuovi',
|
||||
type: 'Tipo',
|
||||
|
|
@ -1324,6 +1327,8 @@ export default {
|
|||
backup: 'Backup',
|
||||
emailAuth: 'Autenticazione Email',
|
||||
ldap: 'LDAP',
|
||||
twoFa: 'Autenticazione 2FA',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
ldap: {
|
||||
title: 'Autenticazione LDAP',
|
||||
|
|
@ -2038,6 +2043,74 @@ export default {
|
|||
deleteUserAndItems: 'Elimina utente E i suoi elementi',
|
||||
deleteUserKeepItems: 'Elimina utente, mantieni elementi (diventeranno senza proprietario)',
|
||||
ok: 'OK',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: 'App Authenticator (TOTP)',
|
||||
totpDesc: 'Usa un\'app come Google Authenticator, Aegis o Authy.',
|
||||
emailOtpTitle: 'OTP via e-mail',
|
||||
emailOtpDesc: 'Invia un codice monouso a {{email}} al momento del login.',
|
||||
emailOtpNoEmail: 'Aggiungi un indirizzo e-mail al tuo account per abilitare questo metodo.',
|
||||
addEmailFirst: 'Il tuo account non ha un indirizzo e-mail. Chiedi a un amministratore di aggiungerne uno.',
|
||||
setupTotp: 'Configura app Authenticator',
|
||||
setupAuthApp: 'Configura app Authenticator',
|
||||
setupInstructions: 'Scansiona il codice QR con la tua app authenticator, poi conferma con un codice.',
|
||||
manualEntry: 'Impossibile scansionare? Inserisci questo segreto manualmente:',
|
||||
scannedContinue: 'Codice scansionato — continua',
|
||||
enterCodeToConfirm: 'Inserisci il codice a 6 cifre dalla tua app authenticator per confermare.',
|
||||
activate: 'Attiva',
|
||||
disableTotp: 'Disabilita Authenticator',
|
||||
disableConfirmHint: 'Inserisci un codice TOTP valido o un codice di backup per disabilitare l\'authenticator.',
|
||||
totpDisabled: 'App Authenticator disabilitata.',
|
||||
emailOtpEnabled: 'OTP via e-mail abilitato.',
|
||||
emailOtpDisabled: 'OTP via e-mail disabilitato.',
|
||||
smtpRequired: 'Configura e testa prima le impostazioni SMTP.',
|
||||
invalidCode: 'Codice non valido. Riprova.',
|
||||
enableEmailOtp: 'Abilita OTP via e-mail',
|
||||
disableEmailOtp: 'Disabilita OTP via e-mail',
|
||||
emailSetupEnterCode: 'È stato inviato un codice di verifica al tuo indirizzo e-mail. Inseriscilo qui sotto per confermare che possiedi questa casella di posta.',
|
||||
verifyAndEnable: 'Verifica e abilita',
|
||||
emailDisablePasswordHint: 'Inserisci la password del tuo account per confermare la disabilitazione dell\'OTP via e-mail.',
|
||||
passwordPlaceholder: 'Inserisci la tua password',
|
||||
backupCodesTitle: 'Salva i tuoi codici di backup',
|
||||
backupCodesWarning: 'Conserva questi codici in un posto sicuro. Ogni codice può essere usato una sola volta.',
|
||||
backupCodesRemaining: '{{count}} codici di backup rimanenti',
|
||||
savedCodes: 'Codici salvati',
|
||||
regenBackup: 'Rigenera codici di backup',
|
||||
regenBackupHint: 'Inserisci il tuo codice TOTP corrente per generare 10 nuovi codici di backup.',
|
||||
newBackupCodes: 'Nuovi codici di backup',
|
||||
linkedAccounts: 'Account SSO collegati',
|
||||
linkedAccountsDesc: 'Questi provider di identità esterni sono collegati al tuo account.',
|
||||
oidcUnlinked: 'Account scollegato.',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'Provider SSO / OIDC',
|
||||
desc: 'Configura provider OpenID Connect per il single sign-on.',
|
||||
addProvider: 'Aggiungi provider',
|
||||
newProvider: 'Nuovo provider',
|
||||
empty: 'Nessun provider OIDC configurato.',
|
||||
created: 'Provider creato.',
|
||||
updated: 'Provider aggiornato.',
|
||||
deleted: 'Provider eliminato.',
|
||||
deleteTitle: 'Elimina provider',
|
||||
deleteMessage: 'Eliminare "{{name}}"? Tutti gli account collegati verranno disconnessi.',
|
||||
form: {
|
||||
name: 'Nome visualizzato',
|
||||
issuerUrl: 'URL emittente',
|
||||
clientId: 'Client ID',
|
||||
clientSecret: 'Client secret',
|
||||
scopes: 'Scope',
|
||||
iconUrl: 'URL icona (opzionale)',
|
||||
enabled: 'Abilitato',
|
||||
autoCreate: 'Crea utenti automaticamente',
|
||||
autoCreateDesc: 'Crea automaticamente un account locale al primo accesso.',
|
||||
secretHint: 'lascia vuoto per mantenere',
|
||||
secretPlaceholder: 'nuovo segreto',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
|
|
@ -2168,6 +2241,28 @@ export default {
|
|||
loginSuccess: 'Accesso riuscito',
|
||||
loginFailed: 'Accesso fallito',
|
||||
enterCredentials: 'Inserisci nome utente e password',
|
||||
enterEmail: 'Inserisci il tuo indirizzo e-mail',
|
||||
oidcLoginFailed: 'Accesso OIDC fallito',
|
||||
oidcErrors: {
|
||||
providerError: "Il provider di identità ha restituito un errore",
|
||||
missingParameters: 'Parametri obbligatori mancanti nel callback OIDC',
|
||||
invalidState: 'Lo stato OIDC non è valido o è già stato utilizzato',
|
||||
stateExpired: 'La sessione OIDC è scaduta — riprovare',
|
||||
providerNotFound: 'Provider OIDC non trovato',
|
||||
discoveryFailed: 'Impossibile recuperare il documento di discovery OIDC',
|
||||
invalidDiscovery: 'Il documento di discovery OIDC non è valido',
|
||||
networkError: "Errore di rete durante lo scambio di token OIDC",
|
||||
badResponse: "Risposta inattesa durante lo scambio di token OIDC",
|
||||
noIdToken: 'Il provider OIDC non ha restituito un ID token',
|
||||
validationFailed: 'La validazione del token OIDC non è riuscita',
|
||||
nonceMismatch: 'Il nonce OIDC non corrisponde — possibile attacco di replay',
|
||||
missingSubClaim: 'Il token OIDC è privo del claim sub',
|
||||
noLinkedAccount: 'Nessun account locale è collegato a questa identità OIDC',
|
||||
accountInactive: 'Il tuo account è inattivo',
|
||||
userResolutionFailed: 'Impossibile risolvere il tuo account',
|
||||
internalError: "Si è verificato un errore interno durante il login OIDC",
|
||||
tokenExchangeFailed: 'Lo scambio di token OIDC non è riuscito',
|
||||
},
|
||||
forgotPasswordTitle: 'Password dimenticata',
|
||||
forgotPasswordMessage: 'Se hai dimenticato la password, contatta il tuo amministratore di sistema per reimpostarla.',
|
||||
forgotPasswordEmailMessage: 'Inserisci il tuo indirizzo email e ti invieremo una nuova password.',
|
||||
|
|
@ -2182,6 +2277,33 @@ export default {
|
|||
resetStep3: 'Possono impostare una nuova password temporanea',
|
||||
resetStep4: 'Accedi con la nuova password e cambiala in Impostazioni',
|
||||
gotIt: 'Capito',
|
||||
twoFA: {
|
||||
title: 'Autenticazione a due fattori',
|
||||
subtitle: 'Il tuo account è protetto da 2FA. Inserisci il codice di verifica qui sotto.',
|
||||
methodAuthenticator: 'App di autenticazione',
|
||||
methodEmail: 'Codice via e-mail',
|
||||
methodBackup: 'Codice di recupero',
|
||||
instructionsTotp: "Apri la tua app di autenticazione e inserisci il codice a 6 cifre per Bambuddy.",
|
||||
instructionsEmail: "Un codice a 6 cifre è stato inviato al tuo indirizzo e-mail. È valido per 10 minuti.",
|
||||
instructionsEmailNotSent: 'Clicca il pulsante qui sotto per ricevere un codice di verifica via e-mail.',
|
||||
instructionsBackup: 'Inserisci uno dei tuoi codici di recupero a 8 caratteri. Ogni codice può essere utilizzato una sola volta.',
|
||||
sendCodeButton: 'Invia codice via e-mail',
|
||||
sendingCode: 'Invio in corso...',
|
||||
resendCode: 'Invia nuovamente il codice',
|
||||
codeLabel: 'Codice di verifica',
|
||||
backupCodeLabel: 'Codice di recupero',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: 'Verifica',
|
||||
verifyingButton: 'Verifica in corso...',
|
||||
backToLogin: '← Torna alla pagina di accesso',
|
||||
orContinueWith: 'oppure accedi con',
|
||||
signInWith: 'Accedi con {{provider}}',
|
||||
enterCode: 'Inserisci il codice di verifica',
|
||||
sendCodeFailed: 'Invio del codice di verifica non riuscito',
|
||||
invalidCode: 'Codice non valido. Riprova.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ export default {
|
|||
more: 'もっと見る',
|
||||
ascending: '昇順',
|
||||
descending: '降順',
|
||||
back: '戻る',
|
||||
copy: 'コピー',
|
||||
copied: 'コピーしました!',
|
||||
printer: 'プリンター',
|
||||
remove: '削除',
|
||||
type: '種類',
|
||||
|
|
@ -1324,6 +1327,8 @@ export default {
|
|||
backup: 'バックアップ',
|
||||
emailAuth: 'メール認証',
|
||||
ldap: 'LDAP',
|
||||
twoFa: '二段階認証',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
spoolbuddy: {
|
||||
infoTitle: 'SpoolBuddy デバイス',
|
||||
|
|
@ -2077,6 +2082,74 @@ export default {
|
|||
deleteUserAndItems: 'ユーザーとそのアイテムを削除',
|
||||
deleteUserKeepItems: 'ユーザーを削除、アイテムは保持(オーナーなしになります)',
|
||||
ok: 'OK',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: '認証アプリ (TOTP)',
|
||||
totpDesc: 'Google Authenticator、Aegis、Authyなどのアプリを使用します。',
|
||||
emailOtpTitle: 'メールOTP',
|
||||
emailOtpDesc: 'ログイン時に{{email}}にワンタイムコードを送信します。',
|
||||
emailOtpNoEmail: 'この方法を有効にするには、アカウントにメールアドレスを追加してください。',
|
||||
addEmailFirst: 'アカウントにメールアドレスがありません。管理者に追加を依頼してください。',
|
||||
setupTotp: '認証アプリを設定',
|
||||
setupAuthApp: '認証アプリを設定',
|
||||
setupInstructions: '認証アプリでQRコードをスキャンし、コードで確認してください。',
|
||||
manualEntry: 'スキャンできない場合は、このシークレットを手動で入力してください:',
|
||||
scannedContinue: 'コードをスキャンしました — 続ける',
|
||||
enterCodeToConfirm: '認証アプリの6桁のコードを入力して設定を確認してください。',
|
||||
activate: '有効化',
|
||||
disableTotp: '認証アプリを無効化',
|
||||
disableConfirmHint: '認証アプリを無効にするには、有効なTOTPコードまたはバックアップコードを入力してください。',
|
||||
totpDisabled: '認証アプリが無効化されました。',
|
||||
emailOtpEnabled: 'メールOTPが有効化されました。',
|
||||
emailOtpDisabled: 'メールOTPが無効化されました。',
|
||||
smtpRequired: '先にSMTP設定を構成してテストしてください。',
|
||||
invalidCode: '無効なコードです。もう一度お試しください。',
|
||||
enableEmailOtp: 'メールOTPを有効化',
|
||||
disableEmailOtp: 'メールOTPを無効化',
|
||||
emailSetupEnterCode: '確認コードがメールアドレスに送信されました。このメールボックスを所有していることを確認するために、以下に入力してください。',
|
||||
verifyAndEnable: '確認して有効化',
|
||||
emailDisablePasswordHint: 'メールOTPの無効化を確認するには、アカウントのパスワードを入力してください。',
|
||||
passwordPlaceholder: 'パスワードを入力してください',
|
||||
backupCodesTitle: 'バックアップコードを保存',
|
||||
backupCodesWarning: 'これらのコードを安全な場所に保存してください。各コードは一度しか使用できません。',
|
||||
backupCodesRemaining: 'バックアップコード残り{{count}}個',
|
||||
savedCodes: 'コードを保存しました',
|
||||
regenBackup: 'バックアップコードを再生成',
|
||||
regenBackupHint: '現在のTOTPコードを入力して10個の新しいバックアップコードを生成します。',
|
||||
newBackupCodes: '新しいバックアップコード',
|
||||
linkedAccounts: 'リンクされたSSOアカウント',
|
||||
linkedAccountsDesc: 'これらの外部IDプロバイダーがあなたのアカウントにリンクされています。',
|
||||
oidcUnlinked: 'アカウントのリンクを解除しました。',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'SSO / OIDCプロバイダー',
|
||||
desc: 'シングルサインオン用のOpenID Connectプロバイダーを設定します。',
|
||||
addProvider: 'プロバイダーを追加',
|
||||
newProvider: '新しいプロバイダー',
|
||||
empty: 'OIDCプロバイダーがまだ設定されていません。',
|
||||
created: 'プロバイダーが作成されました。',
|
||||
updated: 'プロバイダーが更新されました。',
|
||||
deleted: 'プロバイダーが削除されました。',
|
||||
deleteTitle: 'プロバイダーを削除',
|
||||
deleteMessage: '"{{name}}"を削除しますか?リンクされたすべてのユーザーアカウントが切断されます。',
|
||||
form: {
|
||||
name: '表示名',
|
||||
issuerUrl: '発行者URL',
|
||||
clientId: 'クライアントID',
|
||||
clientSecret: 'クライアントシークレット',
|
||||
scopes: 'スコープ',
|
||||
iconUrl: 'アイコンURL (任意)',
|
||||
enabled: '有効',
|
||||
autoCreate: 'ユーザーを自動作成',
|
||||
autoCreateDesc: '初回ログイン時にローカルアカウントを自動的に作成します。',
|
||||
secretHint: '空白のままで現在のものを維持',
|
||||
secretPlaceholder: '新しいシークレット',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
|
|
@ -2207,6 +2280,28 @@ export default {
|
|||
loginSuccess: 'ログインしました',
|
||||
loginFailed: 'ログインに失敗しました',
|
||||
enterCredentials: 'ユーザー名とパスワードを入力してください',
|
||||
enterEmail: 'メールアドレスを入力してください',
|
||||
oidcLoginFailed: 'OIDCログインに失敗しました',
|
||||
oidcErrors: {
|
||||
providerError: 'IDプロバイダーがエラーを返しました',
|
||||
missingParameters: 'OIDCコールバックに必須パラメーターがありません',
|
||||
invalidState: 'OIDCの状態が無効か、すでに使用されています',
|
||||
stateExpired: 'OIDCログインセッションが期限切れです。もう一度お試しください',
|
||||
providerNotFound: 'OIDCプロバイダーが見つかりません',
|
||||
discoveryFailed: 'OIDCディスカバリードキュメントの取得に失敗しました',
|
||||
invalidDiscovery: 'OIDCディスカバリードキュメントが無効です',
|
||||
networkError: 'OIDCトークン交換中にネットワークエラーが発生しました',
|
||||
badResponse: 'OIDCトークン交換中に予期しない応答を受信しました',
|
||||
noIdToken: 'OIDCプロバイダーがIDトークンを返しませんでした',
|
||||
validationFailed: 'OIDCトークンの検証に失敗しました',
|
||||
nonceMismatch: 'OIDCノンスが一致しません。リプレイ攻撃の可能性があります',
|
||||
missingSubClaim: 'OIDCトークンにsubクレームがありません',
|
||||
noLinkedAccount: 'このOIDCアイデンティティに関連付けられたローカルアカウントがありません',
|
||||
accountInactive: 'あなたのアカウントは無効です',
|
||||
userResolutionFailed: 'アカウントを解決できませんでした',
|
||||
internalError: 'OIDCログイン中に内部エラーが発生しました',
|
||||
tokenExchangeFailed: 'OIDCトークン交換に失敗しました',
|
||||
},
|
||||
forgotPasswordTitle: 'パスワードを忘れた場合',
|
||||
forgotPasswordMessage: 'パスワードを忘れた場合は、システム管理者に連絡してリセットしてもらってください。',
|
||||
forgotPasswordEmailMessage: 'メールアドレスを入力すると、新しいパスワードを送信します。',
|
||||
|
|
@ -2221,6 +2316,33 @@ export default {
|
|||
resetStep3: '管理者が新しい仮パスワードを設定',
|
||||
resetStep4: '新しいパスワードでログインし、設定で変更',
|
||||
gotIt: '了解',
|
||||
twoFA: {
|
||||
title: '二段階認証',
|
||||
subtitle: 'アカウントは二段階認証で保護されています。確認コードを入力してください。',
|
||||
methodAuthenticator: '認証アプリ',
|
||||
methodEmail: 'メール認証',
|
||||
methodBackup: 'バックアップコード',
|
||||
instructionsTotp: '認証アプリを開いて、Bambuddy用の6桁のコードを入力してください。',
|
||||
instructionsEmail: '6桁の確認コードをメールアドレスに送信しました。有効期限は10分です。',
|
||||
instructionsEmailNotSent: '下のボタンをクリックして、メールで確認コードを受け取ってください。',
|
||||
instructionsBackup: '8文字のバックアップコードをいずれか1つ入力してください。各コードは1回のみ使用可能です。',
|
||||
sendCodeButton: 'メールでコードを送信する',
|
||||
sendingCode: '送信中...',
|
||||
resendCode: 'コードを再送する',
|
||||
codeLabel: '確認コード',
|
||||
backupCodeLabel: 'バックアップコード',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: '確認する',
|
||||
verifyingButton: '確認中...',
|
||||
backToLogin: '← ログイン画面に戻る',
|
||||
orContinueWith: 'または以下でログイン',
|
||||
signInWith: '{{provider}}でログイン',
|
||||
enterCode: '確認コードを入力してください',
|
||||
sendCodeFailed: '確認コードの送信に失敗しました',
|
||||
invalidCode: '無効なコードです。もう一度お試しください。',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ export default {
|
|||
more: '+{{count}} mais',
|
||||
ascending: 'Crescente',
|
||||
descending: 'Decrescente',
|
||||
back: 'Voltar',
|
||||
copy: 'Copiar',
|
||||
copied: 'Copiado!',
|
||||
printer: 'Impressora',
|
||||
remove: 'Remover',
|
||||
type: 'Tipo',
|
||||
|
|
@ -1324,6 +1327,8 @@ export default {
|
|||
backup: 'Backup',
|
||||
emailAuth: 'Autenticação por Email',
|
||||
ldap: 'LDAP',
|
||||
twoFa: 'Autenticação 2FA',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
ldap: {
|
||||
title: 'Autenticação LDAP',
|
||||
|
|
@ -2038,6 +2043,74 @@ export default {
|
|||
deleteUserAndItems: 'Excluir usuário E seus itens',
|
||||
deleteUserKeepItems: 'Excluir usuário, manter itens (ficarão sem dono)',
|
||||
ok: 'OK',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: 'App Autenticador (TOTP)',
|
||||
totpDesc: 'Use um app como Google Authenticator, Aegis ou Authy.',
|
||||
emailOtpTitle: 'OTP por e-mail',
|
||||
emailOtpDesc: 'Envie um código único para {{email}} ao fazer login.',
|
||||
emailOtpNoEmail: 'Adicione um endereço de e-mail à sua conta para ativar este método.',
|
||||
addEmailFirst: 'Sua conta não tem endereço de e-mail. Peça a um administrador para adicionar um.',
|
||||
setupTotp: 'Configurar App Autenticador',
|
||||
setupAuthApp: 'Configurar App Autenticador',
|
||||
setupInstructions: 'Escaneie o código QR com seu app autenticador e confirme com um código.',
|
||||
manualEntry: 'Não consegue escanear? Digite este segredo manualmente:',
|
||||
scannedContinue: 'Código escaneado — continuar',
|
||||
enterCodeToConfirm: 'Digite o código de 6 dígitos do seu app autenticador para confirmar.',
|
||||
activate: 'Ativar',
|
||||
disableTotp: 'Desativar Autenticador',
|
||||
disableConfirmHint: 'Digite um código TOTP válido ou um código de backup para desativar o autenticador.',
|
||||
totpDisabled: 'App autenticador desativado.',
|
||||
emailOtpEnabled: 'OTP por e-mail ativado.',
|
||||
emailOtpDisabled: 'OTP por e-mail desativado.',
|
||||
smtpRequired: 'Por favor, configure e teste as configurações SMTP primeiro.',
|
||||
invalidCode: 'Código inválido. Por favor, tente novamente.',
|
||||
enableEmailOtp: 'Ativar OTP por e-mail',
|
||||
disableEmailOtp: 'Desativar OTP por e-mail',
|
||||
emailSetupEnterCode: 'Um código de verificação foi enviado para o seu endereço de e-mail. Digite-o abaixo para confirmar que você possui esta caixa de entrada.',
|
||||
verifyAndEnable: 'Verificar e Ativar',
|
||||
emailDisablePasswordHint: 'Digite a senha da sua conta para confirmar a desativação do OTP por e-mail.',
|
||||
passwordPlaceholder: 'Digite sua senha',
|
||||
backupCodesTitle: 'Salve seus códigos de backup',
|
||||
backupCodesWarning: 'Guarde estes códigos em lugar seguro. Cada código só pode ser usado uma vez.',
|
||||
backupCodesRemaining: '{{count}} códigos de backup restantes',
|
||||
savedCodes: 'Códigos salvos',
|
||||
regenBackup: 'Regenerar códigos de backup',
|
||||
regenBackupHint: 'Digite seu código TOTP atual para gerar 10 novos códigos de backup.',
|
||||
newBackupCodes: 'Novos códigos de backup',
|
||||
linkedAccounts: 'Contas SSO vinculadas',
|
||||
linkedAccountsDesc: 'Estes provedores de identidade externos estão vinculados à sua conta.',
|
||||
oidcUnlinked: 'Conta desvinculada.',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'Provedores SSO / OIDC',
|
||||
desc: 'Configure provedores OpenID Connect para login único.',
|
||||
addProvider: 'Adicionar provedor',
|
||||
newProvider: 'Novo provedor',
|
||||
empty: 'Nenhum provedor OIDC configurado ainda.',
|
||||
created: 'Provedor criado.',
|
||||
updated: 'Provedor atualizado.',
|
||||
deleted: 'Provedor excluído.',
|
||||
deleteTitle: 'Excluir provedor',
|
||||
deleteMessage: 'Excluir "{{name}}"? Todas as contas vinculadas serão desconectadas.',
|
||||
form: {
|
||||
name: 'Nome de exibição',
|
||||
issuerUrl: 'URL do emissor',
|
||||
clientId: 'Client ID',
|
||||
clientSecret: 'Client secret',
|
||||
scopes: 'Escopos',
|
||||
iconUrl: 'URL do ícone (opcional)',
|
||||
enabled: 'Ativado',
|
||||
autoCreate: 'Criar usuários automaticamente',
|
||||
autoCreateDesc: 'Cria automaticamente uma conta local no primeiro login.',
|
||||
secretHint: 'deixe em branco para manter',
|
||||
secretPlaceholder: 'novo segredo',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
|
|
@ -2168,6 +2241,28 @@ export default {
|
|||
loginSuccess: 'Login realizado com sucesso',
|
||||
loginFailed: 'Falha no login',
|
||||
enterCredentials: 'Por favor, insira nome de usuário e senha',
|
||||
enterEmail: 'Por favor, insira seu endereço de e-mail',
|
||||
oidcLoginFailed: 'Falha no login OIDC',
|
||||
oidcErrors: {
|
||||
providerError: 'O provedor de identidade retornou um erro',
|
||||
missingParameters: 'Parâmetros obrigatórios ausentes no callback OIDC',
|
||||
invalidState: 'Estado OIDC inválido ou já utilizado',
|
||||
stateExpired: 'Sessão OIDC expirada — tente novamente',
|
||||
providerNotFound: 'Provedor OIDC não encontrado',
|
||||
discoveryFailed: 'Falha ao obter o documento de descoberta OIDC',
|
||||
invalidDiscovery: 'Documento de descoberta OIDC inválido',
|
||||
networkError: 'Erro de rede durante a troca de token OIDC',
|
||||
badResponse: 'Resposta inesperada durante a troca de token OIDC',
|
||||
noIdToken: 'O provedor OIDC não retornou um token de ID',
|
||||
validationFailed: 'Falha na validação do token OIDC',
|
||||
nonceMismatch: 'Nonce OIDC não corresponde — possível ataque de replay',
|
||||
missingSubClaim: 'Token OIDC sem claim sub',
|
||||
noLinkedAccount: 'Nenhuma conta local vinculada a esta identidade OIDC',
|
||||
accountInactive: 'Sua conta está inativa',
|
||||
userResolutionFailed: 'Falha ao resolver sua conta',
|
||||
internalError: 'Erro interno durante o login OIDC',
|
||||
tokenExchangeFailed: 'Falha na troca de token OIDC',
|
||||
},
|
||||
forgotPasswordTitle: 'Esqueceu a Senha',
|
||||
forgotPasswordMessage: 'Se você esqueceu sua senha, entre em contato com o administrador do sistema para redefini-la.',
|
||||
forgotPasswordEmailMessage: 'Digite seu endereço de email e enviaremos uma nova senha.',
|
||||
|
|
@ -2182,6 +2277,47 @@ export default {
|
|||
resetStep3: 'Eles podem definir uma nova senha temporária para você',
|
||||
resetStep4: 'Faça login com a nova senha e altere-a nas Configurações',
|
||||
gotIt: 'Entendi',
|
||||
resetPassword: {
|
||||
title: 'Definir nova senha',
|
||||
subtitle: 'Digite e confirme sua nova senha abaixo.',
|
||||
newPassword: 'Nova senha',
|
||||
newPasswordPlaceholder: 'Pelo menos 8 caracteres',
|
||||
confirmPassword: 'Confirmar senha',
|
||||
confirmPasswordPlaceholder: 'Repetir nova senha',
|
||||
saving: 'Salvando\u2026',
|
||||
submit: 'Definir nova senha',
|
||||
backToLogin: 'Voltar para o login',
|
||||
passwordsDoNotMatch: 'As senhas não coincidem',
|
||||
passwordTooShort: 'A senha deve ter pelo menos 8 caracteres',
|
||||
resetFailed: 'Falha ao redefinir senha. O link pode ter expirado.',
|
||||
},
|
||||
twoFA: {
|
||||
title: 'Autenticação em dois fatores',
|
||||
subtitle: 'Sua conta está protegida com 2FA. Insira o código de verificação abaixo.',
|
||||
methodAuthenticator: 'Aplicativo autenticador',
|
||||
methodEmail: 'Código por e-mail',
|
||||
methodBackup: 'Código de recuperação',
|
||||
instructionsTotp: 'Abra seu aplicativo autenticador e insira o código de 6 dígitos gerado para o Bambuddy.',
|
||||
instructionsEmail: 'Um código de 6 dígitos foi enviado para o seu e-mail. Ele é válido por 10 minutos.',
|
||||
instructionsEmailNotSent: 'Clique no botão abaixo para receber um código de verificação por e-mail.',
|
||||
instructionsBackup: 'Insira um dos seus códigos de recuperação de 8 caracteres. Cada código só pode ser utilizado uma vez.',
|
||||
sendCodeButton: 'Enviar código por e-mail',
|
||||
sendingCode: 'Enviando...',
|
||||
resendCode: 'Reenviar código',
|
||||
codeLabel: 'Código de verificação',
|
||||
backupCodeLabel: 'Código de recuperação',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: 'Verificar',
|
||||
verifyingButton: 'Verificando...',
|
||||
backToLogin: '← Voltar para o login',
|
||||
orContinueWith: 'ou entrar com',
|
||||
signInWith: 'Entrar com {{provider}}',
|
||||
enterCode: 'Por favor, insira o código de verificação',
|
||||
sendCodeFailed: 'Falha ao enviar o código de verificação',
|
||||
invalidCode: 'Código inválido. Por favor, tente novamente.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ export default {
|
|||
more: '还有 {{count}} 个',
|
||||
ascending: '升序',
|
||||
descending: '降序',
|
||||
back: '返回',
|
||||
copy: '复制',
|
||||
copied: '已复制!',
|
||||
printer: '打印机',
|
||||
remove: '移除',
|
||||
type: '类型',
|
||||
|
|
@ -1324,6 +1327,8 @@ export default {
|
|||
backup: '备份',
|
||||
emailAuth: '邮箱认证',
|
||||
ldap: 'LDAP',
|
||||
twoFa: '双因素认证',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
ldap: {
|
||||
title: 'LDAP 认证',
|
||||
|
|
@ -2038,6 +2043,74 @@ export default {
|
|||
deleteUserAndItems: '删除用户及其所有项目',
|
||||
deleteUserKeepItems: '删除用户,保留项目(将变为无主项目)',
|
||||
ok: '确定',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: '身份验证器应用 (TOTP)',
|
||||
totpDesc: '使用 Google Authenticator、Aegis 或 Authy 等应用。',
|
||||
emailOtpTitle: '邮件 OTP',
|
||||
emailOtpDesc: '登录时向 {{email}} 发送一次性验证码。',
|
||||
emailOtpNoEmail: '请先为账户添加邮箱地址以启用此方式。',
|
||||
addEmailFirst: '您的账户没有邮箱地址,请联系管理员添加。',
|
||||
setupTotp: '设置身份验证器应用',
|
||||
setupAuthApp: '设置身份验证器应用',
|
||||
setupInstructions: '使用身份验证器应用扫描二维码,然后输入验证码确认。',
|
||||
manualEntry: '无法扫描?请手动输入此密钥:',
|
||||
scannedContinue: '已扫描 — 继续',
|
||||
enterCodeToConfirm: '请输入身份验证器应用中的6位验证码以确认设置。',
|
||||
activate: '激活',
|
||||
disableTotp: '停用身份验证器',
|
||||
disableConfirmHint: '请输入有效的 TOTP 码或备用码来停用身份验证器。',
|
||||
totpDisabled: '身份验证器应用已停用。',
|
||||
emailOtpEnabled: '邮件 OTP 已启用。',
|
||||
emailOtpDisabled: '邮件 OTP 已停用。',
|
||||
smtpRequired: '请先配置并测试SMTP设置。',
|
||||
invalidCode: '无效验证码,请重试。',
|
||||
enableEmailOtp: '启用邮件 OTP',
|
||||
disableEmailOtp: '停用邮件 OTP',
|
||||
emailSetupEnterCode: '验证码已发送至您的邮箱地址。请在下方输入以确认您拥有此邮箱。',
|
||||
verifyAndEnable: '验证并启用',
|
||||
emailDisablePasswordHint: '请输入您的账户密码以确认停用邮件 OTP。',
|
||||
passwordPlaceholder: '输入您的密码',
|
||||
backupCodesTitle: '保存备用码',
|
||||
backupCodesWarning: '请将这些码保存在安全的地方。每个码只能使用一次,且不会再次显示。',
|
||||
backupCodesRemaining: '剩余 {{count}} 个备用码',
|
||||
savedCodes: '已保存',
|
||||
regenBackup: '重新生成备用码',
|
||||
regenBackupHint: '输入当前 TOTP 码以生成 10 个新备用码,所有现有备用码将失效。',
|
||||
newBackupCodes: '新备用码',
|
||||
linkedAccounts: '已关联的 SSO 账户',
|
||||
linkedAccountsDesc: '以下外部身份提供商已与您的账户关联。',
|
||||
oidcUnlinked: '账户已解除关联。',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'SSO / OIDC 提供商',
|
||||
desc: '配置 OpenID Connect 提供商以实现单点登录。',
|
||||
addProvider: '添加提供商',
|
||||
newProvider: '新提供商',
|
||||
empty: '尚未配置 OIDC 提供商。',
|
||||
created: '提供商已创建。',
|
||||
updated: '提供商已更新。',
|
||||
deleted: '提供商已删除。',
|
||||
deleteTitle: '删除提供商',
|
||||
deleteMessage: '删除"{{name}}"?所有关联账户将断开连接。',
|
||||
form: {
|
||||
name: '显示名称',
|
||||
issuerUrl: '颁发者 URL',
|
||||
clientId: '客户端 ID',
|
||||
clientSecret: '客户端密钥',
|
||||
scopes: '作用域',
|
||||
iconUrl: '图标 URL(可选)',
|
||||
enabled: '已启用',
|
||||
autoCreate: '自动创建用户',
|
||||
autoCreateDesc: '首次登录时自动创建本地账户。',
|
||||
secretHint: '留空以保留当前',
|
||||
secretPlaceholder: '新密钥',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
|
|
@ -2168,6 +2241,28 @@ export default {
|
|||
loginSuccess: '登录成功',
|
||||
loginFailed: '登录失败',
|
||||
enterCredentials: '请输入用户名和密码',
|
||||
enterEmail: '请输入您的电子邮件地址',
|
||||
oidcLoginFailed: 'OIDC 登录失败',
|
||||
oidcErrors: {
|
||||
providerError: '身份提供商返回了一个错误',
|
||||
missingParameters: 'OIDC 回调缺少必要参数',
|
||||
invalidState: 'OIDC 状态无效或已被使用',
|
||||
stateExpired: 'OIDC 登录会话已过期,请重试',
|
||||
providerNotFound: '未找到 OIDC 提供商',
|
||||
discoveryFailed: '无法获取 OIDC 发现文档',
|
||||
invalidDiscovery: 'OIDC 发现文档无效',
|
||||
networkError: 'OIDC 令牌交换时出现网络错误',
|
||||
badResponse: 'OIDC 令牌交换时收到意外响应',
|
||||
noIdToken: 'OIDC 提供商未返回 ID 令牌',
|
||||
validationFailed: 'OIDC 令牌验证失败',
|
||||
nonceMismatch: 'OIDC nonce 不匹配,可能存在重放攻击',
|
||||
missingSubClaim: 'OIDC 令牌缺少 sub 声明',
|
||||
noLinkedAccount: '没有与此 OIDC 身份关联的本地帐户',
|
||||
accountInactive: '您的帐户已被停用',
|
||||
userResolutionFailed: '无法解析您的帐户',
|
||||
internalError: 'OIDC 登录过程中发生内部错误',
|
||||
tokenExchangeFailed: 'OIDC 令牌交换失败',
|
||||
},
|
||||
forgotPasswordTitle: '忘记密码',
|
||||
forgotPasswordMessage: '如果您忘记了密码,请联系系统管理员进行重置。',
|
||||
forgotPasswordEmailMessage: '输入您的邮箱地址,我们将向您发送新密码。',
|
||||
|
|
@ -2182,6 +2277,33 @@ export default {
|
|||
resetStep3: '他们可以为您设置一个临时密码',
|
||||
resetStep4: '使用新密码登录并在设置中修改密码',
|
||||
gotIt: '知道了',
|
||||
twoFA: {
|
||||
title: '两步验证',
|
||||
subtitle: '您的账户已启用两步验证。请在下方输入验证码。',
|
||||
methodAuthenticator: '身份验证器应用',
|
||||
methodEmail: '邮箱验证码',
|
||||
methodBackup: '备用恢复码',
|
||||
instructionsTotp: '请打开您的身份验证器应用,输入 Bambuddy 的 6 位验证码。',
|
||||
instructionsEmail: '6 位验证码已发送至您的邮箱,有效期为 10 分钟。',
|
||||
instructionsEmailNotSent: '点击下方按钮,通过邮件获取验证码。',
|
||||
instructionsBackup: '请输入您的一个 8 位备用恢复码。每个恢复码只能使用一次。',
|
||||
sendCodeButton: '发送邮箱验证码',
|
||||
sendingCode: '发送中...',
|
||||
resendCode: '重新发送验证码',
|
||||
codeLabel: '验证码',
|
||||
backupCodeLabel: '备用恢复码',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: '验证',
|
||||
verifyingButton: '验证中...',
|
||||
backToLogin: '← 返回登录页面',
|
||||
orContinueWith: '或通过以下方式登录',
|
||||
signInWith: '使用 {{provider}} 登录',
|
||||
enterCode: '请输入验证码',
|
||||
sendCodeFailed: '验证码发送失败',
|
||||
invalidCode: '无效验证码,请重试。',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
|
|
|||
|
|
@ -1,37 +1,153 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { X, Mail } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { X, Mail, Shield, Smartphone, Key } from 'lucide-react';
|
||||
import { api, type LoginResponse } from '../api/client';
|
||||
import { Card, CardHeader, CardContent } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
|
||||
type LoginStep = 'credentials' | '2fa' | 'reset-password';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const { login } = useAuth();
|
||||
const { login, loginWithToken } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const { mode } = useTheme();
|
||||
|
||||
// Credentials step state
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
||||
const [forgotEmail, setForgotEmail] = useState('');
|
||||
|
||||
// 2FA step state
|
||||
const [step, setStep] = useState<LoginStep>('credentials');
|
||||
const [preAuthToken, setPreAuthToken] = useState('');
|
||||
const [twoFAMethods, setTwoFAMethods] = useState<string[]>([]);
|
||||
const [twoFAMethod, setTwoFAMethod] = useState<'totp' | 'email' | 'backup'>('totp');
|
||||
const [twoFACode, setTwoFACode] = useState('');
|
||||
const [emailOTPSent, setEmailOTPSent] = useState(false);
|
||||
const twoFAInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// H-6: Password reset step state
|
||||
const [resetToken, setResetToken] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
// Check if advanced auth is enabled
|
||||
const { data: advancedAuthStatus } = useQuery({
|
||||
queryKey: ['advancedAuthStatus'],
|
||||
queryFn: () => api.getAdvancedAuthStatus(),
|
||||
});
|
||||
|
||||
// Fetch enabled OIDC providers for login buttons
|
||||
const { data: oidcProviders } = useQuery({
|
||||
queryKey: ['oidcProviders'],
|
||||
queryFn: () => api.getOIDCProviders(),
|
||||
});
|
||||
|
||||
// M-B: Detect #reset_token=... in the URL fragment and switch to the reset step.
|
||||
// Fragments are never sent to the server so the token never appears in access-logs
|
||||
// or Referer headers — mirrors the H-4 treatment of the OIDC token.
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
const token = hash.startsWith('#reset_token=') ? hash.slice('#reset_token='.length) : null;
|
||||
if (token) {
|
||||
setResetToken(token);
|
||||
setStep('reset-password');
|
||||
// Clear the fragment from the URL so it can't be bookmarked or re-triggered.
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Handle OIDC callback: if #oidc_token=... is present in the fragment, exchange it.
|
||||
// H-4: Read from the URL fragment (#) — fragments are never sent to the server
|
||||
// so the exchange token stays out of access logs and Referer headers.
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
const oidcToken = hash.startsWith('#oidc_token=') ? hash.slice('#oidc_token='.length) : null;
|
||||
const oidcError = searchParams.get('oidc_error');
|
||||
|
||||
if (oidcError) {
|
||||
// L-3: Whitelist known OIDC error codes so provider-controlled text is never
|
||||
// shown verbatim. Any unknown code falls back to a generic message.
|
||||
const KNOWN_OIDC_ERRORS: Record<string, string> = {
|
||||
oidc_provider_error: t('login.oidcErrors.providerError'),
|
||||
missing_parameters: t('login.oidcErrors.missingParameters'),
|
||||
invalid_state: t('login.oidcErrors.invalidState'),
|
||||
state_expired: t('login.oidcErrors.stateExpired'),
|
||||
provider_not_found: t('login.oidcErrors.providerNotFound'),
|
||||
discovery_failed: t('login.oidcErrors.discoveryFailed'),
|
||||
invalid_discovery_document: t('login.oidcErrors.invalidDiscovery'),
|
||||
token_exchange_network_error: t('login.oidcErrors.networkError'),
|
||||
token_exchange_bad_response: t('login.oidcErrors.badResponse'),
|
||||
no_id_token: t('login.oidcErrors.noIdToken'),
|
||||
token_validation_failed: t('login.oidcErrors.validationFailed'),
|
||||
nonce_mismatch: t('login.oidcErrors.nonceMismatch'),
|
||||
missing_sub_claim: t('login.oidcErrors.missingSubClaim'),
|
||||
no_linked_account: t('login.oidcErrors.noLinkedAccount'),
|
||||
account_inactive: t('login.oidcErrors.accountInactive'),
|
||||
user_resolution_failed: t('login.oidcErrors.userResolutionFailed'),
|
||||
internal_error: t('login.oidcErrors.internalError'),
|
||||
};
|
||||
// Dynamic codes like "token_exchange_<provider_code>" → generic message
|
||||
const errorMsg = KNOWN_OIDC_ERRORS[oidcError]
|
||||
?? (oidcError.startsWith('token_exchange_') ? t('login.oidcErrors.tokenExchangeFailed') : t('login.oidcLoginFailed'));
|
||||
showToast(errorMsg, 'error');
|
||||
// Remove query params from URL cleanly
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (oidcToken) {
|
||||
api.exchangeOIDCToken(oidcToken).then((resp: LoginResponse) => {
|
||||
if (resp.requires_2fa && resp.pre_auth_token) {
|
||||
// OIDC user has 2FA enabled — redirect to 2FA step
|
||||
setPreAuthToken(resp.pre_auth_token);
|
||||
const methods = resp.two_fa_methods ?? [];
|
||||
setTwoFAMethods(methods);
|
||||
if (methods.includes('totp')) setTwoFAMethod('totp');
|
||||
else if (methods.includes('email')) setTwoFAMethod('email');
|
||||
else setTwoFAMethod('backup');
|
||||
setStep('2fa');
|
||||
// Remove oidc_token from URL so page refresh doesn't re-trigger exchange
|
||||
navigate('/login', { replace: true });
|
||||
} else if (resp.access_token && resp.user) {
|
||||
loginWithToken(resp.access_token, resp.user);
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}).catch((err: Error) => {
|
||||
showToast(err.message || t('login.oidcLoginFailed'), 'error');
|
||||
navigate('/login', { replace: true });
|
||||
});
|
||||
}
|
||||
}, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// --- Step 1: Credentials login ---
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: () => login(username, password),
|
||||
onSuccess: () => {
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/');
|
||||
onSuccess: (resp: LoginResponse) => {
|
||||
if (resp.requires_2fa && resp.pre_auth_token) {
|
||||
// 2FA required — switch to verification step
|
||||
setPreAuthToken(resp.pre_auth_token);
|
||||
const methods = resp.two_fa_methods ?? [];
|
||||
setTwoFAMethods(methods);
|
||||
// Pick a sensible default method
|
||||
if (methods.includes('totp')) setTwoFAMethod('totp');
|
||||
else if (methods.includes('email')) setTwoFAMethod('email');
|
||||
else setTwoFAMethod('backup');
|
||||
setStep('2fa');
|
||||
} else if (resp.access_token && resp.user) {
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('login.loginFailed'), 'error');
|
||||
|
|
@ -50,6 +166,62 @@ export function LoginPage() {
|
|||
},
|
||||
});
|
||||
|
||||
// H-6: Mutation to set a new password using the reset token from the email link
|
||||
const resetPasswordMutation = useMutation({
|
||||
mutationFn: () => api.forgotPasswordConfirm(resetToken, newPassword),
|
||||
onSuccess: (data) => {
|
||||
showToast(data.message, 'success');
|
||||
setStep('credentials');
|
||||
setResetToken('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('login.resetPassword.resetFailed'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// --- Step 2: 2FA verification ---
|
||||
const sendEmailOTPMutation = useMutation({
|
||||
mutationFn: () => api.sendEmailOTP(preAuthToken),
|
||||
onSuccess: (data: { message: string; pre_auth_token?: string }) => {
|
||||
setEmailOTPSent(true);
|
||||
// Backend issues a fresh pre-auth token after consuming the original one
|
||||
if (data.pre_auth_token) setPreAuthToken(data.pre_auth_token);
|
||||
showToast(data.message, 'success');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('login.twoFA.sendCodeFailed'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const verify2FAMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.verify2FA({ pre_auth_token: preAuthToken, code: twoFACode, method: twoFAMethod }),
|
||||
onSuccess: (resp: LoginResponse) => {
|
||||
if (resp.access_token && resp.user) {
|
||||
loginWithToken(resp.access_token, resp.user);
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('login.twoFA.invalidCode'), 'error');
|
||||
setTwoFACode('');
|
||||
},
|
||||
});
|
||||
|
||||
// OIDC login
|
||||
const oidcLoginMutation = useMutation({
|
||||
mutationFn: (providerId: number) => api.getOIDCAuthorizeUrl(providerId),
|
||||
onSuccess: (data) => {
|
||||
window.location.href = data.auth_url;
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('login.oidcLoginFailed'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) {
|
||||
|
|
@ -59,15 +231,285 @@ export function LoginPage() {
|
|||
loginMutation.mutate();
|
||||
};
|
||||
|
||||
const handle2FASubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!twoFACode.trim()) {
|
||||
showToast(t('login.twoFA.enterCode'), 'error');
|
||||
return;
|
||||
}
|
||||
verify2FAMutation.mutate();
|
||||
};
|
||||
|
||||
const handleForgotPassword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!forgotEmail) {
|
||||
showToast('Please enter your email address', 'error');
|
||||
showToast(t('login.enterEmail'), 'error');
|
||||
return;
|
||||
}
|
||||
forgotPasswordMutation.mutate(forgotEmail);
|
||||
};
|
||||
|
||||
const handleMethodChange = (method: 'totp' | 'email' | 'backup') => {
|
||||
setTwoFAMethod(method);
|
||||
setTwoFACode('');
|
||||
setEmailOTPSent(false);
|
||||
// Re-focus the code input after method switch (autoFocus only fires on mount)
|
||||
setTimeout(() => twoFAInputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
// ---- Render: password-reset step (H-6) ----
|
||||
if (step === 'reset-password') {
|
||||
const handleResetSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newPassword !== confirmPassword) {
|
||||
showToast(t('login.resetPassword.passwordsDoNotMatch'), 'error');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
showToast(t('login.resetPassword.passwordTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
resetPasswordMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center">
|
||||
<Key className="w-7 h-7 text-bambu-green" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white">{t('login.resetPassword.title')}</h2>
|
||||
<p className="mt-2 text-sm text-bambu-gray">{t('login.resetPassword.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleResetSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="new-password" className="block text-sm font-medium text-white mb-2">
|
||||
{t('login.resetPassword.newPassword')}
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
required
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder={t('login.resetPassword.newPasswordPlaceholder')}
|
||||
autoFocus
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirm-password" className="block text-sm font-medium text-white mb-2">
|
||||
{t('login.resetPassword.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder={t('login.resetPassword.confirmPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={resetPasswordMutation.isPending || !newPassword || !confirmPassword}
|
||||
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{resetPasswordMutation.isPending ? t('login.resetPassword.saving') : t('login.resetPassword.submit')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep('credentials');
|
||||
setResetToken('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
}}
|
||||
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
|
||||
>
|
||||
{t('login.resetPassword.backToLogin')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Render: 2FA step ----
|
||||
if (step === '2fa') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center">
|
||||
<Shield className="w-7 h-7 text-bambu-green" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white">{t('login.twoFA.title')}</h2>
|
||||
<p className="mt-2 text-sm text-bambu-gray">{t('login.twoFA.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Method selector — only show if multiple methods available */}
|
||||
{twoFAMethods.length > 1 && (
|
||||
<div className="flex gap-2">
|
||||
{twoFAMethods.includes('totp') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMethodChange('totp')}
|
||||
className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
|
||||
twoFAMethod === 'totp'
|
||||
? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
|
||||
}`}
|
||||
>
|
||||
<Smartphone className="w-4 h-4" />
|
||||
{t('login.twoFA.methodAuthenticator')}
|
||||
</button>
|
||||
)}
|
||||
{twoFAMethods.includes('email') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMethodChange('email')}
|
||||
className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
|
||||
twoFAMethod === 'email'
|
||||
? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
{t('login.twoFA.methodEmail')}
|
||||
</button>
|
||||
)}
|
||||
{twoFAMethods.includes('backup') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMethodChange('backup')}
|
||||
className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
|
||||
twoFAMethod === 'backup'
|
||||
? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
|
||||
}`}
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
{t('login.twoFA.methodBackup')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handle2FASubmit} className="space-y-4">
|
||||
{/* Method-specific instructions */}
|
||||
{twoFAMethod === 'totp' && (
|
||||
<p className="text-sm text-bambu-gray">{t('login.twoFA.instructionsTotp')}</p>
|
||||
)}
|
||||
{twoFAMethod === 'email' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{emailOTPSent
|
||||
? t('login.twoFA.instructionsEmail')
|
||||
: t('login.twoFA.instructionsEmailNotSent')}
|
||||
</p>
|
||||
{!emailOTPSent && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => sendEmailOTPMutation.mutate()}
|
||||
disabled={sendEmailOTPMutation.isPending}
|
||||
>
|
||||
{sendEmailOTPMutation.isPending
|
||||
? t('login.twoFA.sendingCode')
|
||||
: t('login.twoFA.sendCodeButton')}
|
||||
</Button>
|
||||
)}
|
||||
{emailOTPSent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEmailOTPSent(false); sendEmailOTPMutation.mutate(); }}
|
||||
className="text-xs text-bambu-gray hover:text-bambu-green transition-colors"
|
||||
>
|
||||
{t('login.twoFA.resendCode')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{twoFAMethod === 'backup' && (
|
||||
<p className="text-sm text-bambu-gray">{t('login.twoFA.instructionsBackup')}</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="twofa-code" className="block text-sm font-medium text-white mb-2">
|
||||
{twoFAMethod === 'backup'
|
||||
? t('login.twoFA.backupCodeLabel')
|
||||
: t('login.twoFA.codeLabel')}
|
||||
</label>
|
||||
<input
|
||||
ref={twoFAInputRef}
|
||||
id="twofa-code"
|
||||
type="text"
|
||||
inputMode={twoFAMethod === 'backup' ? 'text' : 'numeric'}
|
||||
autoComplete="one-time-code"
|
||||
value={twoFACode}
|
||||
onChange={(e) => setTwoFACode(e.target.value.trim())}
|
||||
disabled={twoFAMethod === 'email' && !emailOTPSent}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray text-center tracking-widest text-xl font-mono focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-40"
|
||||
placeholder={twoFAMethod === 'backup'
|
||||
? t('login.twoFA.backupCodePlaceholder')
|
||||
: t('login.twoFA.codePlaceholder')}
|
||||
maxLength={twoFAMethod === 'backup' ? 8 : 6}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
verify2FAMutation.isPending ||
|
||||
!twoFACode.trim() ||
|
||||
(twoFAMethod === 'email' && !emailOTPSent)
|
||||
}
|
||||
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{verify2FAMutation.isPending
|
||||
? t('login.twoFA.verifyingButton')
|
||||
: t('login.twoFA.verifyButton')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep('credentials');
|
||||
setPreAuthToken('');
|
||||
setTwoFACode('');
|
||||
setEmailOTPSent(false);
|
||||
}}
|
||||
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
|
||||
>
|
||||
{t('login.twoFA.backToLogin')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Render: credentials step ----
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
|
||||
|
|
@ -92,7 +534,7 @@ export function LoginPage() {
|
|||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-white mb-2">
|
||||
{advancedAuthStatus?.advanced_auth_enabled
|
||||
? t('login.usernameOrEmail') || 'Username or Email'
|
||||
? t('login.usernameOrEmail')
|
||||
: t('login.username')}
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -103,7 +545,7 @@ export function LoginPage() {
|
|||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder={advancedAuthStatus?.advanced_auth_enabled
|
||||
? t('login.usernameOrEmailPlaceholder') || 'Enter your username or email'
|
||||
? t('login.usernameOrEmailPlaceholder')
|
||||
: t('login.usernamePlaceholder')}
|
||||
autoComplete="username"
|
||||
/>
|
||||
|
|
@ -111,7 +553,7 @@ export function LoginPage() {
|
|||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-white mb-2">
|
||||
{t('login.password')}
|
||||
{t('login.password') || 'Password'}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
|
|
@ -136,18 +578,49 @@ export function LoginPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{advancedAuthStatus?.advanced_auth_enabled && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotPassword(true)}
|
||||
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
|
||||
>
|
||||
{t('login.forgotPassword')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotPassword(true)}
|
||||
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
|
||||
>
|
||||
{t('login.forgotPassword')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* OIDC provider buttons */}
|
||||
{oidcProviders && oidcProviders.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-bambu-dark-tertiary" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-bambu-dark-secondary text-bambu-gray">{t('login.twoFA.orContinueWith')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{oidcProviders.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => oidcLoginMutation.mutate(provider.id)}
|
||||
disabled={oidcLoginMutation.isPending}
|
||||
className="w-full flex items-center justify-center gap-3 py-3 px-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary hover:border-bambu-green/50 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{provider.icon_url ? (
|
||||
<img src={provider.icon_url} alt="" className="w-5 h-5 object-contain" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-bambu-green" />
|
||||
)}
|
||||
{t('login.twoFA.signInWith', { provider: provider.name })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Forgot Password Modal */}
|
||||
|
|
@ -182,12 +655,12 @@ export function LoginPage() {
|
|||
{advancedAuthStatus?.advanced_auth_enabled ? (
|
||||
<form onSubmit={handleForgotPassword} className="space-y-4">
|
||||
<p className="text-bambu-gray text-sm">
|
||||
{t('login.forgotPasswordEmailMessage') || 'Enter your email address and we\'ll send you a new password.'}
|
||||
{t('login.forgotPasswordEmailMessage')}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label htmlFor="forgot-email" className="block text-sm font-medium text-white mb-2">
|
||||
{t('login.emailAddress') || 'Email Address'}
|
||||
{t('login.emailAddress')}
|
||||
</label>
|
||||
<input
|
||||
id="forgot-email"
|
||||
|
|
@ -196,7 +669,7 @@ export function LoginPage() {
|
|||
value={forgotEmail}
|
||||
onChange={(e) => setForgotEmail(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder={t('login.emailPlaceholder') || 'your.email@example.com'}
|
||||
placeholder={t('login.emailPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -210,7 +683,7 @@ export function LoginPage() {
|
|||
setForgotEmail('');
|
||||
}}
|
||||
>
|
||||
{t('login.cancel') || 'Cancel'}
|
||||
{t('login.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
@ -218,8 +691,8 @@ export function LoginPage() {
|
|||
disabled={forgotPasswordMutation.isPending}
|
||||
>
|
||||
{forgotPasswordMutation.isPending
|
||||
? (t('login.sending') || 'Sending...')
|
||||
: (t('login.sendResetEmail') || 'Send Reset Email')}
|
||||
? t('login.sending')
|
||||
: t('login.sendResetEmail')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
|
|||
import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
|
||||
import { EmailSettings } from '../components/EmailSettings';
|
||||
import { LDAPSettings } from '../components/LDAPSettings';
|
||||
import { TwoFactorSettings } from '../components/TwoFactorSettings';
|
||||
import { OIDCProviderSettings } from '../components/OIDCProviderSettings';
|
||||
import { APIBrowser } from '../components/APIBrowser';
|
||||
import { Toggle } from '../components/Toggle';
|
||||
import { virtualPrinterApi, spoolbuddyApi } from '../api/client';
|
||||
|
|
@ -40,7 +42,7 @@ import { Palette } from 'lucide-react';
|
|||
|
||||
const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
|
||||
type TabType = typeof validTabs[number];
|
||||
type UsersSubTab = 'users' | 'email' | 'ldap';
|
||||
type UsersSubTab = 'users' | 'email' | 'ldap' | 'twofa' | 'oidc';
|
||||
|
||||
const STORAGE_CATEGORY_COLORS: Record<string, string> = {
|
||||
database: 'bg-blue-600',
|
||||
|
|
@ -80,7 +82,7 @@ export function SettingsPage() {
|
|||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const { authEnabled, user, refreshAuth, hasPermission } = useAuth();
|
||||
const { authEnabled, user, isAdmin, refreshAuth, hasPermission } = useAuth();
|
||||
const {
|
||||
mode,
|
||||
darkStyle, darkBackground, darkAccent,
|
||||
|
|
@ -4403,6 +4405,30 @@ export function SettingsPage() {
|
|||
<span className="w-2 h-2 rounded-full bg-green-400" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUsersSubTab('twofa')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
|
||||
usersSubTab === 'twofa'
|
||||
? 'text-bambu-green border-bambu-green'
|
||||
: 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
{t('settings.tabs.twoFa')}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setUsersSubTab('oidc')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
|
||||
usersSubTab === 'oidc'
|
||||
? 'text-bambu-green border-bambu-green'
|
||||
: 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{t('settings.tabs.oidc')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Users Sub-tab */}
|
||||
|
|
@ -4729,6 +4755,18 @@ export function SettingsPage() {
|
|||
<LDAPSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usersSubTab === 'twofa' && (
|
||||
<div className="max-w-2xl">
|
||||
<TwoFactorSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usersSubTab === 'oidc' && isAdmin && (
|
||||
<div className="max-w-3xl">
|
||||
<OIDCProviderSettings />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,3 +79,9 @@ filterwarnings = [
|
|||
markers = [
|
||||
"docker: marks tests that run in Docker integration environment",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"cryptography>=46.0.7",
|
||||
"pyjwt>=2.12.1",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ psutil>=6.0.0
|
|||
PyJWT>=2.12.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
ldap3>=2.9.0
|
||||
pyotp>=2.9.0
|
||||
|
||||
# HTTP client (used for OIDC token exchange)
|
||||
httpx>=0.26.0
|
||||
|
||||
# Plate Detection (optional - enables build plate empty detection)
|
||||
opencv-python-headless>=4.8.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue