feat: install wizard on first launch (#1977)

* Login form loading

* add first install middleware and settings

* updates

* Login form loading

* add first install middleware and settings

* updates

* only set first install when superuser is created

* refactor first install

* only show if first install

* cleanup

* set password

* update calls

* login superuser on first install

* fix

* fixup!

* fixup!

* fixup!

* fixup!

* fixup!

* last fixup!

* fix mypy and prettier CI errors

* disable first install

* add random super user

* set first install after startup

* remove user id from form

* Update lnbits/core/views/auth_api.py

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>

* Update lnbits/core/views/auth_api.py

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>

* Update lnbits/middleware.py

Co-authored-by: dni  <office@dnilabs.com>

* addressing Vlad's comments

* remove super user

* move to transient settings

* fix: show `first_install` page even after a server restart

* fix: do not add `user_id` in the auth token

* fix: `make check` errors

* fix: `username` is not optional for `UpdateSuperuserPassword`

* feat: nicer error message

---------

Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
Arc
2024-01-25 13:33:40 +00:00
committed by GitHub
parent b71113700a
commit e1bb2113ed
10 changed files with 232 additions and 5 deletions

View File

@@ -52,6 +52,7 @@ from .middleware import (
CustomGZipMiddleware, CustomGZipMiddleware,
ExtensionsRedirectMiddleware, ExtensionsRedirectMiddleware,
InstalledExtensionMiddleware, InstalledExtensionMiddleware,
add_first_install_middleware,
add_ip_block_middleware, add_ip_block_middleware,
add_ratelimit_middleware, add_ratelimit_middleware,
) )
@@ -107,6 +108,8 @@ def create_app() -> FastAPI:
register_custom_extensions_path() register_custom_extensions_path()
add_first_install_middleware(app)
# adds security middleware # adds security middleware
add_ip_block_middleware(app) add_ip_block_middleware(app)
add_ratelimit_middleware(app) add_ratelimit_middleware(app)

View File

@@ -151,11 +151,17 @@ async def get_account(
user_id: str, conn: Optional[Connection] = None user_id: str, conn: Optional[Connection] = None
) -> Optional[User]: ) -> Optional[User]:
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
"SELECT id, email, username, created_at, updated_at FROM accounts WHERE id = ?", """
SELECT id, email, username, created_at, updated_at, extra
FROM accounts WHERE id = ?
""",
(user_id,), (user_id,),
) )
return User(**row) if row else None user = User(**row) if row else None
if user and row["extra"]:
user.config = UserConfig(**json.loads(row["extra"]))
return user
async def get_user_password(user_id: str) -> Optional[str]: async def get_user_password(user_id: str) -> Optional[str]:

View File

@@ -87,6 +87,10 @@ class UserConfig(BaseModel):
last_name: Optional[str] = None last_name: Optional[str] = None
display_name: Optional[str] = None display_name: Optional[str] = None
picture: Optional[str] = None picture: Optional[str] = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: Optional[str] = "lnbits" # auth provider provider: Optional[str] = "lnbits" # auth provider
@@ -141,6 +145,13 @@ class UpdateUserPassword(BaseModel):
password: str = Query(default=..., min_length=8, max_length=50) password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50) password_repeat: str = Query(default=..., min_length=8, max_length=50)
password_old: Optional[str] = Query(default=None, min_length=8, max_length=50) password_old: Optional[str] = Query(default=None, min_length=8, max_length=50)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class LoginUsr(BaseModel): class LoginUsr(BaseModel):

View File

