diff --git a/lnbits/core/crud/settings.py b/lnbits/core/crud/settings.py index 9e9023e6f..fd171dcd3 100644 --- a/lnbits/core/crud/settings.py +++ b/lnbits/core/crud/settings.py @@ -105,7 +105,8 @@ async def get_settings_field( ) if not row: return None - return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"]) + value = json.loads(row["value"]) if row["value"] else None + return SettingsField(id=row["id"], value=value, tag=row["tag"]) async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"): diff --git a/lnbits/core/services/users.py b/lnbits/core/services/users.py index 37f2c998c..626709c1e 100644 --- a/lnbits/core/services/users.py +++ b/lnbits/core/services/users.py @@ -173,6 +173,12 @@ async def check_admin_settings(): if account and account.extra and account.extra.provider == "env": settings.first_install = True + if settings.has_first_install_token_changed(): + logger.warning("First install token is changed. Resetting admin settings.") + new_settings = await init_admin_settings() + settings.super_user = new_settings.super_user + settings.first_install = True + logger.success( "✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` " "to get the superuser." diff --git a/lnbits/core/views/auth_api.py b/lnbits/core/views/auth_api.py index 9fd0d8a73..38d391cd4 100644 --- a/lnbits/core/views/auth_api.py +++ b/lnbits/core/views/auth_api.py @@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse, RedirectResponse from fastapi_sso.sso.base import OpenID, SSOBase from loguru import logger +from lnbits.core.crud.settings import set_settings_field from lnbits.core.crud.users import ( get_user_access_control_lists, update_user_access_control_list, @@ -548,6 +549,13 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse: account.hash_password(data.password) await update_account(account) settings.first_install = False + + # only confrm it after the super user has been successfully updated + if settings.first_install_token: + settings.first_install_token_confirmed = data.first_install_token + await set_settings_field( + "first_install_token_confirmed", data.first_install_token + ) return _auth_success_response(account.username, account.id, account.email) diff --git a/lnbits/settings.py b/lnbits/settings.py index 2fa18b75d..c38eaa23e 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -447,6 +447,7 @@ class SecuritySettings(LNbitsSettings): lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000, ge=0) lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000, ge=0) + first_install_token_confirmed: str | None = Field(default=None) def is_wallet_max_balance_exceeded(self, amount): return ( @@ -1030,6 +1031,13 @@ class EnvSettings(LNbitsSettings): def has_default_extension_path(self) -> bool: return self.lnbits_extensions_path == "lnbits" + def has_first_install_token_changed(self) -> bool: + if not self.first_install_token: + return False + if not settings.first_install_token_confirmed: + return False + return self.first_install_token != settings.first_install_token_confirmed + def check_auth_secret_key(self): if self.auth_secret_key: return diff --git a/lnbits/utils/logger.py b/lnbits/utils/logger.py index 9551af6e2..0d78e4414 100644 --- a/lnbits/utils/logger.py +++ b/lnbits/utils/logger.py @@ -15,7 +15,11 @@ from lnbits.settings import settings def log_server_info(): logger.info("LNbits Info") if settings.first_install: - logger.success("This is a fresh install of LNbits.") + if settings.has_first_install_token_changed(): + logger.success("This is a first install token reset.") + else: + logger.success("This is a fresh install of LNbits.") + if settings.first_install_token: logger.success( f"FIRST_INSTALL_TOKEN: `{settings.first_install_token}`. " diff --git a/tests/conftest.py b/tests/conftest.py index 9dd3d9072..532754145 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,6 +72,7 @@ async def app(settings: Settings): username="superadmin", password="secret1234", password_repeat="secret1234", + first_install_token=settings.first_install_token, ) ) diff --git a/tests/unit/test_first_install.py b/tests/unit/test_first_install.py new file mode 100644 index 000000000..478719a90 --- /dev/null +++ b/tests/unit/test_first_install.py @@ -0,0 +1,140 @@ +from pathlib import Path +from uuid import uuid4 + +import pytest + +from lnbits.core.crud import create_account, delete_account, get_account +from lnbits.core.crud.settings import get_settings_field, set_settings_field +from lnbits.core.db import db +from lnbits.core.models import Account, UpdateSuperuserPassword, UserExtra +from lnbits.core.services.users import check_admin_settings +from lnbits.core.views.auth_api import first_install +from lnbits.settings import settings + + +async def _restore_setting_field(field_name: str, original_row) -> None: + if original_row is None: + await db.execute( + "DELETE FROM system_settings WHERE id = :id AND tag = :tag", + {"id": field_name, "tag": "core"}, + ) + return + + await set_settings_field(field_name, original_row.value, original_row.tag) + + +def test_has_first_install_token_changed_requires_a_confirmed_mismatch(): + original_token = settings.first_install_token + original_confirmed = settings.first_install_token_confirmed + + try: + settings.first_install_token = "new-token" + settings.first_install_token_confirmed = "old-token" + assert settings.has_first_install_token_changed() is True + + settings.first_install_token_confirmed = "new-token" + assert settings.has_first_install_token_changed() is False + + settings.first_install_token_confirmed = None + assert settings.has_first_install_token_changed() is False + + settings.first_install_token = None + assert settings.has_first_install_token_changed() is False + finally: + settings.first_install_token = original_token + settings.first_install_token_confirmed = original_confirmed + + +@pytest.mark.anyio +async def test_first_install_confirms_first_install_token(app): + temp_super_user = uuid4().hex + username = f"super_{temp_super_user[:8]}" + original_super_user = settings.super_user + original_first_install = settings.first_install + original_first_install_token = settings.first_install_token + original_first_install_token_confirmed = settings.first_install_token_confirmed + original_confirmed_row = await get_settings_field("first_install_token_confirmed") + + await create_account(Account(id=temp_super_user, extra=UserExtra(provider="env"))) + + try: + settings.super_user = temp_super_user + settings.first_install = True + settings.first_install_token = "expected-token" + settings.first_install_token_confirmed = None + + response = await first_install( + UpdateSuperuserPassword( + username=username, + password="secret1234", + password_repeat="secret1234", + first_install_token="expected-token", + ) + ) + + assert response.status_code == 200 + assert settings.first_install is False + assert settings.first_install_token_confirmed == "expected-token" + + confirmed_row = await get_settings_field("first_install_token_confirmed") + assert confirmed_row is not None + assert confirmed_row.value == "expected-token" + + account = await get_account(temp_super_user) + assert account is not None + assert account.username == username + assert account.extra.provider == "lnbits" + assert account.verify_password("secret1234") + finally: + await _restore_setting_field( + "first_install_token_confirmed", original_confirmed_row + ) + settings.super_user = original_super_user + settings.first_install = original_first_install + settings.first_install_token = original_first_install_token + settings.first_install_token_confirmed = original_first_install_token_confirmed + await delete_account(temp_super_user) + + +@pytest.mark.anyio +async def test_check_admin_settings_clears_persisted_super_user_when_token_changes(app): + temp_super_user = uuid4().hex + original_super_user = settings.super_user + original_first_install = settings.first_install + original_first_install_token = settings.first_install_token + original_first_install_token_confirmed = settings.first_install_token_confirmed + original_super_user_row = await get_settings_field("super_user") + original_confirmed_row = await get_settings_field("first_install_token_confirmed") + super_user_file = Path(settings.lnbits_data_folder) / ".super_user" + + await create_account( + Account(id=temp_super_user, extra=UserExtra(provider="lnbits")) + ) + + try: + await set_settings_field("super_user", temp_super_user) + await set_settings_field("first_install_token_confirmed", "old-token") + + settings.lnbits_admin_ui = True + settings.super_user = temp_super_user + settings.first_install = False + settings.first_install_token = "new-token" + settings.first_install_token_confirmed = "old-token" + + await check_admin_settings() + + super_user_row = await get_settings_field("super_user") + assert super_user_row is not None + assert super_user_row.value + assert settings.first_install is True + finally: + await _restore_setting_field("super_user", original_super_user_row) + await _restore_setting_field( + "first_install_token_confirmed", original_confirmed_row + ) + settings.super_user = original_super_user + settings.first_install = original_first_install + settings.first_install_token = original_first_install_token + settings.first_install_token_confirmed = original_first_install_token_confirmed + super_user_file.write_text(original_super_user) + await delete_account(temp_super_user)