[feat] Nostr Login (#2703)

---------

Co-authored-by: dni  <office@dnilabs.com>
This commit is contained in:
Vlad Stan
2024-09-30 13:53:38 +02:00
committed by GitHub
co-authored by dni ⚡
parent f062b3d5e5
commit 0b8da2b524
31 changed files with 8281 additions and 315 deletions
+67 -19
View File
@@ -31,6 +31,7 @@ from .models import (
PaymentHistoryPoint,
TinyURL,
UpdateUserPassword,
UpdateUserPubkey,
User,
UserConfig,
Wallet,
@@ -41,6 +42,7 @@ from .models import (
async def create_account(
user_id: Optional[str] = None,
username: Optional[str] = None,
pubkey: Optional[str] = None,
email: Optional[str] = None,
password: Optional[str] = None,
user_config: Optional[UserConfig] = None,
@@ -52,14 +54,17 @@ async def create_account(
now_ph = db.timestamp_placeholder("now")
await (conn or db).execute(
f"""
INSERT INTO accounts (id, username, pass, email, extra, created_at, updated_at)
VALUES (:user, :username, :password, :email, :extra, {now_ph}, {now_ph})
INSERT INTO accounts
(id, username, pass, email, pubkey, extra, created_at, updated_at)
VALUES
(:user, :username, :password, :email, :pubkey, :extra, {now_ph}, {now_ph})
""",
{
"user": user_id,
"username": username,
"password": password,
"email": email,
"pubkey": pubkey,
"extra": extra,
"now": now,
},
@@ -88,7 +93,7 @@ async def update_account(
if username:
assert not user.username or username == user.username, "Cannot change username."
account = await get_account_by_username(username)
assert not account or account.id == user_id, "Username already in exists."
assert not account or account.id == user_id, "Username already exists."
username = user.username or username
email = user.email or email
@@ -161,7 +166,7 @@ async def get_account(
) -> Optional[User]:
row = await (conn or db).fetchone(
"""
SELECT id, email, username, created_at, updated_at, extra
SELECT id, email, username, pubkey, created_at, updated_at, extra
FROM accounts WHERE id = :id
""",
{"id": user_id},
@@ -210,28 +215,56 @@ async def verify_user_password(user_id: str, password: str) -> bool:
return pwd_context.verify(password, existing_password)
# TODO: , conn: Optional[Connection] = None ??, maybe also not a crud function
async def update_user_password(data: UpdateUserPassword) -> Optional[User]:
assert data.password == data.password_repeat, "Passwords do not match."
async def update_user_password(data: UpdateUserPassword, last_login_time: int) -> User:
# old accounts do not have a pasword
if await get_user_password(data.user_id):
assert data.password_old, "Missing old password"
old_pwd_ok = await verify_user_password(data.user_id, data.password_old)
assert old_pwd_ok, "Invalid credentials."
assert 0 <= time() - last_login_time <= settings.auth_credetials_update_threshold, (
"You can only update your credentials in the first"
f" {settings.auth_credetials_update_threshold} seconds after login."
" Please login again!"
)
assert data.password == data.password_repeat, "Passwords do not match."
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
now = int(time())
now_ph = db.timestamp_placeholder("now")
await db.execute(
f"""
UPDATE accounts SET pass = :pass, updated_at = {now_ph}
UPDATE accounts
SET pass = :pass, updated_at = {db.timestamp_placeholder("now")}
WHERE id = :user
""",
{
"pass": pwd_context.hash(data.password),
"now": now,
"now": int(time()),
"user": data.user_id,
},
)
user = await get_user(data.user_id)
assert user, "Updated account couldn't be retrieved"
return user
async def update_user_pubkey(data: UpdateUserPubkey, last_login_time: int) -> User:
assert 0 <= time() - last_login_time <= settings.auth_credetials_update_threshold, (
"You can only update your credentials in the first"
f" {settings.auth_credetials_update_threshold} seconds after login."
" Please login again!"
)
user = await get_account_by_pubkey(data.pubkey)
if user:
assert user.id == data.user_id, "Public key already in use."
await db.execute(
f"""
UPDATE accounts
SET pubkey = :pubkey, updated_at = {db.timestamp_placeholder("now")}
WHERE id = :user
""",
{
"pubkey": data.pubkey,
"now": int(time()),
"user": data.user_id,
},
)
@@ -246,7 +279,7 @@ async def get_account_by_username(
) -> Optional[User]:
row = await (conn or db).fetchone(
"""
SELECT id, username, email, created_at, updated_at
SELECT id, username, pubkey, email, created_at, updated_at
FROM accounts WHERE username = :username
""",
{"username": username},
@@ -255,12 +288,26 @@ async def get_account_by_username(
return User(**row) if row else None
async def get_account_by_pubkey(
pubkey: str, conn: Optional[Connection] = None
) -> Optional[User]:
row = await (conn or db).fetchone(
"""
SELECT id, username, pubkey, email, created_at, updated_at
FROM accounts WHERE pubkey = :pubkey
""",
{"pubkey": pubkey},
)
return User(**row) if row else None
async def get_account_by_email(
email: str, conn: Optional[Connection] = None
) -> Optional[User]:
row = await (conn or db).fetchone(
"""
SELECT id, username, email, created_at, updated_at
SELECT id, username, pubkey, email, created_at, updated_at
FROM accounts WHERE email = :email
""",
{"email": email},
@@ -281,7 +328,7 @@ async def get_account_by_username_or_email(
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
user = await (conn or db).fetchone(
"""
SELECT id, email, username, pass, extra, created_at, updated_at
SELECT id, email, username, pubkey, pass, extra, created_at, updated_at
FROM accounts WHERE id = :id
""",
{"id": user_id},
@@ -306,6 +353,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
id=user["id"],
email=user["email"],
username=user["username"],
pubkey=user["pubkey"],
extensions=[
e for e in extensions if User.is_extension_for_user(e[0], user["id"])
],
+9
View File
@@ -1,6 +1,7 @@
import importlib
import re
from typing import Any
from urllib.parse import urlparse
from uuid import UUID
from loguru import logger
@@ -103,3 +104,11 @@ async def migrate_databases():
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.info("✔️ All migrations done.")
def is_valid_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
+10
View File
@@ -543,3 +543,13 @@ async def m021_add_success_failed_to_apipayments(db):
)
# TODO: drop column in next release
# await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
async def m022_add_pubkey_to_accounts(db):
"""
Adds pubkey column to accounts.
"""
try:
await db.execute("ALTER TABLE accounts ADD COLUMN pubkey TEXT")
except OperationalError:
pass
+15 -2
View File
@@ -138,6 +138,7 @@ class User(BaseModel):
id: str
email: Optional[str] = None
username: Optional[str] = None
pubkey: Optional[str] = None
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
@@ -182,10 +183,15 @@ class UpdateUser(BaseModel):
class UpdateUserPassword(BaseModel):
user_id: str
password_old: Optional[str] = None
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
password_old: Optional[str] = Query(default=None, min_length=8, max_length=50)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
username: str = Query(default=..., min_length=2, max_length=20)
class UpdateUserPubkey(BaseModel):
user_id: str
pubkey: str = Query(default=..., max_length=64)
class UpdateSuperuserPassword(BaseModel):
@@ -203,6 +209,13 @@ class LoginUsernamePassword(BaseModel):
password: str
class AccessTokenPayload(BaseModel):
sub: str
usr: Optional[str] = None
email: Optional[str] = None
auth_time: Optional[int] = 0
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
+4 -1
View File
@@ -826,6 +826,7 @@ async def create_user_account(
user_id: Optional[str] = None,
email: Optional[str] = None,
username: Optional[str] = None,
pubkey: Optional[str] = None,
password: Optional[str] = None,
wallet_name: Optional[str] = None,
user_config: Optional[UserConfig] = None,
@@ -847,7 +848,9 @@ async def create_user_account(
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
password = pwd_context.hash(password) if password else None
account = await create_account(user_id, username, email, password, user_config)
account = await create_account(
user_id, username, pubkey, email, password, user_config
)
wallet = await create_wallet(user_id=account.id, wallet_name=wallet_name)
account.wallets = [wallet]
@@ -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"
+1
View File
@@ -26,6 +26,7 @@
:label="$t('restart')"
color="primary"
@click="restartServer"
class="q-ml-md"
>
<q-tooltip v-if="needsRestart">
<span v-text="$t('restart_tooltip')"></span>
+56 -17
View File
@@ -26,7 +26,9 @@
</q-tabs>
<q-tab-panels v-model="tab">
<q-tab-panel name="user">
<div v-if="passwordData.show">
<div v-if="credentialsData.show">
<q-separator></q-separator>
<q-card-section>
<div class="row">
<div class="col">
@@ -44,11 +46,18 @@
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-input
v-model="credentialsData.username"
:label="$t('username')"
filled
dense
:readonly="hasUsername"
class="q-mb-md"
></q-input>
<q-input
v-if="user.has_password"
v-model="passwordData.oldPassword"
v-model="credentialsData.oldPassword"
type="password"
autocomplete="off"
label="Old Password"
@@ -57,7 +66,7 @@
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="passwordData.newPassword"
v-model="credentialsData.newPassword"
type="password"
autocomplete="off"
:label="$t('password')"
@@ -66,7 +75,7 @@
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="passwordData.newPasswordRepeat"
v-model="credentialsData.newPasswordRepeat"
type="password"
autocomplete="off"
:label="$t('password_repeat')"
@@ -75,24 +84,47 @@
class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="q-pb-lg">
<q-btn
@click="updatePassword"
:disable="(!passwordData.newPassword || !passwordData.newPasswordRepeat) || passwordData.newPassword !== passwordData.newPasswordRepeat"
:disable="disableUpdatePassword()"
unelevated
color="primary"
class="float-right"
:label="$t('change_password')"
>
</q-btn>
</q-card-section>
<q-separator class="q-mt-xl"></q-separator>
<q-card-section>
<div class="col q-mb-sm">
<h4 class="q-my-none">
<span v-text="$t('pubkey')"></span>
</h4>
</div>
<q-input
v-model="credentialsData.pubkey"
type="text"
label="Pubkey"
filled
dense
></q-input>
<q-btn
@click="passwordData.show = false"
@click="updatePubkey"
unelevated
color="primary"
class="q-mt-md float-right"
:label="$t('update_pubkey')"
>
</q-btn>
</q-card-section>
<q-separator class="q-mt-xl"></q-separator>
<q-card-section class="q-pb-lg">
<q-btn
@click="credentialsData.show = false"
:label="$t('back')"
outline
unelevated
color="grey"
class="float-right"
></q-btn>
</q-card-section>
</div>
@@ -137,6 +169,15 @@
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.pubkey"
:label="$t('pubkey')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.email"
:label="$t('email')"
@@ -225,7 +266,6 @@
v-model="user.config.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>
@@ -236,11 +276,10 @@
<span v-text="$t('update_account')"></span>
</q-btn>
<q-btn
@click="showChangePassword()"
:label="user.has_password ? $t('change_password'): $t('set_password')"
outline
unelevated
color="grey"
@click="showUpdateCredentials()"
:label="$t('update_credentials')"
filled
color="primary"
class="float-right"
></q-btn>
</q-card-section>
+22 -1
View File
@@ -230,7 +230,28 @@
v-if="authAction === 'login' && authMethod === 'username-password'"
>
<div class="row">
{% if "google-auth" in LNBITS_AUTH_METHODS %}
{% if "nostr-auth-nip98" in LNBITS_AUTH_METHODS %}
<div class="col-12 full-width q-pa-sm">
<q-btn
@click="signInWithNostr"
outline
no-caps
rounded
color="grey"
class="full-width"
>
<q-avatar size="32px" class="q-mr-md">
<q-img
class="bg-primary"
:src="'{{ static_url_for('static', 'images/logos/nostr.svg') }}'"
></q-img>
</q-avatar>
<div>
<span v-text="$t('signin_with_nostr')"></span>
</div>
</q-btn>
</div>
{%endif%} {% if "google-auth" in LNBITS_AUTH_METHODS %}
<div class="col-12 full-width q-pa-sm">
<q-btn
href="/api/v1/auth/google"
+7 -2
View File
@@ -2,6 +2,7 @@ import hashlib
import json
from http import HTTPStatus
from io import BytesIO
from time import time
from typing import Dict, List
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
@@ -47,8 +48,12 @@ api_router = APIRouter(tags=["Core"])
@api_router.get("/api/v1/health", status_code=HTTPStatus.OK)
async def health():
return
async def health() -> dict:
return {
"server_time": int(time()),
"up_time": int(time() - settings.server_startup_time),
"version": settings.version,
}
@api_router.get(
+115 -15
View File
@@ -1,4 +1,7 @@
import base64
import importlib
import json
from time import time
from typing import Callable, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -13,7 +16,7 @@ from starlette.status import (
)
from lnbits.core.services import create_user_account
from lnbits.decorators import check_user_exists
from lnbits.decorators import access_token_payload, check_user_exists
from lnbits.helpers import (
create_access_token,
decrypt_internal_message,
@@ -22,23 +25,29 @@ from lnbits.helpers import (
is_valid_username,
)
from lnbits.settings import AuthMethods, settings
from lnbits.utils.nostr import normalize_public_key, verify_event
from ..crud import (
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username_or_email,
get_user,
get_user_password,
update_account,
update_user_password,
update_user_pubkey,
verify_user_password,
)
from ..models import (
AccessTokenPayload,
CreateUser,
LoginUsernamePassword,
LoginUsr,
UpdateSuperuserPassword,
UpdateUser,
UpdateUserPassword,
UpdateUserPubkey,
User,
UserConfig,
)
@@ -66,7 +75,7 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
if not await verify_user_password(user.id, data.password):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(user.username, user.id)
return _auth_success_response(user.username, user.id, user.email)
except HTTPException as exc:
raise exc
except Exception as exc:
@@ -74,6 +83,30 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
@auth_router.post("/nostr", description="Login via Nostr")
async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login with Nostr Auth not allowed.")
try:
event = _nostr_nip98_event(request)
user = await get_account_by_pubkey(event["pubkey"])
if not user:
user = await create_user_account(
pubkey=event["pubkey"], user_config=UserConfig(provider="nostr")
)
return _auth_success_response(user.username or "", user.id, user.email)
except HTTPException as exc:
raise exc
except AssertionError as exc:
raise HTTPException(HTTP_401_UNAUTHORIZED, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
@auth_router.post("/usr", description="Login via the User ID")
async def login_usr(data: LoginUsr) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
@@ -84,7 +117,7 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.")
return _auth_success_response(user.username or "", user.id)
return _auth_success_response(user.username or "", user.id, user.email)
except HTTPException as exc:
raise exc
except Exception as exc:
@@ -168,7 +201,7 @@ async def register(data: CreateUser) -> JSONResponse:
user = await create_user_account(
email=data.email, username=data.username, password=data.password
)
return _auth_success_response(user.username)
return _auth_success_response(user.username, user.id, user.email)
except ValueError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
@@ -181,17 +214,46 @@ async def register(data: CreateUser) -> JSONResponse:
@auth_router.put("/password")
async def update_password(
data: UpdateUserPassword, user: User = Depends(check_user_exists)
data: UpdateUserPassword,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTP_401_UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
)
if data.user_id != user.id:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
try:
return await update_user_password(data)
if data.username and not user.username:
await update_account(user_id=user.id, username=data.username)
# old accounts do not have a pasword
if await get_user_password(data.user_id):
assert data.password_old, "Missing old password"
old_pwd_ok = await verify_user_password(data.user_id, data.password_old)
assert old_pwd_ok, "Invalid credentials."
return await update_user_password(data, payload.auth_time or 0)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
) from exc
@auth_router.put("/pubkey")
async def update_pubkey(
data: UpdateUserPubkey,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
if data.user_id != user.id:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
try:
data.pubkey = normalize_public_key(data.pubkey)
return await update_user_pubkey(data, payload.auth_time or 0)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
@@ -239,9 +301,9 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
password_repeat=data.password_repeat,
username=data.username,
)
await update_user_password(super_user)
user = await update_user_password(super_user, int(time()))
settings.first_install = False
return _auth_success_response(username=super_user.username)
return _auth_success_response(user.username, user.id, user.email)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
@@ -288,9 +350,10 @@ def _auth_success_response(
user_id: Optional[str] = None,
email: Optional[str] = None,
) -> JSONResponse:
access_token = create_access_token(
data={"sub": username or "", "usr": user_id, "email": email}
payload = AccessTokenPayload(
sub=username or "", usr=user_id, email=email, auth_time=int(time())
)
access_token = create_access_token(data=payload.dict())
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true")
@@ -300,7 +363,8 @@ def _auth_success_response(
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
access_token = create_access_token(data={"sub": "" or "", "email": email})
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
access_token = create_access_token(data=payload.dict())
response = RedirectResponse(path)
response.set_cookie("cookie_access_token", access_token, httponly=True)
response.set_cookie("is_lnbits_user_authorized", "true")
@@ -349,3 +413,39 @@ def _find_auth_provider_class(provider: str) -> Callable:
pass
raise ValueError(f"No SSO provider found for '{provider}'.")
def _nostr_nip98_event(request: Request) -> dict:
auth_header = request.headers.get("Authorization")
assert auth_header, "Nostr Auth header missing."
scheme, token = auth_header.split()
assert scheme.lower() == "nostr", "Authorization header is not nostr."
event = None
try:
event_json = base64.b64decode(token.encode("ascii"))
event = json.loads(event_json)
except Exception as exc:
logger.warning(exc)
assert event, "Nostr login event cannot be parsed."
assert verify_event(event), "Nostr login event is not valid."
assert event["kind"] == 27_235, "Invalid event kind."
auth_threshold = settings.auth_credetials_update_threshold
assert (
abs(time() - event["created_at"]) < auth_threshold
), f"More than {auth_threshold} seconds have passed since the event was signed."
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
assert method, "Tag 'method' is missing."
assert method.upper() == "POST", "Incorrect value for tag 'method'."
url = next((v for k, v in event["tags"] if k == "u"), None)
assert url, "Tag 'u' for URL is missing."
accepted_urls = [f"{u}/nostr" for u in settings.nostr_absolute_request_urls]
assert url in accepted_urls, f"Incorrect value for tag 'u': '{url}'."
return event
+1 -3
View File
@@ -209,9 +209,7 @@ async def account(
return template_renderer().TemplateResponse(
request,
"core/account.html",
{
"user": user.dict(),
},
{"user": user.dict()},
)