@@ -52,7 +52,7 @@ from .crud import (
update_super_user, update_super_user,
) )
from .helpers import to_valid_user_id from .helpers import to_valid_user_id
from .models import Payment, Wallet from .models import Payment, UserConfig, Wallet
class PaymentFailure(Exception): class PaymentFailure(Exception):
@@ -611,6 +611,10 @@ async def check_admin_settings():
): ):
send_admin_user_to_saas() send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.config and account.config.provider == "env":
settings.first_install = True
logger.success( logger.success(
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` " "✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"to get the superuser." "to get the superuser."
@@ -656,7 +660,9 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
if super_user: if super_user:
account = await get_account(super_user) account = await get_account(super_user)
if not account: if not account:
account = await create_account(user_id=super_user) account = await create_account(
user_id=super_user, user_config=UserConfig(provider="env")
)
if not account.wallets or len(account.wallets) == 0: if not account.wallets or len(account.wallets) == 0:
await create_wallet(user_id=account.id) await create_wallet(user_id=account.id)

View File

@@ -0,0 +1,139 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center main">
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section class="grid">
<div>
<h6 class="q-my-none text-center">
<strong>Welcome to LNbits</strong>
<p>Set up the Superuser account below.</p>
</h6>
<br />
<q-form class="q-gutter-md">
<q-input
filled
v-model="loginData.username"
:label="$t('username')"
></q-input>
<q-input
filled
v-model.trim="loginData.password"
:type="loginData.isPwd ? 'password' : 'text'"
autocomplete="off"
:label="$t('password')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwd = !loginData.isPwd"
/> </template
></q-input>
<q-input
filled
v-model.trim="loginData.passwordRepeat"
:type="loginData.isPwdRepeat ? 'password' : 'text'"
autocomplete="off"
:label="$t('password_repeat')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || 'Passwords_dont_match']"
><template v-slot:append>
<q-icon
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
/> </template
></q-input>
<q-btn
@click="setPassword()"
unelevated
color="primary"
:label="$t('login')"
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
></q-btn>
</q-form>
</div>
<div class="hero-wrapper">
<div class="hero q-mx-auto"></div>
</div>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<style>
main {
display: flex;
flex-direction: column;
justify-content: center;
}
.grid {
display: block;
}
.hero-wrapper {
display: none;
}
.hero {
display: block;
height: 100%;
max-width: 250px;
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
background-position: center;
background-size: contain;
background-repeat: no-repeat;
}
@media (min-width: 992px) {
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
}
.hero-wrapper {
display: block;
position: relative;
height: 100%;
padding: 1rem;
}
}
</style>
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
loginData: {
isPwd: true,
isPwdRepeat: true,
username: '',
password: '',
passwordRepeat: ''
}
}
},
created() {
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
},
computed: {
checkPasswordsMatch() {
return this.loginData.password !== this.loginData.passwordRepeat
}
},
methods: {
async setPassword() {
try {
await LNbits.api.request('PUT', '/api/v1/auth/first_install', null, {
username: this.loginData.username,
password: this.loginData.password,
password_repeat: this.loginData.passwordRepeat
})
window.location.href = '/admin'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
})
</script>
{% endblock %}

View File

@@ -38,6 +38,7 @@ from ..models import (
CreateUser, CreateUser,
LoginUsernamePassword, LoginUsernamePassword,
LoginUsr, LoginUsr,
UpdateSuperuserPassword,
UpdateUser, UpdateUser,
UpdateUserPassword, UpdateUserPassword,
User, User,
@@ -250,6 +251,34 @@ async def update(
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user.") raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user.")
@auth_router.put("/api/v1/auth/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install:
raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install")
try:
await update_account(
user_id=settings.super_user,
username=data.username,
user_config=UserConfig(provider="lnbits"),
)
super_user = UpdateUserPassword(
user_id=settings.super_user,
password=data.password,
password_repeat=data.password_repeat,
username=data.username,
)
await update_user_password(super_user)
settings.first_install = False
return _auth_success_response(username=super_user.username)
except AssertionError as e:
raise HTTPException(HTTP_403_FORBIDDEN, str(e))
except Exception as e:
logger.debug(e)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
)
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None): async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
email = userinfo.email email = userinfo.email
if not email or not is_valid_email_address(email): if not email or not is_valid_email_address(email):

View File

@@ -53,6 +53,22 @@ async def home(request: Request, lightning: str = ""):
) )
@generic_router.get("/first_install", response_class=HTMLResponse)
async def first_install(request: Request):
if not settings.first_install:
return template_renderer().TemplateResponse(
"error.html",
{
"request": request,
"err": "Super user account has already been configured.",
},
)
return template_renderer().TemplateResponse(
"core/first_install.html",
{"request": request},
)
@generic_router.get("/robots.txt", response_class=HTMLResponse) @generic_router.get("/robots.txt", response_class=HTMLResponse)
async def robots(): async def robots():
data = """ data = """

View File

@@ -2,7 +2,7 @@ from http import HTTPStatus
from typing import Any, List, Tuple, Union from typing import Any, List, Tuple, Union
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from slowapi import _rate_limit_exceeded_handler from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware from slowapi.middleware import SlowAPIMiddleware
@@ -210,3 +210,18 @@ def add_ip_block_middleware(app: FastAPI):
return await call_next(request) return await call_next(request)
app.middleware("http")(block_allow_ip_middleware) app.middleware("http")(block_allow_ip_middleware)
def add_first_install_middleware(app: FastAPI):
@app.middleware("http")
async def first_install_middleware(request: Request, call_next):
if (
settings.first_install
and request.url.path != "/api/v1/auth/first_install"
and request.url.path != "/first_install"
and not request.url.path.startswith("/static")
):
return RedirectResponse("/first_install")
return await call_next(request)
app.middleware("http")(first_install_middleware)

View File

@@ -390,6 +390,7 @@ class TransientSettings(InstalledExtensionsSettings):
# - are not read from a file or from the `settings` table # - are not read from a file or from the `settings` table
# - are not persisted in the `settings` table when the settings are updated # - are not persisted in the `settings` table when the settings are updated
# - are cleared on server restart # - are cleared on server restart
first_install: bool = Field(default=False)
@classmethod @classmethod
def readonly_fields(cls): def readonly_fields(cls):

View File

@@ -50,6 +50,7 @@ async def app():
clean_database(settings) clean_database(settings)
app = create_app() app = create_app()
await app.router.startup() await app.router.startup()
settings.first_install = False
yield app yield app
await app.router.shutdown() await app.router.shutdown()