From bbad4a91ae144f6ddf06020d9a79725ab067c8df Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 23 Mar 2026 12:12:22 +0200 Subject: [PATCH] [feat] configure HTTPS Only settings (#3801) --- .github/workflows/jmeter.yml | 1 + lnbits/core/views/auth_api.py | 65 +++++++++++++++++++++++++++++------ lnbits/settings.py | 3 ++ tests/conftest.py | 1 + 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/.github/workflows/jmeter.yml b/.github/workflows/jmeter.yml index 19fe0008c..a2e29d5d1 100644 --- a/.github/workflows/jmeter.yml +++ b/.github/workflows/jmeter.yml @@ -22,6 +22,7 @@ jobs: - name: run LNbits env: LNBITS_ADMIN_UI: true + AUTH_HTTPS_ONLY: false LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw" LNBITS_BACKEND_WALLET_CLASS: FakeWallet run: | diff --git a/lnbits/core/views/auth_api.py b/lnbits/core/views/auth_api.py index 38c8b2166..9fd0d8a73 100644 --- a/lnbits/core/views/auth_api.py +++ b/lnbits/core/views/auth_api.py @@ -154,9 +154,20 @@ async def impersonate_user( max_age = settings.auth_token_expire_minutes * 60 response.set_cookie( - "admin_access_token", cookie_access_token, httponly=True, max_age=max_age + "admin_access_token", + cookie_access_token, + httponly=True, + secure=settings.auth_https_only, + samesite="lax", + max_age=max_age, + ) + response.set_cookie( + "is_lnbits_user_impersonated", + "true", + secure=settings.auth_https_only, + samesite="lax", + max_age=max_age, ) - response.set_cookie("is_lnbits_user_impersonated", "true", max_age=max_age) return response @@ -177,7 +188,12 @@ async def stop_impersonate_user( ) max_age = settings.auth_token_expire_minutes * 60 response.set_cookie( - "cookie_access_token", admin_access_token, httponly=True, max_age=max_age + "cookie_access_token", + admin_access_token, + httponly=True, + secure=settings.auth_https_only, + samesite="lax", + max_age=max_age, ) response.delete_cookie("admin_access_token") response.delete_cookie("is_access_token_expired") @@ -560,7 +576,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non id=uuid4().hex, email=email, extra=UserExtra(email_verified=True) ) await create_user_account(account) - return _auth_redirect_response(redirect_path, email) + return _auth_redirect_response(redirect_path, account.id, email) def _auth_success_response( @@ -575,9 +591,20 @@ def _auth_success_response( max_age = settings.auth_token_expire_minutes * 60 response = JSONResponse({"access_token": access_token, "token_type": "bearer"}) response.set_cookie( - "cookie_access_token", access_token, httponly=True, max_age=max_age + "cookie_access_token", + access_token, + httponly=True, + secure=settings.auth_https_only, + samesite="lax", + max_age=max_age, + ) + response.set_cookie( + "is_lnbits_user_authorized", + "true", + secure=settings.auth_https_only, + samesite="lax", + max_age=max_age, ) - response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age) response.delete_cookie("is_access_token_expired") return response @@ -594,15 +621,28 @@ def _auth_api_token_response( ) -def _auth_redirect_response(path: str, email: str) -> RedirectResponse: - payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time())) +def _auth_redirect_response(path: str, user_id: str, email: str) -> RedirectResponse: + payload = AccessTokenPayload( + usr=user_id, sub="", email=email, auth_time=int(time()) + ) access_token = create_access_token(data=payload.dict()) max_age = settings.auth_token_expire_minutes * 60 response = RedirectResponse(path) response.set_cookie( - "cookie_access_token", access_token, httponly=True, max_age=max_age + "cookie_access_token", + access_token, + httponly=True, + secure=settings.auth_https_only, + samesite="lax", + max_age=max_age, + ) + response.set_cookie( + "is_lnbits_user_authorized", + "true", + secure=settings.auth_https_only, + samesite="lax", + max_age=max_age, ) - response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age) response.delete_cookie("is_access_token_expired") return response @@ -622,7 +662,10 @@ def _new_sso(provider: str) -> SSOBase | None: sso_provider_class = _find_auth_provider_class(provider) sso_provider = sso_provider_class( - client_id, client_secret, None, allow_insecure_http=True + client_id, + client_secret, + None, + allow_insecure_http=not settings.auth_https_only, ) if ( discovery_url diff --git a/lnbits/settings.py b/lnbits/settings.py index 9fa049c75..2fa18b75d 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -1005,6 +1005,9 @@ class EnvSettings(LNbitsSettings): debug: bool = Field(default=False) debug_database: bool = Field(default=False) bundle_assets: bool = Field(default=True) + # When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP. + # Set to false for local/dev environments that run without TLS. + auth_https_only: bool = Field(default=True) host: str = Field(default="127.0.0.1") port: int = Field(default=5000, gt=0) forwarded_allow_ips: str = Field(default="*") diff --git a/tests/conftest.py b/tests/conftest.py index 95ab104d6..9dd3d9072 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,6 +43,7 @@ def anyio_backend(): @pytest.fixture(scope="session") def settings(): # override settings for tests + lnbits_settings.auth_https_only = False lnbits_settings.lnbits_admin_extensions = [] lnbits_settings.lnbits_data_folder = "./tests/data" lnbits_settings.lnbits_admin_ui = True