mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
feat(auth): SSO autologin + disable local username/password login (#1589)
Adds a global local_login_enabled setting plus a per-provider is_autologin flag on OIDCProvider so operators who run their own SSO enabled, or if the calling admin has no UserOIDCLink — either would lock everyone out. App-layer invariant: at most one provider can carry is_autologin; setting it on one clears it on every other. /auth/advanced-auth/status surfaces both new fields so the LoginPage decides UI in one query. The env-var bypass flips the reported local_login_enabled back to true so the SPA matches what the route will accept.
This commit is contained in:
parent
f9bf145477
commit
70857af393
31 changed files with 624 additions and 39 deletions
11
.env.example
11
.env.example
|
|
@ -55,3 +55,14 @@ LOG_TO_FILE=true
|
|||
# In Docker, also bind-mount the host path into the container at the same
|
||||
# location (see docker-compose.yml for the matching volume snippet).
|
||||
# BAMBUDDY_EXTERNAL_ROOTS=
|
||||
|
||||
# Local-login recovery bypass (#1589) — set to "true" / "1" / "yes" to
|
||||
# accept username + password credentials on /auth/login (and to allow the
|
||||
# /auth/forgot-password flow) even when the in-app setting "Disable local
|
||||
# login" is turned on. This is the documented "SSO is broken, let me back
|
||||
# in" path for an operator whose only normal sign-in route is via OIDC.
|
||||
# /auth/advanced-auth/status also reports local_login_enabled=true while
|
||||
# this is set, so the login page shows the credentials form to match.
|
||||
# LDAP is governed by its own ldap_enabled toggle and is not affected.
|
||||
# Leave unset for normal operation.
|
||||
# BAMBUDDY_LOCAL_LOGIN=true
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -112,6 +112,19 @@ _TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
|
|||
)
|
||||
|
||||
|
||||
# #1589: read at call time, not import time, so tests can monkeypatch os.environ
|
||||
# between cases without re-importing the module.
|
||||
def _local_login_env_bypass() -> bool:
|
||||
"""Return True when ``BAMBUDDY_LOCAL_LOGIN`` env var is set truthy.
|
||||
|
||||
Bypasses the ``local_login_enabled`` DB setting on the local-credentials
|
||||
code path AND the forgot-password endpoint so a server admin can recover
|
||||
an install whose SSO provider is unreachable. Accepted truthy values:
|
||||
``true``, ``1``, ``yes`` (case-insensitive).
|
||||
"""
|
||||
return os.environ.get("BAMBUDDY_LOCAL_LOGIN", "").strip().lower() in {"true", "1", "yes"}
|
||||
|
||||
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
"""Return the real client IP for rate-limiting purposes.
|
||||
|
||||
|
|
@ -378,6 +391,13 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
|
|||
client_ip = _get_client_ip(raw_request)
|
||||
await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20)
|
||||
|
||||
# Initialize `user` up front so every downstream branch can read/write
|
||||
# it without UnboundLocalError. The LDAP success path sets it inside its
|
||||
# own block; the local-credentials and email-credentials paths set it
|
||||
# below. The original code relied on the local-credentials path running
|
||||
# unconditionally to bind `user`; #1589 made that path skippable, so the
|
||||
# init has to live here.
|
||||
user = None
|
||||
# Check if LDAP is enabled
|
||||
ldap_user = None
|
||||
ldap_settings = await _get_ldap_settings(db)
|
||||
|
|
@ -415,12 +435,30 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
|
|||
logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e)
|
||||
ldap_user = None
|
||||
|
||||
# #1589: local username/password gate. LDAP keeps its own switch
|
||||
# (ldap_enabled) and is not affected — a delegated directory has its
|
||||
# own policy and lockouts and is closer to SSO than to local creds.
|
||||
# The env-var BAMBUDDY_LOCAL_LOGIN=true bypasses this gate so a server
|
||||
# admin can recover an install whose SSO provider is unreachable
|
||||
# without editing the DB.
|
||||
from backend.app.models.settings import Settings as _Settings_for_local_login
|
||||
|
||||
local_login_allowed = ldap_user is not None or _local_login_env_bypass()
|
||||
if not local_login_allowed:
|
||||
setting_row = await db.execute(
|
||||
select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
|
||||
)
|
||||
row = setting_row.scalar_one_or_none()
|
||||
# Default True when the row is absent — matches AppSettings default
|
||||
# so fresh installs and tests behave like every release before #1589.
|
||||
local_login_allowed = row is None or row.value.lower() == "true"
|
||||
|
||||
# Try username-based authentication (skip if already authenticated via LDAP)
|
||||
if not ldap_user:
|
||||
if not ldap_user and local_login_allowed:
|
||||
user = await authenticate_user(db, request.username, request.password)
|
||||
|
||||
# If username auth failed and advanced auth is enabled, try email-based authentication
|
||||
if not user and not ldap_user:
|
||||
if not user and not ldap_user and local_login_allowed:
|
||||
advanced_auth = await is_advanced_auth_enabled(db)
|
||||
if advanced_auth:
|
||||
user = await authenticate_user_by_email(db, request.username, request.password)
|
||||
|
|
@ -428,6 +466,11 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
|
|||
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)
|
||||
# Same generic 401 either way — never tell the client whether the
|
||||
# username exists or whether local login was disabled. The Settings
|
||||
# UI and /auth/advanced-auth/status are the channels for that state;
|
||||
# leaking it here would help credential-stuffing distinguish "local
|
||||
# disabled" from "wrong password" across an install fleet.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
|
|
@ -813,12 +856,39 @@ async def disable_advanced_auth(
|
|||
|
||||
@router.get("/advanced-auth/status")
|
||||
async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Get advanced authentication status."""
|
||||
"""Get advanced authentication status.
|
||||
|
||||
Surfaces ``local_login_enabled`` and ``autologin_provider_id`` (#1589)
|
||||
so the LoginPage can decide whether to render the credentials form and
|
||||
whether to redirect unauthenticated visitors directly to an SSO
|
||||
provider, in a single query. ``BAMBUDDY_LOCAL_LOGIN=true`` flips the
|
||||
reported value back to True so the recovery path is visible.
|
||||
"""
|
||||
from backend.app.models.oidc_provider import OIDCProvider
|
||||
from backend.app.models.settings import Settings as _Settings_for_local_login
|
||||
|
||||
advanced_auth_enabled = await is_advanced_auth_enabled(db)
|
||||
smtp_configured = await get_smtp_settings(db) is not None
|
||||
|
||||
setting_row = await db.execute(
|
||||
select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
|
||||
)
|
||||
row = setting_row.scalar_one_or_none()
|
||||
db_local_enabled = row is None or row.value.lower() == "true"
|
||||
local_login_enabled = db_local_enabled or _local_login_env_bypass()
|
||||
|
||||
# Autologin provider must be both flagged AND enabled — disabling a
|
||||
# provider should not silently keep redirecting visitors to it.
|
||||
autologin = await db.execute(
|
||||
select(OIDCProvider.id).where(OIDCProvider.is_autologin.is_(True), OIDCProvider.is_enabled.is_(True)).limit(1)
|
||||
)
|
||||
autologin_provider_id = autologin.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"advanced_auth_enabled": advanced_auth_enabled,
|
||||
"smtp_configured": smtp_configured,
|
||||
"local_login_enabled": local_login_enabled,
|
||||
"autologin_provider_id": autologin_provider_id,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -884,6 +954,21 @@ async def forgot_password(
|
|||
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.
|
||||
"""
|
||||
# #1589: forgot-password is a local-credentials flow — useless when local
|
||||
# login is disabled (the reset wouldn't grant access anyway). Same gate as
|
||||
# /auth/login, with the same env-var bypass for SSO-broken recovery.
|
||||
if not _local_login_env_bypass():
|
||||
from backend.app.models.settings import Settings as _Settings_for_local_login
|
||||
|
||||
setting_row = await db.execute(
|
||||
select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
|
||||
)
|
||||
row = setting_row.scalar_one_or_none()
|
||||
if row is not None and row.value.lower() != "true":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Local login is disabled — use SSO instead.",
|
||||
)
|
||||
# Check if advanced auth is enabled
|
||||
advanced_auth = await is_advanced_auth_enabled(db)
|
||||
if not advanced_auth:
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Requ
|
|||
from fastapi.responses import RedirectResponse
|
||||
from jwt import PyJWKClient
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, undefer
|
||||
|
||||
|
|
@ -1388,11 +1388,17 @@ async def create_oidc_provider(
|
|||
icon_content_type=icon_content_type,
|
||||
icon_etag=icon_etag,
|
||||
default_group_id=body.default_group_id,
|
||||
is_autologin=body.is_autologin,
|
||||
)
|
||||
# SEC-1 + SEC-6: runtime guard mirrors the OIDCProviderCreate model_validator in schemas/auth.py.
|
||||
# Catches any future path that bypasses Pydantic validation (direct ORM, scripts).
|
||||
_enforce_auto_link_safety(provider)
|
||||
db.add(provider)
|
||||
# #1589: at most one provider may be the autologin target. When a new one
|
||||
# is created with the flag set, clear it on all others first so the
|
||||
# session still satisfies the invariant after add.
|
||||
if body.is_autologin:
|
||||
await db.execute(update(OIDCProvider).where(OIDCProvider.is_autologin.is_(True)).values(is_autologin=False))
|
||||
await db.commit()
|
||||
await db.refresh(provider)
|
||||
return _build_provider_response(provider)
|
||||
|
|
@ -1471,6 +1477,16 @@ async def update_oidc_provider(
|
|||
# partial updates that each pass schema validation individually but are unsafe together.
|
||||
_enforce_auto_link_safety(provider)
|
||||
|
||||
# #1589: at most one provider may be the autologin target. Clear the flag
|
||||
# on every other provider when this one becomes the autologin. Excludes
|
||||
# the current row so SQLAlchemy doesn't fight our in-memory set above.
|
||||
if body.is_autologin is True:
|
||||
await db.execute(
|
||||
update(OIDCProvider)
|
||||
.where(OIDCProvider.id != provider.id, OIDCProvider.is_autologin.is_(True))
|
||||
.values(is_autologin=False)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(provider)
|
||||
return _build_provider_response(provider)
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import zipfile
|
|||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
|
||||
|
|
@ -139,6 +139,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
|
|||
"default_nozzle_offset_cali",
|
||||
"ldap_enabled",
|
||||
"ldap_auto_provision",
|
||||
"local_login_enabled",
|
||||
]:
|
||||
settings_dict[setting.key] = setting.value.lower() == "true"
|
||||
elif setting.key in [
|
||||
|
|
@ -204,11 +205,39 @@ async def get_settings(
|
|||
async def update_settings(
|
||||
settings_update: AppSettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Update application settings."""
|
||||
update_data = settings_update.model_dump(exclude_unset=True)
|
||||
|
||||
# Safety refusals on disabling local login (#1589). Two failure modes
|
||||
# would otherwise lock everyone out of the install:
|
||||
# 1. No enabled OIDC provider exists — nobody could authenticate.
|
||||
# 2. The caller has no UserOIDCLink — they would lock themselves out
|
||||
# even if other admins are linked.
|
||||
# Either case returns HTTP 400 instead of silently saving. The
|
||||
# ``BAMBUDDY_LOCAL_LOGIN=true`` env-var bypass on /auth/login is a
|
||||
# separate recovery path; the refusals here protect the *default*
|
||||
# configuration where the env var is absent.
|
||||
if update_data.get("local_login_enabled") is False:
|
||||
from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
|
||||
|
||||
enabled_count = await db.scalar(select(func.count(OIDCProvider.id)).where(OIDCProvider.is_enabled.is_(True)))
|
||||
if not enabled_count:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot disable local login: no OIDC provider is enabled.",
|
||||
)
|
||||
if current_user is not None:
|
||||
caller_links = await db.scalar(
|
||||
select(func.count(UserOIDCLink.id)).where(UserOIDCLink.user_id == current_user.id)
|
||||
)
|
||||
if not caller_links:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot disable local login: your account has no OIDC link, so you would lock yourself out.",
|
||||
)
|
||||
|
||||
# Check if any MQTT settings are being updated
|
||||
mqtt_keys = {
|
||||
"mqtt_enabled",
|
||||
|
|
|
|||
|
|
@ -3222,6 +3222,13 @@ async def run_migrations(conn):
|
|||
else:
|
||||
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT false")
|
||||
|
||||
# Migration: Add is_autologin column to oidc_providers (#1589). Postgres
|
||||
# rejects ``DEFAULT 0`` for BOOLEAN columns.
|
||||
if is_sqlite():
|
||||
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT 0")
|
||||
else:
|
||||
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
|
||||
|
||||
# Migration: Disambiguate the four ``user_print_*`` notification template
|
||||
# names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
|
||||
await _migrate_rename_user_print_template_names(conn)
|
||||
|
|
|
|||
|
|
@ -121,6 +121,13 @@ class OIDCProvider(Base):
|
|||
# SHA-256 hex of icon_data, served as the ETag header so clients can
|
||||
# revalidate via If-None-Match and receive 304 Not Modified.
|
||||
icon_etag: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
|
||||
# When True, the LoginPage redirects unauthenticated visitors straight to
|
||||
# this provider's authorize URL on mount (#1589). At most one provider can
|
||||
# carry this flag at a time; setting it on a new provider clears it on the
|
||||
# previous one. The frontend always falls back to the local form if the
|
||||
# authorize-URL fetch fails or times out, and ``/login?fallback=local``
|
||||
# plus ``BAMBUDDY_LOCAL_LOGIN=true`` provide a documented recovery path.
|
||||
is_autologin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
|
||||
|
||||
@property
|
||||
def has_icon(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -413,6 +413,7 @@ class OIDCProviderCreate(BaseModel):
|
|||
require_email_verified: bool = True
|
||||
icon_url: str | None = None
|
||||
default_group_id: int | None = None
|
||||
is_autologin: bool = False # #1589 — at most one provider may carry this
|
||||
|
||||
@field_validator("issuer_url")
|
||||
@classmethod
|
||||
|
|
@ -469,6 +470,7 @@ class OIDCProviderUpdate(BaseModel):
|
|||
require_email_verified: bool | None = None
|
||||
icon_url: str | None = None
|
||||
default_group_id: int | None = None
|
||||
is_autologin: bool | None = None # #1589
|
||||
|
||||
@field_validator("scopes")
|
||||
@classmethod
|
||||
|
|
@ -515,6 +517,7 @@ class OIDCProviderResponse(BaseModel):
|
|||
require_email_verified: bool = True
|
||||
icon_url: str | None = None
|
||||
default_group_id: int | None = None
|
||||
is_autologin: bool = False # #1589
|
||||
# Set explicitly in the route handler from `icon_content_type is not None`
|
||||
# rather than `@computed_field` (project policy) or `icon_data is not None`
|
||||
# (would trigger an async lazy-load on the deferred BLOB column).
|
||||
|
|
|
|||
|
|
@ -339,6 +339,21 @@ class AppSettings(BaseModel):
|
|||
description="JSON array of 3 fan-speed preset values in % (0-100). Empty = use defaults [50, 75, 100]",
|
||||
)
|
||||
|
||||
# Local login (#1589) — when False, /auth/login rejects username+password
|
||||
# credentials with HTTP 403 and the login page hides the credentials form,
|
||||
# leaving only the OIDC SSO provider buttons. LDAP is governed by its own
|
||||
# `ldap_enabled` toggle and is not affected. The env-var
|
||||
# ``BAMBUDDY_LOCAL_LOGIN=true`` bypasses this gate at the route level so a
|
||||
# server admin can recover an install whose SSO provider is unreachable
|
||||
# without editing the DB.
|
||||
local_login_enabled: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Allow username + password login on /auth/login. Disable when only SSO should be usable. "
|
||||
"BAMBUDDY_LOCAL_LOGIN=true on the server overrides this to keep a recovery path open."
|
||||
),
|
||||
)
|
||||
|
||||
# LDAP authentication (#794)
|
||||
ldap_enabled: bool = Field(default=False, description="Enable LDAP authentication")
|
||||
ldap_server_url: str = Field(default="", description="LDAP server URL (e.g., ldap://ldap.example.com:389)")
|
||||
|
|
@ -423,6 +438,7 @@ class AppSettingsUpdate(BaseModel):
|
|||
check_updates: bool | None = None
|
||||
check_printer_firmware: bool | None = None
|
||||
include_beta_updates: bool | None = None
|
||||
local_login_enabled: bool | None = None
|
||||
language: str | None = None
|
||||
notification_language: str | None = None
|
||||
bed_cooled_threshold: float | None = None
|
||||
|
|
|
|||
204
backend/tests/integration/test_local_login_gate.py
Normal file
204
backend/tests/integration/test_local_login_gate.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""Integration tests for the local login gate + autologin (#1589).
|
||||
|
||||
Covers the four contracts described on the GitHub issue:
|
||||
1. POST /auth/login rejects local credentials when local_login_enabled=false
|
||||
AND the BAMBUDDY_LOCAL_LOGIN env var is not set.
|
||||
2. The BAMBUDDY_LOCAL_LOGIN=true env var bypasses the gate (recovery path).
|
||||
3. POST /auth/forgot-password is gated by the same flag (with the same bypass).
|
||||
4. GET /auth/advanced-auth/status surfaces both new fields so the LoginPage
|
||||
can render the right UI in a single query.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.services.ldap_service import LDAPUserInfo
|
||||
|
||||
|
||||
async def _set_setting(db: AsyncSession, key: str, value: str) -> None:
|
||||
result = await db.execute(select(Settings).where(Settings.key == key))
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
db.add(Settings(key=key, value=value))
|
||||
else:
|
||||
row.value = value
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _enable_auth(async_client: AsyncClient, username: str = "gateadm") -> None:
|
||||
"""Set up an auth-enabled install with a known admin so /auth/login is reachable."""
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": username,
|
||||
"admin_password": "GatePass1!",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestLocalLoginGate:
|
||||
"""The `local_login_enabled` setting blocks /auth/login + /auth/forgot-password
|
||||
when the env-var recovery bypass is not in play."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_default_allows_local_credentials(self, async_client: AsyncClient, db_session: AsyncSession):
|
||||
"""Default install (setting absent) keeps the pre-#1589 behaviour."""
|
||||
await _enable_auth(async_client, "gatedefault")
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "gatedefault", "password": "GatePass1!"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_rejected_when_local_disabled(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""With local_login_enabled=false and no env bypass, valid creds are
|
||||
rejected with the same generic 401 as bad creds (no UI-stating leak)."""
|
||||
await _enable_auth(async_client, "gatedeny")
|
||||
await _set_setting(db_session, "local_login_enabled", "false")
|
||||
monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "gatedeny", "password": "GatePass1!"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
# Same wording as wrong-password 401 — never leaks whether local
|
||||
# login is disabled (would help credential stuffing prioritise).
|
||||
assert "Incorrect username or password" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_env_var_bypasses_local_disabled_gate(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""BAMBUDDY_LOCAL_LOGIN=true opens the recovery path even when the
|
||||
DB setting forbids local login (SSO-broken admin recovery)."""
|
||||
await _enable_auth(async_client, "gatebypass")
|
||||
await _set_setting(db_session, "local_login_enabled", "false")
|
||||
monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "true")
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "gatebypass", "password": "GatePass1!"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_forgot_password_rejected_when_local_disabled(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Forgot-password is a local-credentials flow — useless when local
|
||||
login is off (the reset wouldn't grant access anyway)."""
|
||||
await _enable_auth(async_client, "gatefp")
|
||||
await _set_setting(db_session, "local_login_enabled", "false")
|
||||
monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": "x@example.com"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "Local login is disabled" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestLdapLoginNotAffectedByGate:
|
||||
"""LDAP keeps its own ldap_enabled switch and bypasses local_login_enabled
|
||||
entirely. This is the regression suite for the refactor in #1589 — without
|
||||
these tests, an LDAP user could fail to log in when local login is
|
||||
disabled even though the gate is supposed to leave LDAP alone."""
|
||||
|
||||
async def _enable_ldap(self, db: AsyncSession) -> None:
|
||||
for key, value in {
|
||||
"ldap_enabled": "true",
|
||||
"ldap_server_url": "ldaps://ldap.test",
|
||||
"ldap_bind_dn": "cn=svc,dc=test,dc=com",
|
||||
"ldap_bind_password": "x",
|
||||
"ldap_search_base": "dc=test,dc=com",
|
||||
"ldap_user_filter": "(uid={username})",
|
||||
"ldap_security": "ldaps",
|
||||
"ldap_group_mapping": "{}",
|
||||
"ldap_auto_provision": "true",
|
||||
"ldap_default_group": "",
|
||||
}.items():
|
||||
await _set_setting(db, key, value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_ldap_login_succeeds_when_local_disabled(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""LDAP-authenticated login must still issue a JWT even when the
|
||||
local-login gate is off and no env-var bypass is set. The original
|
||||
cut of #1589 wiped the LDAP-bound `user` variable in this branch."""
|
||||
await _enable_auth(async_client, "ldapseed")
|
||||
await self._enable_ldap(db_session)
|
||||
await _set_setting(db_session, "local_login_enabled", "false")
|
||||
monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
|
||||
|
||||
fake_ldap = LDAPUserInfo(
|
||||
username="ldapuser",
|
||||
email="ldapuser@test.com",
|
||||
display_name="LDAP User",
|
||||
groups=[],
|
||||
)
|
||||
with patch(
|
||||
"backend.app.services.ldap_service.authenticate_ldap_user",
|
||||
return_value=fake_ldap,
|
||||
):
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "ldapuser", "password": "anything"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert "access_token" in response.json()
|
||||
assert response.json()["user"]["username"] == "ldapuser"
|
||||
|
||||
|
||||
class TestAdvancedAuthStatusSurfacesGate:
|
||||
"""The /auth/advanced-auth/status endpoint feeds the LoginPage's render
|
||||
decisions in a single query — it must surface both new #1589 fields."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_status_includes_local_login_and_autologin(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
|
||||
response = await async_client.get("/api/v1/auth/advanced-auth/status")
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "local_login_enabled" in result
|
||||
assert "autologin_provider_id" in result
|
||||
# Default install: local on, no autologin provider.
|
||||
assert result["local_login_enabled"] is True
|
||||
assert result["autologin_provider_id"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_env_var_bypass_flips_status_back_to_true(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""When the DB setting is false but the env-var bypass is set, the
|
||||
status reports local_login_enabled=true so the LoginPage shows the
|
||||
credentials form (matching what the route will actually accept)."""
|
||||
await _set_setting(db_session, "local_login_enabled", "false")
|
||||
monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "true")
|
||||
|
||||
response = await async_client.get("/api/v1/auth/advanced-auth/status")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["local_login_enabled"] is True
|
||||
|
|
@ -52,6 +52,7 @@ def _register_all_models():
|
|||
# not re-exported from __init__.py.
|
||||
from backend.app.models import ( # noqa: F401
|
||||
external_link,
|
||||
print_log,
|
||||
print_queue,
|
||||
project_bom,
|
||||
slot_preset,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,16 @@ import { act, screen } from '@testing-library/react';
|
|||
import { render } from '../utils';
|
||||
import { CameraTile } from '../../components/CameraTile';
|
||||
|
||||
// The shared render() util mounts AuthProvider, which fires an async
|
||||
// /auth/me probe on mount. Each test absorbs that settle with a single
|
||||
// `await act(async () => {})` after render so the AuthProvider state
|
||||
// update doesn't bleed into the assertion phase as an act() warning.
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('CameraTile', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
|
@ -14,7 +24,7 @@ describe('CameraTile', () => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders the live stream URL in live mode', () => {
|
||||
it('renders the live stream URL in live mode', async () => {
|
||||
render(
|
||||
<CameraTile
|
||||
printerId={42}
|
||||
|
|
@ -24,12 +34,13 @@ describe('CameraTile', () => {
|
|||
connected
|
||||
/>,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
const img = screen.getByAltText('X1C-Lab') as HTMLImageElement;
|
||||
expect(img.src).toContain('/api/v1/printers/42/camera/stream');
|
||||
expect(img.src).toContain('fps=8');
|
||||
});
|
||||
|
||||
it('renders the snapshot URL and refreshes on the interval', () => {
|
||||
it('renders the snapshot URL and refreshes on the interval', async () => {
|
||||
render(
|
||||
<CameraTile
|
||||
printerId={7}
|
||||
|
|
@ -39,10 +50,11 @@ describe('CameraTile', () => {
|
|||
connected
|
||||
/>,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
const initial = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
|
||||
expect(initial).toContain('/api/v1/printers/7/camera/snapshot');
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
const refreshed = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
|
||||
|
|
@ -50,7 +62,7 @@ describe('CameraTile', () => {
|
|||
expect(refreshed).not.toBe(initial);
|
||||
});
|
||||
|
||||
it('shows an offline placeholder when not connected', () => {
|
||||
it('shows an offline placeholder when not connected', async () => {
|
||||
render(
|
||||
<CameraTile
|
||||
printerId={1}
|
||||
|
|
@ -60,10 +72,11 @@ describe('CameraTile', () => {
|
|||
connected={false}
|
||||
/>,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
expect(screen.queryByAltText('A1-Offline')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the paused placeholder in paused mode', () => {
|
||||
it('shows the paused placeholder in paused mode', async () => {
|
||||
render(
|
||||
<CameraTile
|
||||
printerId={9}
|
||||
|
|
@ -73,6 +86,7 @@ describe('CameraTile', () => {
|
|||
connected
|
||||
/>,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
expect(screen.queryByAltText('H2D-Booth')).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -89,17 +103,20 @@ describe('CameraTile', () => {
|
|||
connected
|
||||
/>,
|
||||
);
|
||||
await flushMicrotasks();
|
||||
fetchMock.mockClear();
|
||||
|
||||
rerender(
|
||||
<CameraTile
|
||||
printerId={11}
|
||||
printerName="X1C-Stop"
|
||||
mode="snapshot"
|
||||
snapshotIntervalMs={5000}
|
||||
connected
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<CameraTile
|
||||
printerId={11}
|
||||
printerName="X1C-Stop"
|
||||
mode="snapshot"
|
||||
snapshotIntervalMs={5000}
|
||||
connected
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const stopCalls = fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes('/api/v1/printers/11/camera/stop'),
|
||||
|
|
|
|||
|
|
@ -493,7 +493,12 @@ export const handlers = [
|
|||
HttpResponse.json({ totp_enabled: false, email_otp_enabled: false, backup_codes_remaining: 0 })
|
||||
),
|
||||
http.get('/api/v1/auth/advanced-auth/status', () =>
|
||||
HttpResponse.json({ advanced_auth_enabled: false, smtp_configured: false })
|
||||
HttpResponse.json({
|
||||
advanced_auth_enabled: false,
|
||||
smtp_configured: false,
|
||||
local_login_enabled: true,
|
||||
autologin_provider_id: null,
|
||||
})
|
||||
),
|
||||
http.get('/api/v1/auth/ldap/status', () =>
|
||||
HttpResponse.json({ ldap_enabled: false, ldap_configured: false })
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ const mockPreferences = {
|
|||
const mockAdvancedAuthEnabled = {
|
||||
advanced_auth_enabled: true,
|
||||
smtp_configured: true,
|
||||
local_login_enabled: true,
|
||||
autologin_provider_id: null,
|
||||
};
|
||||
|
||||
const mockSettingsWithNotifications = {
|
||||
|
|
|
|||
|
|
@ -1087,6 +1087,10 @@ export interface AppSettings {
|
|||
check_updates: boolean;
|
||||
check_printer_firmware: boolean;
|
||||
include_beta_updates: boolean;
|
||||
// #1589: false hides the local username/password form on the login page;
|
||||
// BAMBUDDY_LOCAL_LOGIN=true on the server flips the reported value back to
|
||||
// true so the env-var recovery path is visible to the SPA.
|
||||
local_login_enabled: boolean;
|
||||
language: string;
|
||||
notification_language: string;
|
||||
// AMS threshold settings
|
||||
|
|
@ -3191,6 +3195,9 @@ export interface OIDCProvider {
|
|||
// includes this field in the response (Pydantic default-False is
|
||||
// populated unconditionally in the route handler).
|
||||
has_icon: boolean;
|
||||
// #1589: when true, the LoginPage redirects unauthenticated visitors
|
||||
// straight to this provider on mount. At most one provider may carry this.
|
||||
is_autologin: boolean;
|
||||
}
|
||||
|
||||
export interface OIDCProviderCreate {
|
||||
|
|
@ -3206,6 +3213,7 @@ export interface OIDCProviderCreate {
|
|||
require_email_verified?: boolean;
|
||||
icon_url?: string | null;
|
||||
default_group_id?: number | null;
|
||||
is_autologin?: boolean; // #1589
|
||||
}
|
||||
|
||||
export interface OIDCLink {
|
||||
|
|
@ -3228,6 +3236,13 @@ export interface TestSMTPResponse {
|
|||
export interface AdvancedAuthStatus {
|
||||
advanced_auth_enabled: boolean;
|
||||
smtp_configured: boolean;
|
||||
// #1589: false hides the username/password form on the LoginPage; the env
|
||||
// var BAMBUDDY_LOCAL_LOGIN=true on the server flips this back to true so
|
||||
// the recovery path remains visible.
|
||||
local_login_enabled: boolean;
|
||||
// #1589: when set, LoginPage redirects to this provider's authorize URL
|
||||
// on mount unless ?fallback=local is in the URL or the redirect times out.
|
||||
autologin_provider_id: number | null;
|
||||
}
|
||||
|
||||
export interface LDAPStatus {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const EMPTY_FORM: OIDCProviderCreate = {
|
|||
require_email_verified: true,
|
||||
icon_url: undefined,
|
||||
default_group_id: null,
|
||||
is_autologin: false,
|
||||
};
|
||||
|
||||
// ─── Provider form (create / edit) ───────────────────────────────────────────
|
||||
|
|
@ -149,6 +150,13 @@ function ProviderForm({
|
|||
<p className="text-bambu-gray text-xs">{requireEmailVerifiedDesc}</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer w-full">
|
||||
<Toggle checked={form.is_autologin ?? false} onChange={(v) => set('is_autologin', v)} />
|
||||
<div>
|
||||
<p className="text-white text-sm">{t('settings.oidc.form.autologin')}</p>
|
||||
<p className="text-bambu-gray text-xs">{t('settings.oidc.form.autologinDesc')}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -447,6 +455,7 @@ export function OIDCProviderSettings() {
|
|||
require_email_verified: provider.require_email_verified,
|
||||
icon_url: provider.icon_url ?? undefined,
|
||||
default_group_id: provider.default_group_id ?? null,
|
||||
is_autologin: provider.is_autologin,
|
||||
}}
|
||||
onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
|
||||
onCancel={() => setEditingId(null)}
|
||||
|
|
|
|||
|
|
@ -1865,6 +1865,10 @@ export default {
|
|||
checkPrinterFirmware: 'Drucker-Firmware prüfen',
|
||||
includeBetaUpdates: 'Beta-Versionen einschließen',
|
||||
includeBetaUpdatesDesc: 'Über Beta- und Vorabversionen bei der Updateprüfung benachrichtigen',
|
||||
localLogin: {
|
||||
disable: 'Lokale Benutzername-/Passwort-Anmeldung deaktivieren',
|
||||
disableHint: 'Wenn aktiviert, ist nur die Anmeldung über SSO möglich. LDAP ist davon nicht betroffen. Setzen Sie BAMBUDDY_LOCAL_LOGIN=true auf dem Server, um einen Wiederherstellungsweg offen zu halten.',
|
||||
},
|
||||
// Queue
|
||||
enableRetry: 'Wiederholung aktivieren',
|
||||
// Home Assistant
|
||||
|
|
@ -2535,6 +2539,8 @@ export default {
|
|||
defaultGroup: 'Standardgruppe',
|
||||
defaultGroupDesc: 'Gruppe, der automatisch erstellte Benutzer zugewiesen werden. Fallback auf Viewers, wenn nicht gesetzt.',
|
||||
defaultGroupViewersFallback: 'Viewers (Standard)',
|
||||
autologin: 'Automatische Anmeldung',
|
||||
autologinDesc: 'Nicht angemeldete Besucher direkt zu diesem Anbieter weiterleiten. Diese Option kann nur für einen Anbieter aktiv sein.',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -2683,6 +2689,8 @@ export default {
|
|||
signingIn: 'Anmeldung läuft...',
|
||||
rememberMe: 'Angemeldet bleiben',
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
autologinFailed: 'Automatische SSO-Anmeldung fehlgeschlagen. Bitte wählen Sie unten einen Anbieter.',
|
||||
localDisabledNotice: 'Lokale Anmeldung ist deaktiviert. Bitte verwenden Sie einen der SSO-Anbieter unten.',
|
||||
loginSuccess: 'Erfolgreich angemeldet',
|
||||
loginFailed: 'Anmeldung fehlgeschlagen',
|
||||
enterCredentials: 'Bitte Benutzername und Passwort eingeben',
|
||||
|
|
|
|||
|
|
@ -1879,6 +1879,10 @@ export default {
|
|||
checkPrinterFirmware: 'Check printer firmware',
|
||||
includeBetaUpdates: 'Include beta versions',
|
||||
includeBetaUpdatesDesc: 'Notify about beta and prerelease versions when checking for updates',
|
||||
localLogin: {
|
||||
disable: 'Disable local username/password login',
|
||||
disableHint: 'When enabled, only SSO providers can sign in. LDAP is unaffected. Set BAMBUDDY_LOCAL_LOGIN=true on the server to keep a recovery path.',
|
||||
},
|
||||
// Queue
|
||||
enableRetry: 'Enable retry',
|
||||
// Home Assistant
|
||||
|
|
@ -2550,6 +2554,8 @@ export default {
|
|||
defaultGroup: 'Default Group',
|
||||
defaultGroupDesc: 'Group assigned to auto-created users. Falls back to Viewers if not set.',
|
||||
defaultGroupViewersFallback: 'Viewers (default)',
|
||||
autologin: 'Autologin',
|
||||
autologinDesc: 'Redirect unauthenticated visitors straight to this provider. Only one provider can carry this flag.',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -2698,6 +2704,8 @@ export default {
|
|||
signingIn: 'Logging in...',
|
||||
rememberMe: 'Remember Me',
|
||||
forgotPassword: 'Forgot your password?',
|
||||
autologinFailed: 'Automatic SSO sign-in failed. Pick a provider below to continue.',
|
||||
localDisabledNotice: 'Local sign-in is disabled. Use one of the SSO providers below.',
|
||||
loginSuccess: 'Logged in successfully',
|
||||
loginFailed: 'Login failed',
|
||||
enterCredentials: 'Please enter username and password',
|
||||
|
|
|
|||
|
|
@ -1868,6 +1868,10 @@ export default {
|
|||
checkPrinterFirmware: 'Comprobar el firmware de la impresora',
|
||||
includeBetaUpdates: 'Incluir versiones beta',
|
||||
includeBetaUpdatesDesc: 'Notificar sobre versiones beta y preliminares al buscar actualizaciones',
|
||||
localLogin: {
|
||||
disable: 'Deshabilitar el inicio de sesión local con usuario/contraseña',
|
||||
disableHint: 'Cuando se habilita, solo los proveedores SSO pueden iniciar sesión. LDAP no se ve afectado. Defina BAMBUDDY_LOCAL_LOGIN=true en el servidor para mantener una vía de recuperación.',
|
||||
},
|
||||
// Queue
|
||||
enableRetry: 'Activar reintentos',
|
||||
// Home Assistant
|
||||
|
|
@ -2538,6 +2542,8 @@ export default {
|
|||
defaultGroup: 'Grupo predeterminado',
|
||||
defaultGroupDesc: 'Grupo asignado a los usuarios creados automáticamente. Si no se establece, se usa Visores como alternativa.',
|
||||
defaultGroupViewersFallback: 'Visores (predeterminado)',
|
||||
autologin: 'Inicio automático',
|
||||
autologinDesc: 'Redirigir a los visitantes no autenticados directamente a este proveedor. Solo un proveedor puede llevar esta marca.',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -2686,6 +2692,8 @@ export default {
|
|||
signingIn: 'Iniciando sesión...',
|
||||
rememberMe: 'Recordarme',
|
||||
forgotPassword: '¿Olvidó su contraseña?',
|
||||
autologinFailed: 'El inicio de sesión SSO automático falló. Elija un proveedor abajo para continuar.',
|
||||
localDisabledNotice: 'El inicio de sesión local está deshabilitado. Use uno de los proveedores SSO de abajo.',
|
||||
loginSuccess: 'Sesión iniciada correctamente',
|
||||
loginFailed: 'Error al iniciar sesión',
|
||||
enterCredentials: 'Introduzca el nombre de usuario y la contraseña',
|
||||
|
|
|
|||
|
|
@ -1821,6 +1821,10 @@ export default {
|
|||
checkPrinterFirmware: 'Vérifier le firmware imprimante',
|
||||
includeBetaUpdates: 'Inclure les versions bêta',
|
||||
includeBetaUpdatesDesc: 'Notifier des versions bêta et préliminaires lors de la vérification des mises à jour',
|
||||
localLogin: {
|
||||
disable: 'Désactiver la connexion locale par nom d\'utilisateur/mot de passe',
|
||||
disableHint: 'Quand activée, seuls les fournisseurs SSO peuvent se connecter. LDAP n\'est pas affecté. Définissez BAMBUDDY_LOCAL_LOGIN=true sur le serveur pour conserver une voie de récupération.',
|
||||
},
|
||||
// Queue
|
||||
enableRetry: 'Activer la rétentative',
|
||||
// Home Assistant
|
||||
|
|
@ -2478,6 +2482,8 @@ export default {
|
|||
defaultGroup: 'Groupe par défaut',
|
||||
defaultGroupDesc: 'Groupe attribué aux utilisateurs créés automatiquement. Repli sur Viewers si non défini.',
|
||||
defaultGroupViewersFallback: 'Viewers (par défaut)',
|
||||
autologin: 'Connexion automatique',
|
||||
autologinDesc: 'Rediriger les visiteurs non authentifiés directement vers ce fournisseur. Un seul fournisseur peut porter cet indicateur.',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -2672,6 +2678,8 @@ export default {
|
|||
signingIn: 'Connexion...',
|
||||
rememberMe: 'Se souvenir de moi',
|
||||
forgotPassword: 'Mot de passe oublié ?',
|
||||
autologinFailed: 'La connexion SSO automatique a échoué. Choisissez un fournisseur ci-dessous pour continuer.',
|
||||
localDisabledNotice: 'La connexion locale est désactivée. Utilisez l\'un des fournisseurs SSO ci-dessous.',
|
||||
loginSuccess: 'Connecté avec succès',
|
||||
loginFailed: 'Échec de connexion',
|
||||
enterCredentials: 'Entrez vos identifiants',
|
||||
|
|
|
|||
|
|
@ -1821,6 +1821,10 @@ export default {
|
|||
checkPrinterFirmware: 'Controlla firmware stampante',
|
||||
includeBetaUpdates: 'Includi versioni beta',
|
||||
includeBetaUpdatesDesc: 'Notifica versioni beta e prerelease durante il controllo aggiornamenti',
|
||||
localLogin: {
|
||||
disable: 'Disabilita l\'accesso locale con nome utente/password',
|
||||
disableHint: 'Quando attivato, solo i provider SSO possono accedere. LDAP non è interessato. Imposta BAMBUDDY_LOCAL_LOGIN=true sul server per mantenere un percorso di ripristino.',
|
||||
},
|
||||
// Queue
|
||||
enableRetry: 'Abilita retry',
|
||||
// Home Assistant
|
||||
|
|
@ -2477,6 +2481,8 @@ export default {
|
|||
defaultGroup: 'Gruppo predefinito',
|
||||
defaultGroupDesc: 'Gruppo assegnato agli utenti creati automaticamente. Ritorno a Viewers se non impostato.',
|
||||
defaultGroupViewersFallback: 'Viewers (predefinito)',
|
||||
autologin: 'Accesso automatico',
|
||||
autologinDesc: 'Reindirizza i visitatori non autenticati direttamente a questo provider. Solo un provider può avere questo flag.',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -2671,6 +2677,8 @@ export default {
|
|||
signingIn: 'Accesso in corso...',
|
||||
rememberMe: 'Ricordami',
|
||||
forgotPassword: 'Hai dimenticato la password?',
|
||||
autologinFailed: 'Accesso SSO automatico fallito. Scegli un provider qui sotto per continuare.',
|
||||
localDisabledNotice: 'L\'accesso locale è disabilitato. Usa uno dei provider SSO qui sotto.',
|
||||
loginSuccess: 'Accesso riuscito',
|
||||
loginFailed: 'Accesso fallito',
|
||||
enterCredentials: 'Inserisci nome utente e password',
|
||||
|
|
|
|||
|
|
@ -1864,6 +1864,10 @@ export default {
|
|||
checkPrinterFirmware: 'プリンターファームウェアの確認',
|
||||
includeBetaUpdates: 'ベータ版を含める',
|
||||
includeBetaUpdatesDesc: 'アップデート確認時にベータ版およびプレリリース版を通知する',
|
||||
localLogin: {
|
||||
disable: 'ローカルのユーザー名/パスワードログインを無効化',
|
||||
disableHint: '有効にすると、SSOプロバイダーのみでサインインできます。LDAPには影響しません。復旧用のパスを残すには、サーバーで BAMBUDDY_LOCAL_LOGIN=true を設定してください。',
|
||||
},
|
||||
// Queue
|
||||
enableRetry: 'リトライを有効化',
|
||||
// Home Assistant
|
||||
|
|
@ -2534,6 +2538,8 @@ export default {
|
|||
defaultGroup: 'デフォルトグループ',
|
||||
defaultGroupDesc: '自動作成ユーザーに割り当てられるグループ。未設定の場合はViewersにフォールバックします。',
|
||||
defaultGroupViewersFallback: 'Viewers(デフォルト)',
|
||||
autologin: '自動サインイン',
|
||||
autologinDesc: '未認証の訪問者をこのプロバイダーに直接リダイレクトします。このフラグを付けられるプロバイダーは1つだけです。',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -2683,6 +2689,8 @@ export default {
|
|||
signingIn: 'ログイン中...',
|
||||
rememberMe: 'ログイン状態を保持する',
|
||||
forgotPassword: 'パスワードをお忘れですか?',
|
||||
autologinFailed: 'SSOへの自動サインインに失敗しました。下から続行するプロバイダーを選択してください。',
|
||||
localDisabledNotice: 'ローカルサインインは無効化されています。下のSSOプロバイダーをご利用ください。',
|
||||
loginSuccess: 'ログインしました',
|
||||
loginFailed: 'ログインに失敗しました',
|
||||
enterCredentials: 'ユーザー名とパスワードを入力してください',
|
||||
|
|
|
|||
|
|
@ -1765,6 +1765,10 @@ export default {
|
|||
checkPrinterFirmware: '프린터 펌웨어 확인',
|
||||
includeBetaUpdates: '베타 버전 포함',
|
||||
includeBetaUpdatesDesc: '업데이트 확인 시 베타 및 사전 릴리스 버전에 대해 알림',
|
||||
localLogin: {
|
||||
disable: '로컬 사용자명/비밀번호 로그인 비활성화',
|
||||
disableHint: '활성화하면 SSO 공급자로만 로그인할 수 있습니다. LDAP는 영향을 받지 않습니다. 서버에서 BAMBUDDY_LOCAL_LOGIN=true 를 설정하면 복구 경로가 유지됩니다.'
|
||||
},
|
||||
enableRetry: '재시도 활성화',
|
||||
homeAssistantDescription: 'Home Assistant를 통해 스마트 플러그 제어',
|
||||
environmentManagedLabel: '(환경 변수 관리)',
|
||||
|
|
@ -2384,7 +2388,9 @@ export default {
|
|||
requireEmailVerifiedAutoLink: '이 설정을 변경하려면 먼저 자동 연결을 비활성화하세요.',
|
||||
defaultGroup: '기본 그룹',
|
||||
defaultGroupDesc: '자동 생성된 사용자에게 할당되는 그룹. 설정되지 않으면 Viewers로 대체됩니다.',
|
||||
defaultGroupViewersFallback: 'Viewers (기본값)'
|
||||
defaultGroupViewersFallback: 'Viewers (기본값)',
|
||||
autologin: '자동 로그인',
|
||||
autologinDesc: '인증되지 않은 방문자를 이 공급자로 바로 리디렉션합니다. 이 플래그를 가질 수 있는 공급자는 하나뿐입니다.'
|
||||
},
|
||||
refreshIcon: '아이콘 새로고침',
|
||||
removeIcon: '아이콘 제거',
|
||||
|
|
@ -2526,6 +2532,8 @@ export default {
|
|||
signingIn: '로그인 중...',
|
||||
rememberMe: '로그인 유지',
|
||||
forgotPassword: '비밀번호를 잊으셨나요?',
|
||||
autologinFailed: 'SSO 자동 로그인에 실패했습니다. 아래에서 계속할 공급자를 선택하세요.',
|
||||
localDisabledNotice: '로컬 로그인이 비활성화되어 있습니다. 아래 SSO 공급자 중 하나를 사용하세요.',
|
||||
loginSuccess: '성공적으로 로그인되었습니다',
|
||||
loginFailed: '로그인 실패',
|
||||
enterCredentials: '사용자명과 비밀번호를 입력하세요',
|
||||
|
|
|
|||
|
|
@ -1821,6 +1821,10 @@ export default {
|
|||
checkPrinterFirmware: 'Verificar firmware da impressora',
|
||||
includeBetaUpdates: 'Incluir versões beta',
|
||||
includeBetaUpdatesDesc: 'Notificar sobre versões beta e pré-lançamento ao verificar atualizações',
|
||||
localLogin: {
|
||||
disable: 'Desativar login local com usuário/senha',
|
||||
disableHint: 'Quando ativado, somente provedores SSO podem fazer login. O LDAP não é afetado. Defina BAMBUDDY_LOCAL_LOGIN=true no servidor para manter um caminho de recuperação.',
|
||||
},
|
||||
// Queue
|
||||
enableRetry: 'Habilitar tentativa',
|
||||
// Home Assistant
|
||||
|
|
@ -2477,6 +2481,8 @@ export default {
|
|||
defaultGroup: 'Grupo padrão',
|
||||
defaultGroupDesc: 'Grupo atribuído aos usuários criados automaticamente. Retorna a Viewers se não definido.',
|
||||
defaultGroupViewersFallback: 'Viewers (padrão)',
|
||||
autologin: 'Login automático',
|
||||
autologinDesc: 'Redirecionar visitantes não autenticados diretamente para este provedor. Apenas um provedor pode ter esta marcação.',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -2671,6 +2677,8 @@ export default {
|
|||
signingIn: 'Entrando...',
|
||||
rememberMe: 'Lembrar de mim',
|
||||
forgotPassword: 'Esqueceu sua senha?',
|
||||
autologinFailed: 'O login SSO automático falhou. Escolha um provedor abaixo para continuar.',
|
||||
localDisabledNotice: 'O login local está desativado. Use um dos provedores SSO abaixo.',
|
||||
loginSuccess: 'Login realizado com sucesso',
|
||||
loginFailed: 'Falha no login',
|
||||
enterCredentials: 'Por favor, insira nome de usuário e senha',
|
||||
|
|
|
|||
|
|
@ -1868,6 +1868,10 @@ export default {
|
|||
checkPrinterFirmware: 'Yazıcı firmware\'ini kontrol et',
|
||||
includeBetaUpdates: 'Beta sürümleri dahil et',
|
||||
includeBetaUpdatesDesc: 'Güncellemeleri kontrol ederken beta ve önyayım sürümleri hakkında bildir',
|
||||
localLogin: {
|
||||
disable: 'Yerel kullanıcı adı/şifre ile oturum açmayı devre dışı bırak',
|
||||
disableHint: 'Etkinleştirildiğinde yalnızca SSO sağlayıcıları ile oturum açılabilir. LDAP etkilenmez. Bir kurtarma yolu açık tutmak için sunucuda BAMBUDDY_LOCAL_LOGIN=true ayarlayın.',
|
||||
},
|
||||
// Kuyruk
|
||||
enableRetry: 'Yeniden denemeyi etkinleştir',
|
||||
// Home Assistant
|
||||
|
|
@ -2538,6 +2542,8 @@ export default {
|
|||
defaultGroup: 'Varsayılan Grup',
|
||||
defaultGroupDesc: 'Otomatik oluşturulan kullanıcılara atanan grup. Ayarlanmazsa Viewers\'a geri döner.',
|
||||
defaultGroupViewersFallback: 'Viewers (varsayılan)',
|
||||
autologin: 'Otomatik oturum açma',
|
||||
autologinDesc: 'Kimlik doğrulaması yapılmamış ziyaretçileri doğrudan bu sağlayıcıya yönlendir. Bu işareti yalnızca bir sağlayıcı taşıyabilir.',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -2686,6 +2692,8 @@ export default {
|
|||
signingIn: 'Giriş yapılıyor...',
|
||||
rememberMe: 'Beni Hatırla',
|
||||
forgotPassword: 'Parolanızı mı unuttunuz?',
|
||||
autologinFailed: 'Otomatik SSO girişi başarısız oldu. Devam etmek için aşağıdan bir sağlayıcı seçin.',
|
||||
localDisabledNotice: 'Yerel oturum açma devre dışı. Aşağıdaki SSO sağlayıcılarından birini kullanın.',
|
||||
loginSuccess: 'Başarıyla giriş yapıldı',
|
||||
loginFailed: 'Giriş başarısız',
|
||||
enterCredentials: 'Lütfen kullanıcı adı ve parola girin',
|
||||
|
|
|
|||
|
|
@ -1866,6 +1866,10 @@ export default {
|
|||
checkPrinterFirmware: '检查打印机固件',
|
||||
includeBetaUpdates: '包含测试版本',
|
||||
includeBetaUpdatesDesc: '检查更新时通知测试版和预发布版本',
|
||||
localLogin: {
|
||||
disable: '禁用本地用户名/密码登录',
|
||||
disableHint: '启用后,只能通过SSO提供商登录。LDAP不受影响。在服务器上设置 BAMBUDDY_LOCAL_LOGIN=true 可保留恢复通道。',
|
||||
},
|
||||
// Queue
|
||||
enableRetry: '启用重试',
|
||||
// Home Assistant
|
||||
|
|
@ -2522,6 +2526,8 @@ export default {
|
|||
defaultGroup: '默认组',
|
||||
defaultGroupDesc: '自动创建用户时分配的组。未设置时回退到 Viewers。',
|
||||
defaultGroupViewersFallback: 'Viewers(默认)',
|
||||
autologin: '自动登录',
|
||||
autologinDesc: '将未认证的访问者直接重定向到该提供商。只有一个提供商可以携带此标志。',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -2671,6 +2677,8 @@ export default {
|
|||
signingIn: '登录中...',
|
||||
rememberMe: '记住我',
|
||||
forgotPassword: '忘记密码?',
|
||||
autologinFailed: '自动SSO登录失败。请在下方选择一个提供商以继续。',
|
||||
localDisabledNotice: '本地登录已禁用。请使用下方的SSO提供商之一。',
|
||||
loginSuccess: '登录成功',
|
||||
loginFailed: '登录失败',
|
||||
enterCredentials: '请输入用户名和密码',
|
||||
|
|
|
|||
|
|
@ -1866,6 +1866,10 @@ export default {
|
|||
checkPrinterFirmware: '檢查印表機韌體',
|
||||
includeBetaUpdates: '包含測試版本',
|
||||
includeBetaUpdatesDesc: '檢查更新時通知測試版和預發布版本',
|
||||
localLogin: {
|
||||
disable: '停用本機使用者名稱/密碼登入',
|
||||
disableHint: '啟用後,只能透過SSO提供者登入。LDAP不受影響。在伺服器上設定 BAMBUDDY_LOCAL_LOGIN=true 可保留復原途徑。',
|
||||
},
|
||||
// Queue
|
||||
enableRetry: '啟用重試',
|
||||
// Home Assistant
|
||||
|
|
@ -2522,6 +2526,8 @@ export default {
|
|||
defaultGroup: '預設群組',
|
||||
defaultGroupDesc: '自動建立使用者時分配的群組。未設定時回退到 Viewers。',
|
||||
defaultGroupViewersFallback: 'Viewers(預設)',
|
||||
autologin: '自動登入',
|
||||
autologinDesc: '將未驗證的訪客直接重新導向至此提供者。此旗標僅能由一個提供者持有。',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -2671,6 +2677,8 @@ export default {
|
|||
signingIn: '登入中...',
|
||||
rememberMe: '記住我',
|
||||
forgotPassword: '忘記密碼?',
|
||||
autologinFailed: '自動SSO登入失敗。請在下方選擇一個提供者以繼續。',
|
||||
localDisabledNotice: '本機登入已停用。請使用下方的SSO提供者之一。',
|
||||
loginSuccess: '登入成功',
|
||||
loginFailed: '登入失敗',
|
||||
enterCredentials: '請輸入使用者名稱和密碼',
|
||||
|
|
|
|||
|
|
@ -165,6 +165,42 @@ export function LoginPage() {
|
|||
queryFn: () => api.getOIDCProviders(),
|
||||
});
|
||||
|
||||
// #1589: autologin redirect with fallback. When the backend reports an
|
||||
// `autologin_provider_id`, redirect unauthenticated visitors directly to
|
||||
// that provider's authorize URL on mount — unless the URL carries
|
||||
// `?fallback=local` (the documented recovery path that pairs with the
|
||||
// server-side BAMBUDDY_LOCAL_LOGIN env-var bypass). The authorize-URL
|
||||
// fetch is raced against a 5-second timeout; on timeout or fetch error
|
||||
// we skip the redirect and render the normal page, surfacing a banner
|
||||
// so the user understands why autologin didn't kick in.
|
||||
const [autologinFailed, setAutologinFailed] = useState(false);
|
||||
const autologinAttemptedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autologinAttemptedRef.current) return;
|
||||
const fallbackQuery = searchParams.get('fallback');
|
||||
if (fallbackQuery === 'local') return;
|
||||
if (!advancedAuthStatus || !advancedAuthStatus.autologin_provider_id) return;
|
||||
// Don't redirect mid-OIDC-exchange (we're already coming back from the IdP).
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#oidc_token=') || searchParams.get('oidc_error')) return;
|
||||
autologinAttemptedRef.current = true;
|
||||
|
||||
const providerId = advancedAuthStatus.autologin_provider_id;
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error('autologin timeout')), 5000),
|
||||
);
|
||||
Promise.race([api.getOIDCAuthorizeUrl(providerId), timeoutPromise])
|
||||
.then((result) => {
|
||||
window.location.href = (result as { auth_url: string }).auth_url;
|
||||
})
|
||||
.catch(() => {
|
||||
setAutologinFailed(true);
|
||||
});
|
||||
}, [advancedAuthStatus, searchParams]);
|
||||
|
||||
const localLoginEnabled = advancedAuthStatus?.local_login_enabled !== false;
|
||||
const showAutologinBanner = autologinFailed && advancedAuthStatus?.autologin_provider_id != null;
|
||||
|
||||
// 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.
|
||||
|
|
@ -667,6 +703,19 @@ export function LoginPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{showAutologinBanner && (
|
||||
<div className="mt-6 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
|
||||
{t('login.autologinFailed')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!localLoginEnabled && (
|
||||
<div className="mt-6 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 px-4 py-3 text-sm text-bambu-gray">
|
||||
{t('login.localDisabledNotice')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localLoginEnabled && (
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
|
|
@ -739,6 +788,7 @@ export function LoginPage() {
|
|||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* OIDC provider buttons */}
|
||||
{oidcProviders && oidcProviders.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -554,7 +554,7 @@ export function SettingsPage() {
|
|||
});
|
||||
|
||||
// Advanced auth status for user creation
|
||||
const { data: advancedAuthStatus = { advanced_auth_enabled: false, smtp_configured: false } } = useQuery({
|
||||
const { data: advancedAuthStatus = { advanced_auth_enabled: false, smtp_configured: false, local_login_enabled: true, autologin_provider_id: null } } = useQuery({
|
||||
queryKey: ['advancedAuthStatus'],
|
||||
queryFn: () => api.getAdvancedAuthStatus(),
|
||||
});
|
||||
|
|
@ -934,6 +934,7 @@ export function SettingsPage() {
|
|||
settings.check_updates !== localSettings.check_updates ||
|
||||
(settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
|
||||
(settings.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
|
||||
(settings.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
|
||||
settings.notification_language !== localSettings.notification_language ||
|
||||
(settings.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
|
||||
settings.ams_humidity_good !== localSettings.ams_humidity_good ||
|
||||
|
|
@ -1027,6 +1028,7 @@ export function SettingsPage() {
|
|||
check_updates: localSettings.check_updates,
|
||||
check_printer_firmware: localSettings.check_printer_firmware,
|
||||
include_beta_updates: localSettings.include_beta_updates,
|
||||
local_login_enabled: localSettings.local_login_enabled,
|
||||
notification_language: localSettings.notification_language,
|
||||
bed_cooled_threshold: localSettings.bed_cooled_threshold,
|
||||
ams_humidity_good: localSettings.ams_humidity_good,
|
||||
|
|
@ -5672,7 +5674,23 @@ export function SettingsPage() {
|
|||
)}
|
||||
|
||||
{usersSubTab === 'oidc' && isAdmin && (
|
||||
<div className="max-w-3xl">
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.local_login_enabled === false}
|
||||
onChange={(e) => updateSetting('local_login_enabled', !e.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green/50 cursor-pointer"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{t('settings.localLogin.disable')}</p>
|
||||
<p className="text-xs text-bambu-gray mt-0.5">{t('settings.localLogin.disableHint')}</p>
|
||||
</div>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OIDCProviderSettings />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-DMYFpZ9c.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-Blhe8AhR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DIWYFok8.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue