mirror of
https://github.com/lnbits/lnbits.git
synced 2025-10-09 20:12:34 +02:00
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:
@@ -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)
|
||||||
|
@@ -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]:
|
||||||
|
@@ -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):
|
||||||
|
@@ -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)
|
||||||
|
|
||||||
|
139
lnbits/core/templates/core/first_install.html
Normal file
139
lnbits/core/templates/core/first_install.html
Normal 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 %}
|
@@ -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):
|
||||||
|
@@ -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 = """
|
||||||
|
@@ -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)
|
||||||
|
@@ -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):
|
||||||
|
@@ -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()
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user