[feat] Nostr Login (#2703)
--------- Co-authored-by: dni ⚡ <office@dnilabs.com>
This commit is contained in:
+67
-19
@@ -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
|
||||
|
||||
+15
-2
@@ -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(
|
||||
|
||||
+115
-15
@@ -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()},
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user