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:
Sn0rrii 2026-04-13 13:24:28 +02:00 committed by GitHub
parent 8af0966e68
commit ba1c97c808
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 10473 additions and 290 deletions

View file

@ -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))

View file

@ -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 (DELETERETURNING) 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
)

View file

@ -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")

View file

@ -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))

File diff suppressed because it is too large Load diff

View file

@ -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"}

View file

@ -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

View file

@ -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"),

View 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."
)

View file

@ -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)

View file

@ -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",
]

View 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),
)

View 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}>"

View file

@ -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)

View 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}>"

View 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}>"

View file

@ -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 68 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

View file

@ -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]:

View file

@ -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,
)

View file

@ -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()

View file

@ -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

View 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

File diff suppressed because it is too large Load diff

View file

@ -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"]

View 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}"
)

View 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"

View file

@ -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 -->

View file

@ -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 () => {

View file

@ -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();
});
});
});
});

View file

@ -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}`),

View 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>
);
}

View 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>
);
}

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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>
)}

View file

@ -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",
]

View file

@ -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