Skip to content

Commit 0b8da2b

Browse files
motorina0dni
andauthored
[feat] Nostr Login (lnbits#2703)
--------- Co-authored-by: dni ⚡ <[email protected]>
1 parent f062b3d commit 0b8da2b

31 files changed

+8281
-315
lines changed

.env.example

+1-1
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRI
140140
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
141141
AUTH_SECRET_KEY=""
142142
AUTH_TOKEN_EXPIRE_MINUTES=525600
143-
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth
143+
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
144144
AUTH_ALLOWED_METHODS="user-id-only, username-password"
145145
# Set this flag if HTTP is used for OAuth
146146
# OAUTHLIB_INSECURE_TRANSPORT="1"

lnbits/commands.py

+4-13
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from functools import wraps
55
from pathlib import Path
66
from typing import List, Optional, Tuple
7-
from urllib.parse import urlparse
87

98
import click
109
import httpx
@@ -30,7 +29,7 @@
3029
ExtensionRelease,
3130
InstallableExtension,
3231
)
33-
from lnbits.core.helpers import migrate_databases
32+
from lnbits.core.helpers import is_valid_url, migrate_databases
3433
from lnbits.core.models import Payment, PaymentState
3534
from lnbits.core.services import check_admin_settings
3635
from lnbits.core.views.extension_api import (
@@ -328,7 +327,7 @@ async def extensions_update(
328327
if extension and all_extensions:
329328
click.echo("Only one of extension ID or the '--all' flag must be specified")
330329
return
331-
if url and not _is_url(url):
330+
if url and not is_valid_url(url):
332331
click.echo(f"Invalid '--url' option value: {url}")
333332
return
334333

@@ -402,7 +401,7 @@ async def extensions_install(
402401
):
403402
"""Install a extension"""
404403
click.echo(f"Installing {extension}... {repo_index}")
405-
if url and not _is_url(url):
404+
if url and not is_valid_url(url):
406405
click.echo(f"Invalid '--url' option value: {url}")
407406
return
408407

@@ -430,7 +429,7 @@ async def extensions_uninstall(
430429
"""Uninstall a extension"""
431430
click.echo(f"Uninstalling '{extension}'...")
432431

433-
if url and not _is_url(url):
432+
if url and not is_valid_url(url):
434433
click.echo(f"Invalid '--url' option value: {url}")
435434
return
436435

@@ -659,11 +658,3 @@ async def _is_lnbits_started(url: Optional[str]):
659658
return True
660659
except Exception:
661660
return False
662-
663-
664-
def _is_url(url):
665-
try:
666-
result = urlparse(url)
667-
return all([result.scheme, result.netloc])
668-
except ValueError:
669-
return False

lnbits/core/crud.py

+67-19
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
PaymentHistoryPoint,
3232
TinyURL,
3333
UpdateUserPassword,
34+
UpdateUserPubkey,
3435
User,
3536
UserConfig,
3637
Wallet,
@@ -41,6 +42,7 @@
4142
async def create_account(
4243
user_id: Optional[str] = None,
4344
username: Optional[str] = None,
45+
pubkey: Optional[str] = None,
4446
email: Optional[str] = None,
4547
password: Optional[str] = None,
4648
user_config: Optional[UserConfig] = None,
@@ -52,14 +54,17 @@ async def create_account(
5254
now_ph = db.timestamp_placeholder("now")
5355
await (conn or db).execute(
5456
f"""
55-
INSERT INTO accounts (id, username, pass, email, extra, created_at, updated_at)
56-
VALUES (:user, :username, :password, :email, :extra, {now_ph}, {now_ph})
57+
INSERT INTO accounts
58+
(id, username, pass, email, pubkey, extra, created_at, updated_at)
59+
VALUES
60+
(:user, :username, :password, :email, :pubkey, :extra, {now_ph}, {now_ph})
5761
""",
5862
{
5963
"user": user_id,
6064
"username": username,
6165
"password": password,
6266
"email": email,
67+
"pubkey": pubkey,
6368
"extra": extra,
6469
"now": now,
6570
},
@@ -88,7 +93,7 @@ async def update_account(
8893
if username:
8994
assert not user.username or username == user.username, "Cannot change username."
9095
account = await get_account_by_username(username)
91-
assert not account or account.id == user_id, "Username already in exists."
96+
assert not account or account.id == user_id, "Username already exists."
9297

9398
username = user.username or username
9499
email = user.email or email
@@ -161,7 +166,7 @@ async def get_account(
161166
) -> Optional[User]:
162167
row = await (conn or db).fetchone(
163168
"""
164-
SELECT id, email, username, created_at, updated_at, extra
169+
SELECT id, email, username, pubkey, created_at, updated_at, extra
165170
FROM accounts WHERE id = :id
166171
""",
167172
{"id": user_id},
@@ -210,28 +215,56 @@ async def verify_user_password(user_id: str, password: str) -> bool:
210215
return pwd_context.verify(password, existing_password)
211216

212217

213-
# TODO: , conn: Optional[Connection] = None ??, maybe also not a crud function
214-
async def update_user_password(data: UpdateUserPassword) -> Optional[User]:
215-
assert data.password == data.password_repeat, "Passwords do not match."
218+
async def update_user_password(data: UpdateUserPassword, last_login_time: int) -> User:
216219

217-
# old accounts do not have a pasword
218-
if await get_user_password(data.user_id):
219-
assert data.password_old, "Missing old password"
220-
old_pwd_ok = await verify_user_password(data.user_id, data.password_old)
221-
assert old_pwd_ok, "Invalid credentials."
220+
assert 0 <= time() - last_login_time <= settings.auth_credetials_update_threshold, (
221+
"You can only update your credentials in the first"
222+
f" {settings.auth_credetials_update_threshold} seconds after login."
223+
" Please login again!"
224+
)
225+
assert data.password == data.password_repeat, "Passwords do not match."
222226

223227
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
224228

225-
now = int(time())
226-
now_ph = db.timestamp_placeholder("now")
227229
await db.execute(
228230
f"""
229-
UPDATE accounts SET pass = :pass, updated_at = {now_ph}
231+
UPDATE accounts
232+
SET pass = :pass, updated_at = {db.timestamp_placeholder("now")}
230233
WHERE id = :user
231234
""",
232235
{
233236
"pass": pwd_context.hash(data.password),
234-
"now": now,
237+
"now": int(time()),
238+
"user": data.user_id,
239+
},
240+
)
241+
242+
user = await get_user(data.user_id)
243+
assert user, "Updated account couldn't be retrieved"
244+
return user
245+
246+
247+
async def update_user_pubkey(data: UpdateUserPubkey, last_login_time: int) -> User:
248+
249+
assert 0 <= time() - last_login_time <= settings.auth_credetials_update_threshold, (
250+
"You can only update your credentials in the first"
251+
f" {settings.auth_credetials_update_threshold} seconds after login."
252+
" Please login again!"
253+
)
254+
255+
user = await get_account_by_pubkey(data.pubkey)
256+
if user:
257+
assert user.id == data.user_id, "Public key already in use."
258+
259+
await db.execute(
260+
f"""
261+
UPDATE accounts
262+
SET pubkey = :pubkey, updated_at = {db.timestamp_placeholder("now")}
263+
WHERE id = :user
264+
""",
265+
{
266+
"pubkey": data.pubkey,
267+
"now": int(time()),
235268
"user": data.user_id,
236269
},
237270
)
@@ -246,7 +279,7 @@ async def get_account_by_username(
246279
) -> Optional[User]:
247280
row = await (conn or db).fetchone(
248281
"""
249-
SELECT id, username, email, created_at, updated_at
282+
SELECT id, username, pubkey, email, created_at, updated_at
250283
FROM accounts WHERE username = :username
251284
""",
252285
{"username": username},
@@ -255,12 +288,26 @@ async def get_account_by_username(
255288
return User(**row) if row else None
256289

257290

291+
async def get_account_by_pubkey(
292+
pubkey: str, conn: Optional[Connection] = None
293+
) -> Optional[User]:
294+
row = await (conn or db).fetchone(
295+
"""
296+
SELECT id, username, pubkey, email, created_at, updated_at
297+
FROM accounts WHERE pubkey = :pubkey
298+
""",
299+
{"pubkey": pubkey},
300+
)
301+
302+
return User(**row) if row else None
303+
304+
258305
async def get_account_by_email(
259306
email: str, conn: Optional[Connection] = None
260307
) -> Optional[User]:
261308
row = await (conn or db).fetchone(
262309
"""
263-
SELECT id, username, email, created_at, updated_at
310+
SELECT id, username, pubkey, email, created_at, updated_at
264311
FROM accounts WHERE email = :email
265312
""",
266313
{"email": email},
@@ -281,7 +328,7 @@ async def get_account_by_username_or_email(
281328
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
282329
user = await (conn or db).fetchone(
283330
"""
284-
SELECT id, email, username, pass, extra, created_at, updated_at
331+
SELECT id, email, username, pubkey, pass, extra, created_at, updated_at
285332
FROM accounts WHERE id = :id
286333
""",
287334
{"id": user_id},
@@ -306,6 +353,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
306353
id=user["id"],
307354
email=user["email"],
308355
username=user["username"],
356+
pubkey=user["pubkey"],
309357
extensions=[
310358
e for e in extensions if User.is_extension_for_user(e[0], user["id"])
311359
],

lnbits/core/helpers.py

+9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import importlib
22
import re
33
from typing import Any
4+
from urllib.parse import urlparse
45
from uuid import UUID
56

67
from loguru import logger
@@ -103,3 +104,11 @@ async def migrate_databases():
103104
logger.exception(f"Error migrating extension {ext.code}: {e}")
104105

105106
logger.info("✔️ All migrations done.")
107+
108+
109+
def is_valid_url(url):
110+
try:
111+
result = urlparse(url)
112+
return all([result.scheme, result.netloc])
113+
except ValueError:
114+
return False

lnbits/core/migrations.py

+10
Original file line numberDiff line numberDiff line change
@@ -543,3 +543,13 @@ async def m021_add_success_failed_to_apipayments(db):
543543
)
544544
# TODO: drop column in next release
545545
# await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
546+
547+
548+
async def m022_add_pubkey_to_accounts(db):
549+
"""
550+
Adds pubkey column to accounts.
551+
"""
552+
try:
553+
await db.execute("ALTER TABLE accounts ADD COLUMN pubkey TEXT")
554+
except OperationalError:
555+
pass

lnbits/core/models.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ class User(BaseModel):
138138
id: str
139139
email: Optional[str] = None
140140
username: Optional[str] = None
141+
pubkey: Optional[str] = None
141142
extensions: list[str] = []
142143
wallets: list[Wallet] = []
143144
admin: bool = False
@@ -182,10 +183,15 @@ class UpdateUser(BaseModel):
182183

183184
class UpdateUserPassword(BaseModel):
184185
user_id: str
186+
password_old: Optional[str] = None
185187
password: str = Query(default=..., min_length=8, max_length=50)
186188
password_repeat: str = Query(default=..., min_length=8, max_length=50)
187-
password_old: Optional[str] = Query(default=None, min_length=8, max_length=50)
188-
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
189+
username: str = Query(default=..., min_length=2, max_length=20)
190+
191+
192+
class UpdateUserPubkey(BaseModel):
193+
user_id: str
194+
pubkey: str = Query(default=..., max_length=64)
189195

190196

191197
class UpdateSuperuserPassword(BaseModel):
@@ -203,6 +209,13 @@ class LoginUsernamePassword(BaseModel):
203209
password: str
204210

205211

212+
class AccessTokenPayload(BaseModel):
213+
sub: str
214+
usr: Optional[str] = None
215+
email: Optional[str] = None
216+
auth_time: Optional[int] = 0
217+
218+
206219
class PaymentState(str, Enum):
207220
PENDING = "pending"
208221
SUCCESS = "success"

lnbits/core/services.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,7 @@ async def create_user_account(
826826
user_id: Optional[str] = None,
827827
email: Optional[str] = None,
828828
username: Optional[str] = None,
829+
pubkey: Optional[str] = None,
829830
password: Optional[str] = None,
830831
wallet_name: Optional[str] = None,
831832
user_config: Optional[UserConfig] = None,
@@ -847,7 +848,9 @@ async def create_user_account(
847848
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
848849
password = pwd_context.hash(password) if password else None
849850

850-
account = await create_account(user_id, username, email, password, user_config)
851+
account = await create_account(
852+
user_id, username, pubkey, email, password, user_config
853+
)
851854
wallet = await create_wallet(user_id=account.id, wallet_name=wallet_name)
852855
account.wallets = [wallet]
853856

lnbits/core/templates/admin/_tab_security.html

+34
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,40 @@ <h6 class="q-my-none q-mb-sm">Authentication</h6>
2424
</div>
2525
</div>
2626
</q-card-section>
27+
<q-card-section
28+
v-if="formData.auth_allowed_methods?.includes('nostr-auth-nip98')"
29+
class="q-pl-xl"
30+
>
31+
<strong class="q-my-none q-mb-sm">Nostr Auth</strong>
32+
33+
<div class="row">
34+
<div class="col-md-12 col-sm-12 q-pr-sm">
35+
<q-input
36+
filled
37+
v-model="nostrAcceptedUrl"
38+
@keydown.enter="addNostrUrl"
39+
type="text"
40+
label="Nostr Request URL"
41+
hint="Absolute URL that the clients will use to login."
42+
>
43+
<q-btn @click="addNostrUrl" dense flat icon="add"></q-btn>
44+
</q-input>
45+
</div>
46+
<div>
47+
<div>
48+
<q-chip
49+
v-for="url in formData.nostr_absolute_request_urls"
50+
:key="url"
51+
removable
52+
@remove="removeNostrUrl(url)"
53+
color="primary"
54+
text-color="white"
55+
:label="url"
56+
></q-chip>
57+
</div>
58+
</div>
59+
</div>
60+
</q-card-section>
2761
<q-card-section
2862
v-if="formData.auth_allowed_methods?.includes('google-auth')"
2963
class="q-pl-xl"

lnbits/core/templates/admin/index.html

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
:label="$t('restart')"
2727
color="primary"
2828
@click="restartServer"
29+
class="q-ml-md"
2930
>
3031
<q-tooltip v-if="needsRestart">
3132
<span v-text="$t('restart_tooltip')"></span>

0 commit comments

Comments
 (0)