mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2024-11-19 01:43:42 +01:00
[feat] Nostr Login (#2703)
---------
Co-authored-by: dni ⚡ <office@dnilabs.com>
This commit is contained in:
parent
f062b3d5e5
commit
0b8da2b524
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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"])
|
||||
],
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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]
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -209,9 +209,7 @@ async def account(
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/account.html",
|
||||
{
|
||||
"user": user.dict(),
|
||||
},
|
||||
{"user": user.dict()},
|
||||
)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
18
lnbits/static/bundle.min.js
vendored
18
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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',
|
||||
|
6
lnbits/static/images/logos/nostr.svg
Normal file
6
lnbits/static/images/logos/nostr.svg
Normal 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 |
@ -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
|
||||
}
|
||||
|
@ -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/')
|
||||
|
@ -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',
|
||||
|
@ -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)
|
||||
}
|
||||
},
|
||||
|
@ -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
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
190
lnbits/utils/nostr.py
Normal 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)
|
@ -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
351
package-lock.json
generated
@ -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"
|
||||
},
|
||||
|
@ -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
858
tests/api/test_auth.py
Normal 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!"
|
||||
)
|
@ -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()
|
||||
|
Loading…
Reference in New Issue
Block a user