[feat] Nostr Login (#2703)

---------

Co-authored-by: dni  <office@dnilabs.com>
This commit is contained in:
Vlad Stan 2024-09-30 14:53:38 +03:00 committed by GitHub
parent f062b3d5e5
commit 0b8da2b524
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 8281 additions and 315 deletions

View File

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

View File

@ -4,7 +4,6 @@ import time
from functools import wraps
from pathlib import Path
from typing import List, Optional, Tuple
from urllib.parse import urlparse
import click
import httpx
@ -30,7 +29,7 @@ from lnbits.core.extensions.models import (
ExtensionRelease,
InstallableExtension,
)
from lnbits.core.helpers import migrate_databases
from lnbits.core.helpers import is_valid_url, migrate_databases
from lnbits.core.models import Payment, PaymentState
from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import (
@ -328,7 +327,7 @@ async def extensions_update(
if extension and all_extensions:
click.echo("Only one of extension ID or the '--all' flag must be specified")
return
if url and not _is_url(url):
if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@ -402,7 +401,7 @@ async def extensions_install(
):
"""Install a extension"""
click.echo(f"Installing {extension}... {repo_index}")
if url and not _is_url(url):
if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@ -430,7 +429,7 @@ async def extensions_uninstall(
"""Uninstall a extension"""
click.echo(f"Uninstalling '{extension}'...")
if url and not _is_url(url):
if url and not is_valid_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@ -659,11 +658,3 @@ async def _is_lnbits_started(url: Optional[str]):
return True
except Exception:
return False
def _is_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False

View File

@ -31,6 +31,7 @@ from .models import (
PaymentHistoryPoint,
TinyURL,
UpdateUserPassword,
UpdateUserPubkey,
User,
UserConfig,
Wallet,
@ -41,6 +42,7 @@ from .models import (
async def create_account(
user_id: Optional[str] = None,
username: Optional[str] = None,
pubkey: Optional[str] = None,
email: Optional[str] = None,
password: Optional[str] = None,
user_config: Optional[UserConfig] = None,
@ -52,14 +54,17 @@ async def create_account(
now_ph = db.timestamp_placeholder("now")
await (conn or db).execute(
f"""
INSERT INTO accounts (id, username, pass, email, extra, created_at, updated_at)
VALUES (:user, :username, :password, :email, :extra, {now_ph}, {now_ph})
INSERT INTO accounts
(id, username, pass, email, pubkey, extra, created_at, updated_at)
VALUES
(:user, :username, :password, :email, :pubkey, :extra, {now_ph}, {now_ph})
""",
{
"user": user_id,
"username": username,
"password": password,
"email": email,
"pubkey": pubkey,
"extra": extra,
"now": now,
},
@ -88,7 +93,7 @@ async def update_account(
if username:
assert not user.username or username == user.username, "Cannot change username."
account = await get_account_by_username(username)
assert not account or account.id == user_id, "Username already in exists."
assert not account or account.id == user_id, "Username already exists."
username = user.username or username
email = user.email or email
@ -161,7 +166,7 @@ async def get_account(
) -> Optional[User]:
row = await (conn or db).fetchone(
"""
SELECT id, email, username, created_at, updated_at, extra
SELECT id, email, username, pubkey, created_at, updated_at, extra
FROM accounts WHERE id = :id
""",
{"id": user_id},
@ -210,28 +215,56 @@ async def verify_user_password(user_id: str, password: str) -> bool:
return pwd_context.verify(password, existing_password)
# TODO: , conn: Optional[Connection] = None ??, maybe also not a crud function
async def update_user_password(data: UpdateUserPassword) -> Optional[User]:
assert data.password == data.password_repeat, "Passwords do not match."
async def update_user_password(data: UpdateUserPassword, last_login_time: int) -> User:
# old accounts do not have a pasword
if await get_user_password(data.user_id):
assert data.password_old, "Missing old password"
old_pwd_ok = await verify_user_password(data.user_id, data.password_old)
assert old_pwd_ok, "Invalid credentials."
assert 0 <= time() - last_login_time <= settings.auth_credetials_update_threshold, (
"You can only update your credentials in the first"
f" {settings.auth_credetials_update_threshold} seconds after login."
" Please login again!"
)
assert data.password == data.password_repeat, "Passwords do not match."
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
now = int(time())
now_ph = db.timestamp_placeholder("now")
await db.execute(
f"""
UPDATE accounts SET pass = :pass, updated_at = {now_ph}
UPDATE accounts
SET pass = :pass, updated_at = {db.timestamp_placeholder("now")}
WHERE id = :user
""",
{
"pass": pwd_context.hash(data.password),
"now": now,
"now": int(time()),
"user": data.user_id,
},
)
user = await get_user(data.user_id)
assert user, "Updated account couldn't be retrieved"
return user
async def update_user_pubkey(data: UpdateUserPubkey, last_login_time: int) -> User:
assert 0 <= time() - last_login_time <= settings.auth_credetials_update_threshold, (
"You can only update your credentials in the first"
f" {settings.auth_credetials_update_threshold} seconds after login."
" Please login again!"
)
user = await get_account_by_pubkey(data.pubkey)
if user:
assert user.id == data.user_id, "Public key already in use."
await db.execute(
f"""
UPDATE accounts
SET pubkey = :pubkey, updated_at = {db.timestamp_placeholder("now")}
WHERE id = :user
""",
{
"pubkey": data.pubkey,
"now": int(time()),
"user": data.user_id,
},
)
@ -246,7 +279,7 @@ async def get_account_by_username(
) -> Optional[User]:
row = await (conn or db).fetchone(
"""
SELECT id, username, email, created_at, updated_at
SELECT id, username, pubkey, email, created_at, updated_at
FROM accounts WHERE username = :username
""",
{"username": username},
@ -255,12 +288,26 @@ async def get_account_by_username(
return User(**row) if row else None
async def get_account_by_pubkey(
pubkey: str, conn: Optional[Connection] = None
) -> Optional[User]:
row = await (conn or db).fetchone(
"""
SELECT id, username, pubkey, email, created_at, updated_at
FROM accounts WHERE pubkey = :pubkey
""",
{"pubkey": pubkey},
)
return User(**row) if row else None
async def get_account_by_email(
email: str, conn: Optional[Connection] = None
) -> Optional[User]:
row = await (conn or db).fetchone(
"""
SELECT id, username, email, created_at, updated_at
SELECT id, username, pubkey, email, created_at, updated_at
FROM accounts WHERE email = :email
""",
{"email": email},
@ -281,7 +328,7 @@ async def get_account_by_username_or_email(
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
user = await (conn or db).fetchone(
"""
SELECT id, email, username, pass, extra, created_at, updated_at
SELECT id, email, username, pubkey, pass, extra, created_at, updated_at
FROM accounts WHERE id = :id
""",
{"id": user_id},
@ -306,6 +353,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
id=user["id"],
email=user["email"],
username=user["username"],
pubkey=user["pubkey"],
extensions=[
e for e in extensions if User.is_extension_for_user(e[0], user["id"])
],

View File

@ -1,6 +1,7 @@
import importlib
import re
from typing import Any
from urllib.parse import urlparse
from uuid import UUID
from loguru import logger
@ -103,3 +104,11 @@ async def migrate_databases():
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.info("✔️ All migrations done.")
def is_valid_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False

View File

@ -543,3 +543,13 @@ async def m021_add_success_failed_to_apipayments(db):
)
# TODO: drop column in next release
# await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
async def m022_add_pubkey_to_accounts(db):
"""
Adds pubkey column to accounts.
"""
try:
await db.execute("ALTER TABLE accounts ADD COLUMN pubkey TEXT")
except OperationalError:
pass

View File

@ -138,6 +138,7 @@ class User(BaseModel):
id: str
email: Optional[str] = None
username: Optional[str] = None
pubkey: Optional[str] = None
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
@ -182,10 +183,15 @@ class UpdateUser(BaseModel):
class UpdateUserPassword(BaseModel):
user_id: str
password_old: Optional[str] = None
password: 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)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
username: str = Query(default=..., min_length=2, max_length=20)
class UpdateUserPubkey(BaseModel):
user_id: str
pubkey: str = Query(default=..., max_length=64)
class UpdateSuperuserPassword(BaseModel):
@ -203,6 +209,13 @@ class LoginUsernamePassword(BaseModel):
password: str
class AccessTokenPayload(BaseModel):
sub: str
usr: Optional[str] = None
email: Optional[str] = None
auth_time: Optional[int] = 0
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"

View File

@ -826,6 +826,7 @@ async def create_user_account(
user_id: Optional[str] = None,
email: Optional[str] = None,
username: Optional[str] = None,
pubkey: Optional[str] = None,
password: Optional[str] = None,
wallet_name: Optional[str] = None,
user_config: Optional[UserConfig] = None,
@ -847,7 +848,9 @@ async def create_user_account(
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
password = pwd_context.hash(password) if password else None
account = await create_account(user_id, username, email, password, user_config)
account = await create_account(
user_id, username, pubkey, email, password, user_config
)
wallet = await create_wallet(user_id=account.id, wallet_name=wallet_name)
account.wallets = [wallet]

View File

@ -24,6 +24,40 @@
</div>
</div>
</q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('nostr-auth-nip98')"
class="q-pl-xl"
>
<strong class="q-my-none q-mb-sm">Nostr Auth</strong>
<div class="row">
<div class="col-md-12 col-sm-12 q-pr-sm">
<q-input
filled
v-model="nostrAcceptedUrl"
@keydown.enter="addNostrUrl"
type="text"
label="Nostr Request URL"
hint="Absolute URL that the clients will use to login."
>
<q-btn @click="addNostrUrl" dense flat icon="add"></q-btn>
</q-input>
</div>
<div>
<div>
<q-chip
v-for="url in formData.nostr_absolute_request_urls"
:key="url"
removable
@remove="removeNostrUrl(url)"
color="primary"
text-color="white"
:label="url"
></q-chip>
</div>
</div>
</div>
</q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('google-auth')"
class="q-pl-xl"

View File

@ -26,6 +26,7 @@
:label="$t('restart')"
color="primary"
@click="restartServer"
class="q-ml-md"
>
<q-tooltip v-if="needsRestart">
<span v-text="$t('restart_tooltip')"></span>

View File

@ -26,7 +26,9 @@
</q-tabs>
<q-tab-panels v-model="tab">
<q-tab-panel name="user">
<div v-if="passwordData.show">
<div v-if="credentialsData.show">
<q-separator></q-separator>
<q-card-section>
<div class="row">
<div class="col">
@ -44,11 +46,18 @@
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-input
v-model="credentialsData.username"
:label="$t('username')"
filled
dense
:readonly="hasUsername"
class="q-mb-md"
></q-input>
<q-input
v-if="user.has_password"
v-model="passwordData.oldPassword"
v-model="credentialsData.oldPassword"
type="password"
autocomplete="off"
label="Old Password"
@ -57,7 +66,7 @@
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="passwordData.newPassword"
v-model="credentialsData.newPassword"
type="password"
autocomplete="off"
:label="$t('password')"
@ -66,7 +75,7 @@
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="passwordData.newPasswordRepeat"
v-model="credentialsData.newPasswordRepeat"
type="password"
autocomplete="off"
:label="$t('password_repeat')"
@ -75,24 +84,47 @@
class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="q-pb-lg">
<q-btn
@click="updatePassword"
:disable="(!passwordData.newPassword || !passwordData.newPasswordRepeat) || passwordData.newPassword !== passwordData.newPasswordRepeat"
:disable="disableUpdatePassword()"
unelevated
color="primary"
class="float-right"
:label="$t('change_password')"
>
</q-btn>
</q-card-section>
<q-separator class="q-mt-xl"></q-separator>
<q-card-section>
<div class="col q-mb-sm">
<h4 class="q-my-none">
<span v-text="$t('pubkey')"></span>
</h4>
</div>
<q-input
v-model="credentialsData.pubkey"
type="text"
label="Pubkey"
filled
dense
></q-input>
<q-btn
@click="passwordData.show = false"
@click="updatePubkey"
unelevated
color="primary"
class="q-mt-md float-right"
:label="$t('update_pubkey')"
>
</q-btn>
</q-card-section>
<q-separator class="q-mt-xl"></q-separator>
<q-card-section class="q-pb-lg">
<q-btn
@click="credentialsData.show = false"
:label="$t('back')"
outline
unelevated
color="grey"
class="float-right"
></q-btn>
</q-card-section>
</div>
@ -137,6 +169,15 @@
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.pubkey"
:label="$t('pubkey')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.email"
:label="$t('email')"
@ -225,7 +266,6 @@
v-model="user.config.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>
@ -236,11 +276,10 @@
<span v-text="$t('update_account')"></span>
</q-btn>
<q-btn
@click="showChangePassword()"
:label="user.has_password ? $t('change_password'): $t('set_password')"
outline
unelevated
color="grey"
@click="showUpdateCredentials()"
:label="$t('update_credentials')"
filled
color="primary"
class="float-right"
></q-btn>
</q-card-section>

View File

@ -230,7 +230,28 @@
v-if="authAction === 'login' && authMethod === 'username-password'"
>
<div class="row">
{% if "google-auth" in LNBITS_AUTH_METHODS %}
{% if "nostr-auth-nip98" in LNBITS_AUTH_METHODS %}
<div class="col-12 full-width q-pa-sm">
<q-btn
@click="signInWithNostr"
outline
no-caps
rounded
color="grey"
class="full-width"
>
<q-avatar size="32px" class="q-mr-md">
<q-img
class="bg-primary"
:src="'{{ static_url_for('static', 'images/logos/nostr.svg') }}'"
></q-img>
</q-avatar>
<div>
<span v-text="$t('signin_with_nostr')"></span>
</div>
</q-btn>
</div>
{%endif%} {% if "google-auth" in LNBITS_AUTH_METHODS %}
<div class="col-12 full-width q-pa-sm">
<q-btn
href="/api/v1/auth/google"

View File

@ -2,6 +2,7 @@ import hashlib
import json
from http import HTTPStatus
from io import BytesIO
from time import time
from typing import Dict, List
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
@ -47,8 +48,12 @@ api_router = APIRouter(tags=["Core"])
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
async def health():
return
async def health() -> dict:
return {
"server_time": int(time()),
"up_time": int(time() - settings.server_startup_time),
"version": settings.version,
}
@api_router.get(

View File

@ -1,4 +1,7 @@
import base64
import importlib
import json
from time import time
from typing import Callable, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
@ -13,7 +16,7 @@ from starlette.status import (
)
from lnbits.core.services import create_user_account
from lnbits.decorators import check_user_exists
from lnbits.decorators import access_token_payload, check_user_exists
from lnbits.helpers import (
create_access_token,
decrypt_internal_message,
@ -22,23 +25,29 @@ from lnbits.helpers import (
is_valid_username,
)
from lnbits.settings import AuthMethods, settings
from lnbits.utils.nostr import normalize_public_key, verify_event
from ..crud import (
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username_or_email,
get_user,
get_user_password,
update_account,
update_user_password,
update_user_pubkey,
verify_user_password,
)
from ..models import (
AccessTokenPayload,
CreateUser,
LoginUsernamePassword,
LoginUsr,
UpdateSuperuserPassword,
UpdateUser,
UpdateUserPassword,
UpdateUserPubkey,
User,
UserConfig,
)
@ -66,7 +75,7 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
if not await verify_user_password(user.id, data.password):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(user.username, user.id)
return _auth_success_response(user.username, user.id, user.email)
except HTTPException as exc:
raise exc
except Exception as exc:
@ -74,6 +83,30 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
@auth_router.post("/nostr", description="Login via Nostr")
async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login with Nostr Auth not allowed.")
try:
event = _nostr_nip98_event(request)
user = await get_account_by_pubkey(event["pubkey"])
if not user:
user = await create_user_account(
pubkey=event["pubkey"], user_config=UserConfig(provider="nostr")
)
return _auth_success_response(user.username or "", user.id, user.email)
except HTTPException as exc:
raise exc
except AssertionError as exc:
raise HTTPException(HTTP_401_UNAUTHORIZED, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
@auth_router.post("/usr", description="Login via the User ID")
async def login_usr(data: LoginUsr) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
@ -84,7 +117,7 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.")
return _auth_success_response(user.username or "", user.id)
return _auth_success_response(user.username or "", user.id, user.email)
except HTTPException as exc:
raise exc
except Exception as exc:
@ -168,7 +201,7 @@ async def register(data: CreateUser) -> JSONResponse:
user = await create_user_account(
email=data.email, username=data.username, password=data.password
)
return _auth_success_response(user.username)
return _auth_success_response(user.username, user.id, user.email)
except ValueError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
@ -181,17 +214,46 @@ async def register(data: CreateUser) -> JSONResponse:
@auth_router.put("/password")
async def update_password(
data: UpdateUserPassword, user: User = Depends(check_user_exists)
data: UpdateUserPassword,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTP_401_UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
)
if data.user_id != user.id:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
try:
return await update_user_password(data)
if data.username and not user.username:
await update_account(user_id=user.id, username=data.username)
# old accounts do not have a pasword
if await get_user_password(data.user_id):
assert data.password_old, "Missing old password"
old_pwd_ok = await verify_user_password(data.user_id, data.password_old)
assert old_pwd_ok, "Invalid credentials."
return await update_user_password(data, payload.auth_time or 0)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
) from exc
@auth_router.put("/pubkey")
async def update_pubkey(
data: UpdateUserPubkey,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
if data.user_id != user.id:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
try:
data.pubkey = normalize_public_key(data.pubkey)
return await update_user_pubkey(data, payload.auth_time or 0)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
@ -239,9 +301,9 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
password_repeat=data.password_repeat,
username=data.username,
)
await update_user_password(super_user)
user = await update_user_password(super_user, int(time()))
settings.first_install = False
return _auth_success_response(username=super_user.username)
return _auth_success_response(user.username, user.id, user.email)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
@ -288,9 +350,10 @@ def _auth_success_response(
user_id: Optional[str] = None,
email: Optional[str] = None,
) -> JSONResponse:
access_token = create_access_token(
data={"sub": username or "", "usr": user_id, "email": email}
payload = AccessTokenPayload(
sub=username or "", usr=user_id, email=email, auth_time=int(time())
)
access_token = create_access_token(data=payload.dict())
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true")
@ -300,7 +363,8 @@ def _auth_success_response(
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
access_token = create_access_token(data={"sub": "" or "", "email": email})
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
access_token = create_access_token(data=payload.dict())
response = RedirectResponse(path)
response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true")
@ -349,3 +413,39 @@ def _find_auth_provider_class(provider: str) -> Callable:
pass
raise ValueError(f"No SSO provider found for '{provider}'.")
def _nostr_nip98_event(request: Request) -> dict:
auth_header = request.headers.get("Authorization")
assert auth_header, "Nostr Auth header missing."
scheme, token = auth_header.split()
assert scheme.lower() == "nostr", "Authorization header is not nostr."
event = None
try:
event_json = base64.b64decode(token.encode("ascii"))
event = json.loads(event_json)
except Exception as exc:
logger.warning(exc)
assert event, "Nostr login event cannot be parsed."
assert verify_event(event), "Nostr login event is not valid."
assert event["kind"] == 27_235, "Invalid event kind."
auth_threshold = settings.auth_credetials_update_threshold
assert (
abs(time() - event["created_at"]) < auth_threshold
), f"More than {auth_threshold} seconds have passed since the event was signed."
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
assert method, "Tag 'method' is missing."
assert method.upper() == "POST", "Incorrect value for tag 'method'."
url = next((v for k, v in event["tags"] if k == "u"), None)
assert url, "Tag 'u' for URL is missing."
accepted_urls = [f"{u}/nostr" for u in settings.nostr_absolute_request_urls]
assert url in accepted_urls, f"Incorrect value for tag 'u': '{url}'."
return event

View File

@ -209,9 +209,7 @@ async def account(
return template_renderer().TemplateResponse(
request,
"core/account.html",
{
"user": user.dict(),
},
{"user": user.dict()},
)

View File

@ -18,7 +18,13 @@ from lnbits.core.crud import (
get_user_active_extensions_ids,
get_wallet_for_key,
)
from lnbits.core.models import KeyType, SimpleStatus, User, WalletTypeInfo
from lnbits.core.models import (
AccessTokenPayload,
KeyType,
SimpleStatus,
User,
WalletTypeInfo,
)
from lnbits.db import Filter, Filters, TFilterModel
from lnbits.settings import AuthMethods, settings
@ -162,6 +168,16 @@ async def optional_user_id(
return None
async def access_token_payload(
access_token: Annotated[Optional[str], Depends(check_access_token)],
) -> AccessTokenPayload:
if not access_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing access token.")
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
return AccessTokenPayload(**payload)
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
raise HTTPException(
@ -245,17 +261,17 @@ async def _check_user_extension_access(user_id: str, current_path: str):
)
async def _get_account_from_token(access_token):
async def _get_account_from_token(access_token) -> Optional[User]:
try:
payload = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
if "sub" in payload and payload.get("sub"):
return await get_account_by_username(str(payload.get("sub")))
if "usr" in payload and payload.get("usr"):
return await get_account(str(payload.get("usr")))
if "email" in payload and payload.get("email"):
return await get_account_by_email(str(payload.get("email")))
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
user = await _get_user_from_jwt_payload(payload)
if not user:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Data missing for access token."
)
return user
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
except jwt.ExpiredSignatureError as exc:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
@ -263,3 +279,13 @@ async def _get_account_from_token(access_token):
except jwt.PyJWTError as exc:
logger.debug(exc)
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
async def _get_user_from_jwt_payload(payload) -> Optional[User]:
if "sub" in payload and payload.get("sub"):
return await get_account_by_username(str(payload.get("sub")))
if "usr" in payload and payload.get("usr"):
return await get_account(str(payload.get("usr")))
if "email" in payload and payload.get("email"):
return await get_account_by_email(str(payload.get("email")))
return None

View File

@ -429,10 +429,22 @@ class NodeUISettings(LNbitsSettings):
class AuthMethods(Enum):
user_id_only = "user-id-only"
username_and_password = "username-password"
nostr_auth_nip98 = "nostr-auth-nip98"
google_auth = "google-auth"
github_auth = "github-auth"
keycloak_auth = "keycloak-auth"
@classmethod
def all(cls):
return [
AuthMethods.user_id_only.value,
AuthMethods.username_and_password.value,
AuthMethods.nostr_auth_nip98.value,
AuthMethods.google_auth.value,
AuthMethods.github_auth.value,
AuthMethods.keycloak_auth.value,
]
class AuthSettings(LNbitsSettings):
auth_token_expire_minutes: int = Field(default=525600)
@ -443,11 +455,20 @@ class AuthSettings(LNbitsSettings):
AuthMethods.username_and_password.value,
]
)
# How many seconds after login the user is allowed to update its credentials.
# A fresh login is required afterwards.
auth_credetials_update_threshold: int = Field(default=120)
def is_auth_method_allowed(self, method: AuthMethods):
return method.value in self.auth_allowed_methods
class NostrAuthSettings(LNbitsSettings):
nostr_absolute_request_urls: list[str] = Field(
default=["http://127.0.0.1:5000", "http://localhost:5000"]
)
class GoogleAuthSettings(LNbitsSettings):
google_client_id: str = Field(default="")
google_client_secret: str = Field(default="")
@ -475,6 +496,7 @@ class EditableSettings(
WebPushSettings,
NodeUISettings,
AuthSettings,
NostrAuthSettings,
GoogleAuthSettings,
GitHubAuthSettings,
KeycloakAuthSettings,

File diff suppressed because one or more lines are too long

View File

@ -214,6 +214,7 @@ window.localisation.en = {
login_to_account: 'Login to your account',
create_account: 'Create account',
account_settings: 'Account Settings',
signin_with_nostr: 'Continue with Nostr',
signin_with_google: 'Sign in with Google',
signin_with_github: 'Sign in with GitHub',
signin_with_keycloak: 'Sign in with Keycloak',
@ -222,11 +223,14 @@ window.localisation.en = {
password_config: 'Password Config',
password_repeat: 'Password repeat',
change_password: 'Change Password',
update_credentials: 'Update Credentials',
update_pubkey: 'Update Public Key',
set_password: 'Set Password',
invalid_password: 'Password must have at least 8 characters',
login: 'Login',
register: 'Register',
username: 'Username',
pubkey: 'Public Key',
user_id: 'User ID',
email: 'Email',
first_name: 'First Name',

View File

@ -0,0 +1,6 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.04502 6.46189C3.1858 7.47829 2.51519 8.66234 2.10654 9.96165C4.43272 9.43773 7.52382 9.31199 9.27369 9.17577C9.81856 6.93342 11.3694 5.22546 13.968 5.34072C15.0891 5.39311 16.0741 6.11611 16.7552 7.22681C17.2582 6.69242 17.9288 6.33615 18.8194 6.21042C18.8823 6.21042 19.0185 6.19994 19.1233 6.19994C17.2791 4.1881 14.6491 2.90975 11.7047 2.90975C11.1703 2.90975 10.6464 2.96214 10.1224 3.04597C10.0177 3.04597 9.88143 3.06693 9.7033 3.10884C9.69282 3.10884 9.67187 3.10884 9.66139 3.10884C9.65091 3.10884 9.64043 3.10884 9.61948 3.10884C7.58669 3.60132 6.05685 3.20314 5.18716 2.0191C5.09285 1.89336 4.72611 1.33801 4.61085 0.363525C3.97167 1.02366 3.55254 2.1134 3.9088 3.27649C4.19172 4.19858 4.72611 4.69106 5.30242 4.97398C4.42224 5.02637 3.68876 4.85871 2.95528 4.30337C2.52567 3.97854 2.25323 3.62228 1.80267 2.68971C1.38353 3.34984 1.42545 4.08332 1.50927 4.46054C1.61406 4.9635 1.87601 5.52933 2.21132 5.85415C2.72476 6.35711 3.45824 6.47237 4.0555 6.46189H4.04502Z" fill="white"/>
<path d="M13.9782 15.1276C15.2803 15.1276 16.3358 13.3215 16.3358 11.0935C16.3358 8.86547 15.2803 7.05933 13.9782 7.05933C12.6761 7.05933 11.6206 8.86547 11.6206 11.0935C11.6206 13.3215 12.6761 15.1276 13.9782 15.1276Z" fill="white"/>
<path d="M19.165 14.1532C20.1835 14.1532 21.0092 12.7177 21.0092 10.9468C21.0092 9.17601 20.1835 7.74048 19.165 7.74048C18.1465 7.74048 17.3208 9.17601 17.3208 10.9468C17.3208 12.7177 18.1465 14.1532 19.165 14.1532Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0339 31.9777C10.0445 31.5085 10.2194 30.0309 11.7152 27.0729C12.3753 25.7841 13.8737 24.0028 14.9949 23.0387C15.5922 22.5148 16.1894 22.0747 16.7762 21.6661C17.0801 21.488 17.363 21.2993 17.6354 21.1003C22.5434 18.0911 26.876 18.7195 30.4704 19.2408L30.5761 19.2561C30.5761 19.2561 31.4354 16.6994 27.8937 15.3791C25.9657 14.6666 23.6919 14.0903 21.7953 13.6921C21.5229 14.1846 21.1771 14.6037 20.7684 14.9181C20.7628 14.9223 20.7571 14.9266 20.7512 14.931C20.4473 15.1594 19.7267 15.7009 18.4213 15.5468C17.7507 15.4629 17.2373 15.1905 16.8286 14.7923C16.137 15.9345 15.1416 16.6784 13.989 16.7832C10.7931 17.0556 9.17945 14.3732 9.07466 11.4078C6.67513 11.6384 3.4059 13.1158 1.6665 13.975L1.6755 29.386C3.89486 30.2461 7.22426 31.3076 10.0339 31.9777Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -13,11 +13,13 @@ window.app = Vue.createApp({
'confettiStars'
],
tab: 'user',
passwordData: {
credentialsData: {
show: false,
oldPassword: null,
newPassword: null,
newPasswordRepeat: null
newPasswordRepeat: null,
username: null,
pubkey: null
}
}
},
@ -94,6 +96,7 @@ window.app = Vue.createApp({
}
)
this.user = data
this.hasUsername = !!data.username
Quasar.Notify.create({
type: 'positive',
message: 'Account updated.'
@ -102,11 +105,19 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError(e)
}
},
disableUpdatePassword: function () {
return (
!this.credentialsData.newPassword ||
!this.credentialsData.newPasswordRepeat ||
this.credentialsData.newPassword !==
this.credentialsData.newPasswordRepeat
)
},
updatePassword: async function () {
if (!this.user.username) {
if (!this.credentialsData.username) {
Quasar.Notify.create({
type: 'warning',
message: 'Please set a username first.'
message: 'Please set a username.'
})
return
}
@ -117,14 +128,15 @@ window.app = Vue.createApp({
null,
{
user_id: this.user.id,
username: this.user.username,
password_old: this.passwordData.oldPassword,
password: this.passwordData.newPassword,
password_repeat: this.passwordData.newPasswordRepeat
username: this.credentialsData.username,
password_old: this.credentialsData.oldPassword,
password: this.credentialsData.newPassword,
password_repeat: this.credentialsData.newPasswordRepeat
}
)
this.user = data
this.passwordData.show = false
this.hasUsername = !!data.username
this.credentialsData.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Password updated.'
@ -133,17 +145,34 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError(e)
}
},
showChangePassword: function () {
if (!this.user.username) {
Quasar.Notify.create({
type: 'warning',
message: 'Please set a username first.'
updatePubkey: async function () {
try {
const {data} = await LNbits.api.request(
'PUT',
'/api/v1/auth/pubkey',
null,
{
user_id: this.user.id,
pubkey: this.credentialsData.pubkey
}
)
this.user = data
this.hasUsername = !!data.username
this.credentialsData.show = false
this.$q.notify({
type: 'positive',
message: 'Public key updated.'
})
return
} catch (e) {
LNbits.utils.notifyApiError(e)
}
this.passwordData = {
},
showUpdateCredentials: function () {
this.credentialsData = {
show: true,
oldPassword: null,
username: this.user.username,
pubkey: this.user.pubkey,
newPassword: null,
newPasswordRepeat: null
}

View File

@ -41,6 +41,7 @@ window.app = Vue.createApp({
formAddExtensionsManifest: '',
formAllowedIPs: '',
formBlockedIPs: '',
nostrAcceptedUrl: '',
isSuperUser: false,
wallet: {},
cancel: {},
@ -181,6 +182,16 @@ window.app = Vue.createApp({
b => b !== blocked_ip
)
},
addNostrUrl() {
const url = this.nostrAcceptedUrl.trim()
this.removeNostrUrl(url)
this.formData.nostr_absolute_request_urls.push(url)
this.nostrAcceptedUrl = ''
},
removeNostrUrl(url) {
this.formData.nostr_absolute_request_urls =
this.formData.nostr_absolute_request_urls.filter(b => b !== url)
},
restartServer() {
LNbits.api
.request('GET', '/admin/api/v1/restart/')

View File

@ -17,6 +17,9 @@ window.LNbits = {
data: data
})
},
getServerHealth: function () {
return this.request('get', '/api/v1/health')
},
createInvoice: async function (
wallet,
amount,
@ -85,6 +88,14 @@ window.LNbits = {
data: {username, password}
})
},
loginByProvider: function (provider, headers, data) {
return axios({
method: 'POST',
url: `/api/v1/auth/${provider}`,
headers: headers,
data
})
},
loginUsr: function (usr) {
return axios({
method: 'POST',

View File

@ -42,6 +42,79 @@ window.app = Vue.createApp({
this.authAction = 'register'
this.authMethod = authMethod
},
signInWithNostr: async function () {
try {
const nostrToken = await this.createNostrToken()
if (!nostrToken) {
return
}
resp = await LNbits.api.loginByProvider(
'nostr',
{Authorization: nostrToken},
{}
)
window.location.href = '/wallet'
} catch (error) {
console.warn(error)
const details = error?.response?.data?.detail || `${error}`
Quasar.Notify.create({
type: 'negative',
message: 'Failed to sign in with Nostr.',
caption: details
})
}
},
createNostrToken: async function () {
try {
async function _signEvent(e) {
try {
const {data} = await LNbits.api.getServerHealth()
e.created_at = data.server_time
return await window.nostr.signEvent(e)
} catch (error) {
console.error(error)
Quasar.Notify.create({
type: 'negative',
message: 'Failed to sign nostr event.',
caption: `${error}`
})
}
}
if (!window.nostr?.signEvent) {
Quasar.Notify.create({
type: 'negative',
message: 'No Nostr signing app detected.',
caption: 'Is "window.nostr" present?'
})
return
}
const tagU = `${window.location}nostr`
const tagMethod = 'POST'
const nostrToken = await NostrTools.nip98.getToken(
tagU,
tagMethod,
e => _signEvent(e),
true
)
const isTokenValid = await NostrTools.nip98.validateToken(
nostrToken,
tagU,
tagMethod
)
if (!isTokenValid) {
throw new Error('Invalid signed token!')
}
return nostrToken
} catch (error) {
console.warn(error)
Quasar.Notify.create({
type: 'negative',
message: 'Failed create Nostr event.',
caption: `${error}`
})
}
},
register: async function () {
try {
await LNbits.api.register(
@ -69,6 +142,7 @@ window.app = Vue.createApp({
this.usr = ''
window.location.href = '/wallet'
} catch (e) {
console.warn(e)
LNbits.utils.notifyApiError(e)
}
},

View File

@ -12,6 +12,7 @@
"vendor/qrcode.vue.browser.js",
"vendor/chart.umd.js",
"vendor/showdown.js",
"vendor/nostr.bundle.js",
"i18n/i18n.js",
"i18n/de.js",
"i18n/en.js",

6342
lnbits/static/vendor/nostr.bundle.js vendored Normal file

File diff suppressed because it is too large Load Diff

190
lnbits/utils/nostr.py Normal file
View File

@ -0,0 +1,190 @@
import base64
import hashlib
import json
from typing import Dict, Union
import secp256k1
from bech32 import bech32_decode, bech32_encode, convertbits
from Cryptodome import Random
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
def encrypt_content(
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
) -> str:
"""
Encrypts the content to be sent to the service.
Args:
content (str): The content to be encrypted.
service_pubkey (secp256k1.PublicKey): The service provider's public key.
account_private_key_hex (str): The account private key in hex format.
Returns:
str: The encrypted content.
"""
shared = service_pubkey.tweak_mul(
bytes.fromhex(account_private_key_hex)
).serialize()[1:]
# random iv (16B)
iv = Random.new().read(AES.block_size)
aes = AES.new(shared, AES.MODE_CBC, iv)
content_bytes = content.encode("utf-8")
# padding
content_bytes = pad(content_bytes, AES.block_size)
# Encrypt
encrypted_b64 = base64.b64encode(aes.encrypt(content_bytes)).decode("ascii")
iv_b64 = base64.b64encode(iv).decode("ascii")
encrypted_content = encrypted_b64 + "?iv=" + iv_b64
return encrypted_content
def decrypt_content(
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
) -> str:
"""
Decrypts the content coming from the service.
Args:
content (str): The encrypted content.
service_pubkey (secp256k1.PublicKey): The service provider's public key.
account_private_key_hex (str): The account private key in hex format.
Returns:
str: The decrypted content.
"""
shared = service_pubkey.tweak_mul(
bytes.fromhex(account_private_key_hex)
).serialize()[1:]
# extract iv and content
(encrypted_content_b64, iv_b64) = content.split("?iv=")
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
iv = base64.b64decode(iv_b64.encode("ascii"))
# Decrypt
aes = AES.new(shared, AES.MODE_CBC, iv)
decrypted_bytes = aes.decrypt(encrypted_content)
decrypted_bytes = unpad(decrypted_bytes, AES.block_size)
decrypted = decrypted_bytes.decode("utf-8")
return decrypted
def verify_event(event: Dict) -> bool:
"""
Verify the event signature
Args:
event (Dict): The event to verify.
Returns:
bool: True if the event signature is valid, False otherwise.
"""
signature_data = json_dumps(
[
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
if event_id != event["id"]:
return False
pubkey_hex = event["pubkey"]
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
if not pubkey.schnorr_verify(
bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True
):
return False
return True
def sign_event(
event: Dict, account_public_key_hex: str, account_private_key: secp256k1.PrivateKey
) -> Dict:
"""
Signs the event (in place) with the service secret
Args:
event (Dict): The event to be signed.
account_public_key_hex (str): The account public key in hex format.
account_private_key (secp256k1.PrivateKey): The account private key.
Returns:
Dict: The input event with the signature added.
"""
signature_data = json_dumps(
[
0,
account_public_key_hex,
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id
event["pubkey"] = account_public_key_hex
signature = (
account_private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
).hex()
event["sig"] = signature
return event
def json_dumps(data: Union[Dict, list]) -> str:
"""
Converts a Python dictionary to a JSON string with compact encoding.
Args:
data (Dict): The dictionary to be converted.
Returns:
str: The compact JSON string.
"""
if isinstance(data, Dict):
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
def normalize_public_key(pubkey: str) -> str:
if pubkey.startswith("npub1"):
_, decoded_data = bech32_decode(pubkey)
assert decoded_data, "Public Key is not valid npub."
decoded_data_bits = convertbits(decoded_data, 5, 8, False)
assert decoded_data_bits, "Public Key is not valid npub."
return bytes(decoded_data_bits).hex()
assert len(pubkey) == 64, "Public key has wrong length."
try:
int(pubkey, 16)
except Exception as exc:
raise AssertionError("Public Key is not valid hex.") from exc
return pubkey
def hex_to_npub(hex_pubkey: str) -> str:
"""
Converts a hex public key to a Nostr public key.
Args:
hex_pubkey (str): The hex public key to convert.
Returns:
str: The Nostr public key.
"""
normalize_public_key(hex_pubkey)
pubkey_bytes = bytes.fromhex(hex_pubkey)
bits = convertbits(pubkey_bytes, 8, 5, True)
assert bits
return bech32_encode("npub", bits)

View File

@ -1,5 +1,4 @@
import asyncio
import base64
import hashlib
import json
import random
@ -9,13 +8,17 @@ from urllib.parse import parse_qs, unquote, urlparse
import secp256k1
from bolt11 import decode as bolt11_decode
from Cryptodome import Random
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
from loguru import logger
from websockets.client import connect as ws_connect
from lnbits.settings import settings
from lnbits.utils.nostr import (
decrypt_content,
encrypt_content,
json_dumps,
sign_event,
verify_event,
)
from .base import (
InvoiceResponse,
@ -806,148 +809,3 @@ def parse_nwc(nwc) -> Dict:
else:
raise ValueError("Invalid NWC pairing url")
return data
def json_dumps(data: Union[Dict, list]) -> str:
"""
Converts a Python dictionary to a JSON string with compact encoding.
Args:
data (Dict): The dictionary to be converted.
Returns:
str: The compact JSON string.
"""
if isinstance(data, Dict):
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
def encrypt_content(
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
) -> str:
"""
Encrypts the content to be sent to the service.
Args:
content (str): The content to be encrypted.
service_pubkey (secp256k1.PublicKey): The service provider's public key.
account_private_key_hex (str): The account private key in hex format.
Returns:
str: The encrypted content.
"""
shared = service_pubkey.tweak_mul(
bytes.fromhex(account_private_key_hex)
).serialize()[1:]
# random iv (16B)
iv = Random.new().read(AES.block_size)
aes = AES.new(shared, AES.MODE_CBC, iv)
content_bytes = content.encode("utf-8")
# padding
content_bytes = pad(content_bytes, AES.block_size)
# Encrypt
encrypted_b64 = base64.b64encode(aes.encrypt(content_bytes)).decode("ascii")
iv_b64 = base64.b64encode(iv).decode("ascii")
encrypted_content = encrypted_b64 + "?iv=" + iv_b64
return encrypted_content
def decrypt_content(
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
) -> str:
"""
Decrypts the content coming from the service.
Args:
content (str): The encrypted content.
service_pubkey (secp256k1.PublicKey): The service provider's public key.
account_private_key_hex (str): The account private key in hex format.
Returns:
str: The decrypted content.
"""
shared = service_pubkey.tweak_mul(
bytes.fromhex(account_private_key_hex)
).serialize()[1:]
# extract iv and content
(encrypted_content_b64, iv_b64) = content.split("?iv=")
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
iv = base64.b64decode(iv_b64.encode("ascii"))
# Decrypt
aes = AES.new(shared, AES.MODE_CBC, iv)
decrypted_bytes = aes.decrypt(encrypted_content)
decrypted_bytes = unpad(decrypted_bytes, AES.block_size)
decrypted = decrypted_bytes.decode("utf-8")
return decrypted
def verify_event(event: Dict) -> bool:
"""
Verify the event signature
Args:
event (Dict): The event to verify.
Returns:
bool: True if the event signature is valid, False otherwise.
"""
signature_data = json_dumps(
[
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
if event_id != event["id"]: # Invalid event id
return False
pubkey_hex = event["pubkey"]
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
if not pubkey.schnorr_verify(
bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True
):
return False
return True
def sign_event(
event: Dict, account_public_key_hex: str, account_private_key: secp256k1.PrivateKey
) -> Dict:
"""
Signs the event (in place) with the service secret
Args:
event (Dict): The event to be signed.
account_public_key_hex (str): The account public key in hex format.
account_private_key (secp256k1.PrivateKey): The account private key.
Returns:
Dict: The input event with the signature added.
"""
signature_data = json_dumps(
[
0,
account_public_key_hex,
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id
event["pubkey"] = account_public_key_hex
signature = (
account_private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
).hex()
event["sig"] = signature
return event

351
package-lock.json generated
View File

@ -9,6 +9,7 @@
"axios": "^1.7.7",
"chart.js": "^4.4.4",
"moment": "^2.30.1",
"nostr-tools": "^2.7.2",
"qrcode.vue": "^3.4.1",
"quasar": "2.17.0",
"showdown": "^2.1.0",
@ -31,7 +32,6 @@
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
"integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@ -40,7 +40,6 @@
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@ -49,7 +48,6 @@
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz",
"integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.25.6"
},
@ -64,7 +62,6 @@
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz",
"integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.24.8",
"@babel/helper-validator-identifier": "^7.24.7",
@ -78,7 +75,6 @@
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.3.tgz",
"integrity": "sha512-ysJnTGDtuXPa6R2Ii4JIvfMVvDahUUny3aY8+P4r6/0TYHkblgzIMjV6cAn60em67AB0M7OWNAdcAVfWWeN8Qg==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "10.0.3",
"@intlify/shared": "10.0.3"
@ -94,7 +90,6 @@
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.3.tgz",
"integrity": "sha512-KC2fG8nCzSYmXjHptEt6i/xM3k6S2szsPaHDCRgWKEYAbeHe6JFm6X4KRw3Csy112A8CxpavMi1dh3h7khwV5w==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "10.0.3",
"source-map-js": "^1.0.2"
@ -110,7 +105,6 @@
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.3.tgz",
"integrity": "sha512-PWxrCb6fDlnoGLnXLlWu6d7o/HdWACB9TjRnpLro+9uyfqgWA9hvqg5vekcPRyraTieV5srCbTk/ldYw9V3LHw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
@ -180,6 +174,94 @@
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
},
"node_modules/@noble/ciphers": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
]
},
"node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"dependencies": {
"@noble/hashes": "~1.3.0",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.11",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.11.tgz",
@ -194,7 +276,6 @@
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.8.tgz",
"integrity": "sha512-Uzlxp91EPjfbpeO5KtC0KnXPkuTfGsNDeaKQJxQN718uz+RqDYarEf7UhQJGK+ZYloD2taUbHTI2J4WrUaZQNA==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.3",
"@vue/shared": "3.5.8",
@ -207,7 +288,6 @@
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.8.tgz",
"integrity": "sha512-GUNHWvoDSbSa5ZSHT9SnV5WkStWfzJwwTd6NMGzilOE/HM5j+9EB9zGXdtu/fCNEmctBqMs6C9SvVPpVPuk1Eg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.8",
"@vue/shared": "3.5.8"
@ -217,7 +297,6 @@
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.8.tgz",
"integrity": "sha512-taYpngQtSysrvO9GULaOSwcG5q821zCoIQBtQQSx7Uf7DxpR6CIHR90toPr9QfDD2mqHQPCSgoWBvJu0yV9zjg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.3",
"@vue/compiler-core": "3.5.8",
@ -234,7 +313,6 @@
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.8.tgz",
"integrity": "sha512-W96PtryNsNG9u0ZnN5Q5j27Z/feGrFV6zy9q5tzJVyJaLiwYxvC0ek4IXClZygyhjm+XKM7WD9pdKi/wIRVC/Q==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.8",
"@vue/shared": "3.5.8"
@ -243,14 +321,12 @@
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"node_modules/@vue/reactivity": {
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.8.tgz",
"integrity": "sha512-mlgUyFHLCUZcAYkqvzYnlBRCh0t5ZQfLYit7nukn1GR96gc48Bp4B7OIcSfVSvlG1k3BPfD+p22gi1t2n9tsXg==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.8"
}
@ -259,7 +335,6 @@
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.8.tgz",
"integrity": "sha512-fJuPelh64agZ8vKkZgp5iCkPaEqFJsYzxLk9vSC0X3G8ppknclNDr61gDc45yBGTaN5Xqc1qZWU3/NoaBMHcjQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.8",
"@vue/shared": "3.5.8"
@ -269,7 +344,6 @@
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.8.tgz",
"integrity": "sha512-DpAUz+PKjTZPUOB6zJgkxVI3GuYc2iWZiNeeHQUw53kdrparSTG6HeXUrYDjaam8dVsCdvQxDz6ZWxnyjccUjQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.8",
"@vue/runtime-core": "3.5.8",
@ -281,7 +355,6 @@
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.8.tgz",
"integrity": "sha512-7AmC9/mEeV9mmXNVyUIm1a1AjUhyeeGNbkLh39J00E7iPeGks8OGRB5blJiMmvqSh8SkaS7jkLWSpXtxUCeagA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.8",
"@vue/shared": "3.5.8"
@ -293,8 +366,7 @@
"node_modules/@vue/shared": {
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.8.tgz",
"integrity": "sha512-mJleSWbAGySd2RJdX1RBtcrUBX6snyOc0qHpgk3lGi4l9/P/3ny3ELqFWqYdkXIwwNN/kdm8nD9ky8o6l/Lx2A==",
"license": "MIT"
"integrity": "sha512-mJleSWbAGySd2RJdX1RBtcrUBX6snyOc0qHpgk3lGi4l9/P/3ny3ELqFWqYdkXIwwNN/kdm8nD9ky8o6l/Lx2A=="
},
"node_modules/acorn": {
"version": "8.11.3",
@ -308,6 +380,19 @@
"node": ">=0.4.0"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -332,6 +417,27 @@
"zxing-wasm": "1.1.3"
}
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -360,19 +466,27 @@
}
},
"node_modules/chokidar": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 14.16.0"
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/clean-css": {
@ -434,8 +548,7 @@
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/debug": {
"version": "4.3.4",
@ -486,8 +599,19 @@
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.6",
@ -521,6 +645,32 @@
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/html-minifier-terser": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz",
@ -557,6 +707,48 @@
"integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
"dev": true
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/jju": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
@ -576,7 +768,6 @@
"version": "0.30.11",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",
"integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0"
}
@ -725,7 +916,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@ -743,6 +933,45 @@
"tslib": "^2.0.3"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nostr-tools": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.7.2.tgz",
"integrity": "sha512-Bq3Ug0SZFtgtL1+0wCnAe8AJtI7yx/00/a2nUug9SkhfOwlKS92Tef12iCK9FdwXw+oFZWMtRnSwcLayQso+xA==",
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1"
},
"optionalDependencies": {
"nostr-wasm": "v0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"optional": true
},
"node_modules/param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@ -766,8 +995,19 @@
"node_modules/picocolors": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
"license": "ISC"
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.4.47",
@ -787,7 +1027,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.1.0",
@ -845,7 +1084,6 @@
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/quasar/-/quasar-2.17.0.tgz",
"integrity": "sha512-xFWwCt4FGuaC0M4/MA5drjBiCP7kj/5BsUPv2+dDIlyQG9YGvKIewCnWYYt02r4ijRqJSzPb7TsH89Gzkno1Mg==",
"license": "MIT",
"engines": {
"node": ">= 10.18.1",
"npm": ">= 6.13.4",
@ -857,17 +1095,15 @@
}
},
"node_modules/readdirp": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.1.tgz",
"integrity": "sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==",
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16.0"
"dependencies": {
"picomatch": "^2.2.1"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/readjson": {
@ -893,13 +1129,12 @@
}
},
"node_modules/sass": {
"version": "1.79.3",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.79.3.tgz",
"integrity": "sha512-m7dZxh0W9EZ3cw50Me5GOuYm/tVAJAn91SUnohLRo9cXBixGUOdvmryN+dXpwR831bhoY3Zv7rEFt85PUwTmzA==",
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.78.0.tgz",
"integrity": "sha512-AaIqGSrjo5lA2Yg7RvFZrlXDBCp3nV4XP73GrLGvdRWWwk+8H3l0SDvq/5bA4eF+0RFPLuWUk3E+P1U/YqnpsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
"source-map-js": ">=0.6.2 <2.0.0"
},
@ -964,7 +1199,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@ -1001,11 +1235,22 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/try-catch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/try-catch/-/try-catch-3.0.1.tgz",
@ -1039,7 +1284,6 @@
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.8.tgz",
"integrity": "sha512-hvuvuCy51nP/1fSRvrrIqTLSvrSyz2Pq+KQ8S8SXCxTWVE0nMaOnSDnSOxV1eYmGfvK7mqiwvd1C59CEEz7dAQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.8",
"@vue/compiler-sfc": "3.5.8",
@ -1060,7 +1304,6 @@
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.3.tgz",
"integrity": "sha512-8ul2S4Hy9orKs7eOlkw/zqnVu98GttUdyIMRyjoMpv6hFPxnybgBLdep/UCmdan5kUHyxqMnr2cGHTBuPBYJaw==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "10.0.3",
"@intlify/shared": "10.0.3",
@ -1080,7 +1323,6 @@
"version": "5.5.10",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.5.10.tgz",
"integrity": "sha512-lj83FKqRyvo0VLMu49wrLsaHueonfXcwyX9r/GDw0y+myOY5xTfsl75hjBgmmByAxzFSlCPI+CGA9FxYVtRAFQ==",
"license": "MIT",
"dependencies": {
"barcode-detector": "2.2.2",
"webrtc-adapter": "8.2.3"
@ -1093,7 +1335,6 @@
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.4.5.tgz",
"integrity": "sha512-4fKZygS8cH1yCyuabAXGUAsyi1b2/o/OKgu/RUb+znIYOxPRxdkytJEx+0wGcpBE1pX6vUgh5jwWOKRGvuA/7Q==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},

View File

@ -26,6 +26,7 @@
"qrcode.vue": "^3.4.1",
"quasar": "2.17.0",
"showdown": "^2.1.0",
"nostr-tools": "^2.7.2",
"underscore": "^1.13.7",
"vue": "3.5.8",
"vue-i18n": "^10.0.3",
@ -46,7 +47,8 @@
"./node_modules/qrcode.vue/dist/qrcode.vue.browser.js",
"./node_modules/chart.js/dist/chart.umd.js",
"./node_modules/quasar/dist/quasar.css",
"./node_modules/showdown/dist/showdown.js"
"./node_modules/showdown/dist/showdown.js",
"./node_modules/nostr-tools/lib/nostr.bundle.js"
],
"bundle": {
"js": [
@ -62,6 +64,7 @@
"vendor/qrcode.vue.browser.js",
"vendor/chart.umd.js",
"vendor/showdown.js",
"vendor/nostr.bundle.js",
"i18n/i18n.js",
"i18n/de.js",
"i18n/en.js",

858
tests/api/test_auth.py Normal file
View File

@ -0,0 +1,858 @@
import base64
import json
import os
import time
import jwt
import pytest
import secp256k1
import shortuuid
from httpx import AsyncClient
from lnbits.core.models import AccessTokenPayload, User
from lnbits.settings import AuthMethods, settings
from lnbits.utils.nostr import hex_to_npub, sign_event
nostr_event = {
"kind": 27235,
"tags": [["u", "http://localhost:5000/nostr"], ["method", "POST"]],
"created_at": 1727681048,
"content": "",
"pubkey": "f6e80df16fa27f1f2774af0ac61b096f8f63ce9116f0a954fca1e25baee84ba9",
"id": "0fd22355fe63043116fdfceb77be6bf22686aacd16b9e99a10fea6e55ae3f589",
"sig": "fb7eb47fa8355747f6837e55620103d73ba47b2c3164ab8319d2f164022a9f25"
"6e00ecda7d3c8945f07b7d6ecc18cfff34c07bc99677309e2b9310d9fc1bb138",
}
private_key = secp256k1.PrivateKey(
bytes.fromhex("6e00ecda7d3c8945f07b7d6ecc18cfff34c07bc99677309e2b9310d9fc1bb138")
)
pubkey_hex = private_key.pubkey.serialize().hex()[2:]
settings.auth_allowed_methods = AuthMethods.all()
################################ LOGIN ################################
@pytest.mark.asyncio
async def test_login_bad_user(http_client: AsyncClient):
response = await http_client.post(
"/api/v1/auth", json={"username": "non_existing_user", "password": "secret1234"}
)
assert response.status_code == 401, "User does not exist"
assert response.json().get("detail") == "Invalid credentials."
@pytest.mark.asyncio
async def test_login_alan_usr(user_alan: User, http_client: AsyncClient):
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
assert response.status_code == 200, "Alan logs in OK."
access_token = response.json().get("access_token")
assert access_token is not None, "Expected access token after login."
response = await http_client.get(
"/api/v1/auth", headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == 200, "Alan logs in OK."
alan = response.json()
assert alan["id"] == user_alan.id
assert alan["username"] == user_alan.username
assert alan["email"] == user_alan.email
@pytest.mark.asyncio
async def test_login_usr_not_allowed(user_alan: User, http_client: AsyncClient):
# exclude 'user_id_only'
settings.auth_allowed_methods = [AuthMethods.username_and_password.value]
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
assert response.status_code == 401, "Login method not allowed."
assert response.json().get("detail") == "Login by 'User ID' not allowed."
settings.auth_allowed_methods = AuthMethods.all()
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
assert response.status_code == 200, "Login with 'usr' allowed."
assert (
response.json().get("access_token") is not None
), "Expected access token after login."
@pytest.mark.asyncio
async def test_login_alan_username_password_ok(
user_alan: User, http_client: AsyncClient
):
response = await http_client.post(
"/api/v1/auth", json={"username": user_alan.username, "password": "secret1234"}
)
assert response.status_code == 200, "Alan logs in OK"
access_token = response.json().get("access_token")
assert access_token is not None
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
access_token_payload = AccessTokenPayload(**payload)
assert access_token_payload.sub == "alan", "Subject is Alan."
assert access_token_payload.email == "alan@lnbits.com"
assert access_token_payload.auth_time, "Auth time should be set by server."
assert (
0 <= time.time() - access_token_payload.auth_time <= 5
), "Auth time should be very close to now()."
response = await http_client.get(
"/api/v1/auth", headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == 200, "User exits."
user = User(**response.json())
assert user.username == "alan", "Username check."
assert user.email == "alan@lnbits.com", "Email check."
assert not user.pubkey, "No pubkey."
assert not user.admin, "Not admin."
assert not user.super_user, "Not superuser."
assert user.has_password, "Password configured."
assert len(user.wallets) == 1, "One default wallet."
@pytest.mark.asyncio
async def test_login_alan_email_password_ok(user_alan: User, http_client: AsyncClient):
response = await http_client.post(
"/api/v1/auth", json={"username": user_alan.email, "password": "secret1234"}
)
assert response.status_code == 200, "Alan logs in OK"
access_token = response.json().get("access_token")
assert access_token is not None
@pytest.mark.asyncio
async def test_login_alan_password_nok(user_alan: User, http_client: AsyncClient):
response = await http_client.post(
"/api/v1/auth", json={"username": user_alan.username, "password": "bad_pasword"}
)
assert response.status_code == 401, "User does not exist"
assert response.json().get("detail") == "Invalid credentials."
@pytest.mark.asyncio
async def test_login_username_password_not_allowed(
user_alan: User, http_client: AsyncClient
):
# exclude 'username_password'
settings.auth_allowed_methods = [AuthMethods.user_id_only.value]
response = await http_client.post(
"/api/v1/auth", json={"username": user_alan.username, "password": "secret1234"}
)
assert response.status_code == 401, "Login method not allowed."
assert (
response.json().get("detail") == "Login by 'Username and Password' not allowed."
)
settings.auth_allowed_methods = AuthMethods.all()
response = await http_client.post(
"/api/v1/auth", json={"username": user_alan.username, "password": "secret1234"}
)
assert response.status_code == 200, "Username and password is allowed."
assert response.json().get("access_token") is not None
@pytest.mark.asyncio
async def test_login_alan_change_auth_secret_key(
user_alan: User, http_client: AsyncClient
):
response = await http_client.post(
"/api/v1/auth", json={"username": user_alan.username, "password": "secret1234"}
)
assert response.status_code == 200, "Alan logs in OK"
access_token = response.json().get("access_token")
assert access_token is not None
initial_auth_secret_key = settings.auth_secret_key
settings.auth_secret_key = shortuuid.uuid()
response = await http_client.get(
"/api/v1/auth", headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == 401, "Access token not valid anymore."
assert response.json().get("detail") == "Invalid access token."
settings.auth_secret_key = initial_auth_secret_key
response = await http_client.get(
"/api/v1/auth", headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == 200, "Access token valid again."
################################ REGISTER WITH PASSWORD ################################
@pytest.mark.asyncio
async def test_register_ok(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
},
)
access_token = response.json().get("access_token")
assert response.status_code == 200, "User created."
assert response.json().get("access_token") is not None
response = await http_client.get(
"/api/v1/auth", headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == 200, "User exits."
user = User(**response.json())
assert user.username == f"u21.{tiny_id}", "Username check."
assert user.email == f"u21.{tiny_id}@lnbits.com", "Email check."
assert not user.pubkey, "No pubkey check."
assert not user.admin, "Not admin."
assert not user.super_user, "Not superuser."
assert user.has_password, "Password configured."
assert len(user.wallets) == 1, "One default wallet."
@pytest.mark.asyncio
async def test_register_email_twice(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
},
)
assert response.status_code == 200, "User created."
assert response.json().get("access_token") is not None
tiny_id_2 = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id_2}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
},
)
assert response.status_code == 403, "Not allowed."
assert response.json().get("detail") == "Email already exists."
@pytest.mark.asyncio
async def test_register_username_twice(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
},
)
assert response.status_code == 200, "User created."
assert response.json().get("access_token") is not None
tiny_id_2 = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id_2}@lnbits.com",
},
)
assert response.status_code == 403, "Not allowed."
assert response.json().get("detail") == "Username already exists."
@pytest.mark.asyncio
async def test_register_passwords_do_not_match(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret0000",
"email": f"u21.{tiny_id}@lnbits.com",
},
)
assert response.status_code == 400, "Bad passwords."
assert response.json().get("detail") == "Passwords do not match."
@pytest.mark.asyncio
async def test_register_bad_email(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": "not_an_email_lnbits.com",
},
)
assert response.status_code == 400, "Bad email."
assert response.json().get("detail") == "Invalid email."
################################ CHANGE PASSWORD ################################
@pytest.mark.asyncio
async def test_change_password_ok(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
},
)
assert response.status_code == 200, "User created."
access_token = response.json().get("access_token")
assert access_token is not None
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
access_token_payload = AccessTokenPayload(**payload)
response = await http_client.put(
"/api/v1/auth/password",
headers={"Authorization": f"Bearer {access_token}"},
json={
"username": f"u21.{tiny_id}",
"user_id": access_token_payload.usr,
"password_old": "secret1234",
"password": "secret0000",
"password_repeat": "secret0000",
},
)
assert response.status_code == 200, "Password changed."
user = User(**response.json())
assert user.username == f"u21.{tiny_id}", "Username check."
assert user.email == f"u21.{tiny_id}@lnbits.com", "Email check."
response = await http_client.post(
"/api/v1/auth", json={"username": f"u21.{tiny_id}", "password": "secret1234"}
)
assert response.status_code == 401, "Old password does not work"
assert response.json().get("detail") == "Invalid credentials."
response = await http_client.post(
"/api/v1/auth", json={"username": f"u21.{tiny_id}", "password": "secret0000"}
)
assert response.status_code == 200, "New password works."
assert response.json().get("access_token") is not None, "Access token created."
@pytest.mark.asyncio
async def test_change_password_not_authenticated(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.put(
"/api/v1/auth/password",
json={
"username": f"u21.{tiny_id}",
"user_id": "0000",
"password_old": "secret1234",
"password": "secret0000",
"password_repeat": "secret0000",
},
)
assert response.status_code == 401, "User not authenticated."
assert response.json().get("detail") == "Missing user ID or access token."
@pytest.mark.asyncio
async def test_alan_change_password_old_nok(user_alan: User, http_client: AsyncClient):
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
assert response.status_code == 200, "Alan logs in OK."
access_token = response.json().get("access_token")
assert access_token is not None
response = await http_client.put(
"/api/v1/auth/password",
headers={"Authorization": f"Bearer {access_token}"},
json={
"username": user_alan.username,
"user_id": user_alan.id,
"password_old": "secret0000",
"password": "secret0001",
"password_repeat": "secret0001",
},
)
assert response.status_code == 403, "Old password bad."
assert response.json().get("detail") == "Invalid credentials."
@pytest.mark.asyncio
async def test_alan_change_password_different_user(
user_alan: User, http_client: AsyncClient
):
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
assert response.status_code == 200, "Alan logs in OK."
access_token = response.json().get("access_token")
assert access_token is not None
response = await http_client.put(
"/api/v1/auth/password",
headers={"Authorization": f"Bearer {access_token}"},
json={
"username": user_alan.username,
"user_id": user_alan.id[::-1],
"password_old": "secret1234",
"password": "secret0001",
"password_repeat": "secret0001",
},
)
assert response.status_code == 400, "Different user id."
assert response.json().get("detail") == "Invalid user ID."
@pytest.mark.asyncio
async def test_alan_change_password_auth_threshold_expired(
user_alan: User, http_client: AsyncClient
):
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
assert response.status_code == 200, "Alan logs in OK."
access_token = response.json().get("access_token")
assert access_token is not None
initial_update_threshold = settings.auth_credetials_update_threshold
settings.auth_credetials_update_threshold = 1
time.sleep(1.1)
response = await http_client.put(
"/api/v1/auth/password",
headers={"Authorization": f"Bearer {access_token}"},
json={
"username": user_alan.username,
"user_id": user_alan.id,
"password_old": "secret1234",
"password": "secret1234",
"password_repeat": "secret1234",
},
)
settings.auth_credetials_update_threshold = initial_update_threshold
assert response.status_code == 403, "Treshold expired."
assert (
response.json().get("detail") == "You can only update your credentials"
" in the first 1 seconds after login."
" Please login again!"
)
################################ REGISTER PUBLIC KEY ################################
@pytest.mark.asyncio
async def test_register_nostr_ok(http_client: AsyncClient):
event = {**nostr_event}
event["created_at"] = int(time.time())
private_key = secp256k1.PrivateKey(bytes.fromhex(os.urandom(32).hex()))
pubkey_hex = private_key.pubkey.serialize().hex()[2:]
event_signed = sign_event(event, pubkey_hex, private_key)
base64_event = base64.b64encode(json.dumps(event_signed).encode()).decode("ascii")
response = await http_client.post(
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event}"},
)
assert response.status_code == 200, "User created."
access_token = response.json().get("access_token")
assert access_token is not None
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
access_token_payload = AccessTokenPayload(**payload)
assert access_token_payload.auth_time, "Auth time should be set by server."
assert (
0 <= time.time() - access_token_payload.auth_time <= 5
), "Auth time should be very close to now()."
response = await http_client.get(
"/api/v1/auth", headers={"Authorization": f"Bearer {access_token}"}
)
user = User(**response.json())
assert user.username is None, "No username."
assert user.email is None, "No email."
assert user.pubkey == pubkey_hex, "Pubkey check."
assert not user.admin, "Not admin."
assert not user.super_user, "Not superuser."
assert not user.has_password, "Password configured."
assert len(user.wallets) == 1, "One default wallet."
@pytest.mark.asyncio
async def test_register_nostr_not_allowed(http_client: AsyncClient):
# exclude 'nostr_auth_nip98'
settings.auth_allowed_methods = [AuthMethods.username_and_password.value]
response = await http_client.post(
"/api/v1/auth/nostr",
json={},
)
assert response.status_code == 401, "User not authenticated."
assert response.json().get("detail") == "Login with Nostr Auth not allowed."
settings.auth_allowed_methods = AuthMethods.all()
@pytest.mark.asyncio
async def test_register_nostr_bad_header(http_client: AsyncClient):
response = await http_client.post("/api/v1/auth/nostr")
assert response.status_code == 401, "Missing header."
assert response.json().get("detail") == "Nostr Auth header missing."
response = await http_client.post(
"/api/v1/auth/nostr",
headers={"Authorization": "Bearer xyz"},
)
assert response.status_code == 401, "Non nostr header."
assert response.json().get("detail") == "Authorization header is not nostr."
response = await http_client.post(
"/api/v1/auth/nostr",
headers={"Authorization": "nostr xyz"},
)
assert response.status_code == 401, "Nostr not base64."
assert response.json().get("detail") == "Nostr login event cannot be parsed."
@pytest.mark.asyncio
async def test_register_nostr_bad_event(http_client: AsyncClient):
settings.auth_allowed_methods = AuthMethods.all()
base64_event = base64.b64encode(json.dumps(nostr_event).encode()).decode("ascii")
response = await http_client.post(
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event}"},
)
assert response.status_code == 401, "Nostr event expired."
assert (
response.json().get("detail")
== f"More than {settings.auth_credetials_update_threshold}"
" seconds have passed since the event was signed."
)
corrupted_event = {**nostr_event}
corrupted_event["content"] = "xyz"
base64_event = base64.b64encode(json.dumps(corrupted_event).encode()).decode(
"ascii"
)
response = await http_client.post(
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event}"},
)
assert response.status_code == 401, "Nostr event signature invalid."
assert response.json().get("detail") == "Nostr login event is not valid."
@pytest.mark.asyncio
async def test_register_nostr_bad_event_kind(http_client: AsyncClient):
event_bad_kind = {**nostr_event}
event_bad_kind["kind"] = "12345"
event_bad_kind_signed = sign_event(event_bad_kind, pubkey_hex, private_key)
base64_event_bad_kind = base64.b64encode(
json.dumps(event_bad_kind_signed).encode()
).decode("ascii")
response = await http_client.post(
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event_bad_kind}"},
)
assert response.status_code == 401, "Nostr event kind invalid."
assert response.json().get("detail") == "Invalid event kind."
@pytest.mark.asyncio
async def test_register_nostr_bad_event_tag_u(http_client: AsyncClient):
event_bad_kind = {**nostr_event}
event_bad_kind["created_at"] = int(time.time())
event_bad_kind["tags"] = [["u", "http://localhost:5000/nostr"]]
event_bad_tag_signed = sign_event(event_bad_kind, pubkey_hex, private_key)
base64_event_tag_kind = base64.b64encode(
json.dumps(event_bad_tag_signed).encode()
).decode("ascii")
response = await http_client.post(
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event_tag_kind}"},
)
assert response.status_code == 401, "Nostr event tag missing."
assert response.json().get("detail") == "Tag 'method' is missing."
event_bad_kind["tags"] = [["u", "http://localhost:5000/nostr"], ["method", "XYZ"]]
event_bad_tag_signed = sign_event(event_bad_kind, pubkey_hex, private_key)
base64_event_tag_kind = base64.b64encode(
json.dumps(event_bad_tag_signed).encode()
).decode("ascii")
response = await http_client.post(
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event_tag_kind}"},
)
assert response.status_code == 401, "Nostr event tag invalid."
assert response.json().get("detail") == "Incorrect value for tag 'method'."
@pytest.mark.asyncio
async def test_register_nostr_bad_event_tag_menthod(http_client: AsyncClient):
event_bad_kind = {**nostr_event}
event_bad_kind["created_at"] = int(time.time())
event_bad_kind["tags"] = [["method", "POST"]]
event_bad_tag_signed = sign_event(event_bad_kind, pubkey_hex, private_key)
base64_event = base64.b64encode(json.dumps(event_bad_tag_signed).encode()).decode(
"ascii"
)
response = await http_client.post(
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event}"},
)
assert response.status_code == 401, "Nostr event tag missing."
assert response.json().get("detail") == "Tag 'u' for URL is missing."
event_bad_kind["tags"] = [["u", "http://demo.lnbits.com/nostr"], ["method", "POST"]]
event_bad_tag_signed = sign_event(event_bad_kind, pubkey_hex, private_key)
base64_event = base64.b64encode(json.dumps(event_bad_tag_signed).encode()).decode(
"ascii"
)
response = await http_client.post(
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event}"},
)
assert response.status_code == 401, "Nostr event tag invalid."
assert (
response.json().get("detail") == "Incorrect value for tag 'u':"
" 'http://demo.lnbits.com/nostr'."
)
################################ CHANGE PUBLIC KEY ################################
async def test_change_pubkey_npub_ok(http_client: AsyncClient, user_alan: User):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
},
)
assert response.status_code == 200, "User created."
access_token = response.json().get("access_token")
assert access_token is not None
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
access_token_payload = AccessTokenPayload(**payload)
private_key = secp256k1.PrivateKey(bytes.fromhex(os.urandom(32).hex()))
pubkey_hex = private_key.pubkey.serialize().hex()[2:]
npub = hex_to_npub(pubkey_hex)
response = await http_client.put(
"/api/v1/auth/pubkey",
headers={"Authorization": f"Bearer {access_token}"},
json={
"user_id": access_token_payload.usr,
"pubkey": npub,
},
)
assert response.status_code == 200, "Pubkey changed."
user = User(**response.json())
assert user.username == f"u21.{tiny_id}", "Username check."
assert user.email == f"u21.{tiny_id}@lnbits.com", "Email check."
assert user.pubkey == pubkey_hex
@pytest.mark.asyncio
async def test_change_pubkey_ok(http_client: AsyncClient, user_alan: User):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
},
)
assert response.status_code == 200, "User created."
access_token = response.json().get("access_token")
assert access_token is not None
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
access_token_payload = AccessTokenPayload(**payload)
private_key = secp256k1.PrivateKey(bytes.fromhex(os.urandom(32).hex()))
pubkey_hex = private_key.pubkey.serialize().hex()[2:]
response = await http_client.put(
"/api/v1/auth/pubkey",
headers={"Authorization": f"Bearer {access_token}"},
json={
"user_id": access_token_payload.usr,
"pubkey": pubkey_hex,
},
)
assert response.status_code == 200, "Pubkey changed."
user = User(**response.json())
assert user.username == f"u21.{tiny_id}", "Username check."
assert user.email == f"u21.{tiny_id}@lnbits.com", "Email check."
assert user.pubkey == pubkey_hex
# Login with nostr
event = {**nostr_event}
event["created_at"] = int(time.time())
event["pubkey"] = pubkey_hex
event_signed = sign_event(event, pubkey_hex, private_key)
base64_event = base64.b64encode(json.dumps(event_signed).encode()).decode("ascii")
response = await http_client.post(
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event}"},
)
assert response.status_code == 200, "User logged in."
access_token = response.json().get("access_token")
assert access_token is not None
response = await http_client.get(
"/api/v1/auth", headers={"Authorization": f"Bearer {access_token}"}
)
user = User(**response.json())
assert user.username == f"u21.{tiny_id}", "Username check."
assert user.email == f"u21.{tiny_id}@lnbits.com", "Email check."
assert user.pubkey == pubkey_hex, "No pubkey."
assert not user.admin, "Not admin."
assert not user.super_user, "Not superuser."
assert user.has_password, "Password configured."
assert len(user.wallets) == 1, "One default wallet."
response = await http_client.post(
"/api/v1/auth", json={"username": user_alan.username, "password": "secret1234"}
)
assert response.status_code == 200, "Alan logs in OK"
access_token = response.json().get("access_token")
assert access_token is not None
response = await http_client.put(
"/api/v1/auth/pubkey",
headers={"Authorization": f"Bearer {access_token}"},
json={
"user_id": user_alan.id,
"pubkey": pubkey_hex,
},
)
assert response.status_code == 403, "Pubkey already used."
assert response.json().get("detail") == "Public key already in use."
@pytest.mark.asyncio
async def test_change_pubkey_not_authenticated(
http_client: AsyncClient, user_alan: User
):
response = await http_client.put(
"/api/v1/auth/pubkey",
json={
"user_id": user_alan.id,
"pubkey": pubkey_hex,
},
)
assert response.status_code == 401, "Must be authenticated to change pubkey."
assert response.json().get("detail") == "Missing user ID or access token."
@pytest.mark.asyncio
async def test_change_pubkey_other_user(http_client: AsyncClient, user_alan: User):
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
assert response.status_code == 200, "Alan logs in OK."
access_token = response.json().get("access_token")
assert access_token is not None
response = await http_client.put(
"/api/v1/auth/pubkey",
headers={"Authorization": f"Bearer {access_token}"},
json={
"user_id": user_alan.id[::-1],
"pubkey": pubkey_hex,
},
)
assert response.status_code == 400, "Not your user."
assert response.json().get("detail") == "Invalid user ID."
@pytest.mark.asyncio
async def test_alan_change_pubkey_auth_threshold_expired(
user_alan: User, http_client: AsyncClient
):
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
assert response.status_code == 200, "Alan logs in OK."
access_token = response.json().get("access_token")
assert access_token is not None
initial_update_threshold = settings.auth_credetials_update_threshold
settings.auth_credetials_update_threshold = 1
time.sleep(1.1)
response = await http_client.put(
"/api/v1/auth/pubkey",
headers={"Authorization": f"Bearer {access_token}"},
json={
"user_id": user_alan.id,
"pubkey": pubkey_hex,
},
)
settings.auth_credetials_update_threshold = initial_update_threshold
assert response.status_code == 403, "Treshold expired."
assert (
response.json().get("detail") == "You can only update your credentials"
" in the first 1 seconds after login."
" Please login again!"
)

View File

@ -16,11 +16,12 @@ from lnbits.app import create_app
from lnbits.core.crud import (
create_account,
create_wallet,
get_account_by_username,
get_user,
update_payment_status,
)
from lnbits.core.models import CreateInvoice, PaymentState
from lnbits.core.services import update_wallet_balance
from lnbits.core.services import create_user_account, update_wallet_balance
from lnbits.core.views.payment_api import api_payments_create_invoice
from lnbits.db import DB_TYPE, SQLITE, Database
from lnbits.settings import settings
@ -59,6 +60,13 @@ async def client(app):
yield client
@pytest_asyncio.fixture(scope="function")
async def http_client(app):
url = f"http://{settings.host}:{settings.port}"
async with AsyncClient(app=app, base_url=url) as client:
yield client
@pytest.fixture(scope="session")
def test_client(app):
return TestClient(app)
@ -69,6 +77,16 @@ async def db():
yield Database("database")
@pytest_asyncio.fixture(scope="package")
async def user_alan():
user = await get_account_by_username("alan")
if not user:
user = await create_user_account(
email="alan@lnbits.com", username="alan", password="secret1234"
)
yield user
@pytest_asyncio.fixture(scope="session")
async def from_user():
user = await create_account()