feat: partial AUTH support

This commit is contained in:
Vlad Stan 2023-02-14 17:26:40 +02:00
parent d0c6f1392b
commit 3648dc212c
7 changed files with 141 additions and 16 deletions

View file

@ -5,6 +5,8 @@ from typing import Any, Awaitable, Callable, List, Optional, Tuple
from fastapi import WebSocket from fastapi import WebSocket
from loguru import logger from loguru import logger
from lnbits.helpers import urlsafe_short_hash
from .crud import ( from .crud import (
create_event, create_event,
delete_events, delete_events,
@ -74,7 +76,6 @@ class NostrClientManager:
if c.relay_id not in self._active_relays: if c.relay_id not in self._active_relays:
await c.stop(reason=f"Relay '{c.relay_id}' is not active") await c.stop(reason=f"Relay '{c.relay_id}' is not active")
return False return False
# todo: NIP-42: AUTH
return True return True
def _set_client_callbacks(self, client): def _set_client_callbacks(self, client):
@ -91,13 +92,20 @@ class NostrClientConnection:
self.websocket = websocket self.websocket = websocket
self.relay_id = relay_id self.relay_id = relay_id
self.filters: List[NostrFilter] = [] self.filters: List[NostrFilter] = []
self.authenticated = False
self.pubkey: str = None
self._auth_challenge: str = None
self._auth_challenge_created_at = 0
self._last_event_timestamp = 0 # in seconds
self._event_count_per_timestamp = 0
self.broadcast_event: Optional[ self.broadcast_event: Optional[
Callable[[NostrClientConnection, NostrEvent], Awaitable[None]] Callable[[NostrClientConnection, NostrEvent], Awaitable[None]]
] = None ] = None
self.get_client_config: Optional[Callable[[], RelaySpec]] = None self.get_client_config: Optional[Callable[[], RelaySpec]] = None
self._last_event_timestamp = 0 # in seconds
self._event_count_per_timestamp = 0
async def start(self): async def start(self):
await self.websocket.accept() await self.websocket.accept()
@ -150,13 +158,21 @@ class NostrClientConnection:
return await self._handle_request(data[1], NostrFilter.parse_obj(data[2])) return await self._handle_request(data[1], NostrFilter.parse_obj(data[2]))
if message_type == NostrEventType.CLOSE: if message_type == NostrEventType.CLOSE:
self._handle_close(data[1]) self._handle_close(data[1])
if message_type == NostrEventType.AUTH:
self._handle_auth(data[1])
return [] return []
async def _handle_event(self, e: NostrEvent): async def _handle_event(self, e: NostrEvent):
resp_nip20: List[Any] = ["OK", e.id]
logger.info(f"nostr event: [{e.kind}, {e.pubkey}, '{e.content}']") logger.info(f"nostr event: [{e.kind}, {e.pubkey}, '{e.content}']")
resp_nip20: List[Any] = ["OK", e.id]
if not self.authenticated and self.client_config.event_requires_auth(e.kind):
await self._send_msg(["AUTH", self._current_auth_challenge()])
resp_nip20 += [False, "Relay requires authentication"]
await self._send_msg(resp_nip20)
return None
valid, message = await self._validate_write(e) valid, message = await self._validate_write(e)
if not valid: if not valid:
resp_nip20 += [valid, message] resp_nip20 += [valid, message]
@ -201,6 +217,9 @@ class NostrClientConnection:
await mark_events_deleted(self.relay_id, NostrFilter(ids=ids)) await mark_events_deleted(self.relay_id, NostrFilter(ids=ids))
async def _handle_request(self, subscription_id: str, filter: NostrFilter) -> List: async def _handle_request(self, subscription_id: str, filter: NostrFilter) -> List:
if not self.authenticated and self.client_config.require_auth_filter:
return [["AUTH", self._current_auth_challenge()]]
filter.subscription_id = subscription_id filter.subscription_id = subscription_id
self._remove_filter(subscription_id) self._remove_filter(subscription_id)
if self._can_add_filter(): if self._can_add_filter():
@ -227,6 +246,9 @@ class NostrClientConnection:
def _handle_close(self, subscription_id: str): def _handle_close(self, subscription_id: str):
self._remove_filter(subscription_id) self._remove_filter(subscription_id)
def _handle_auth(self):
raise ValueError('Not supported')
def _can_add_filter(self) -> bool: def _can_add_filter(self) -> bool:
return ( return (
self.client_config.max_client_filters != 0 self.client_config.max_client_filters != 0
@ -318,3 +340,16 @@ class NostrClientConnection:
if created_at > (current_time + self.client_config.created_at_in_future): if created_at > (current_time + self.client_config.created_at_in_future):
return False, "created_at is too much into the future" return False, "created_at is too much into the future"
return True, "" return True, ""
def _auth_challenge_expired(self):
if self._auth_challenge_created_at == 0:
return True
current_time_seconds = round(time.time())
chanllenge_max_age_seconds = 300 # 5 min
return (current_time_seconds - self._auth_challenge_created_at) >= chanllenge_max_age_seconds
def _current_auth_challenge(self):
if self._auth_challenge_expired():
self._auth_challenge = self.relay_id + ":" + urlsafe_short_hash()
self._auth_challenge_created_at = round(time.time())
return self._auth_challenge

View file

@ -62,6 +62,17 @@ class StorageSpec(Spec):
value *= 1024 value *= 1024
return value return value
class AuthSpec(BaseModel):
require_auth_events = Field(False, alias="requireAuthEvents")
skiped_auth_events = Field([], alias="skipedAuthEvents")
require_auth_filter = Field(False, alias="requireAuthFilter")
def event_requires_auth(self, kind: int) -> bool:
if not self.require_auth_events:
return False
return kind not in self.skiped_auth_events
class PaymentSpec(BaseModel): class PaymentSpec(BaseModel):
is_paid_relay = Field(False, alias="isPaidRelay") is_paid_relay = Field(False, alias="isPaidRelay")
cost_to_join = Field(0, alias="costToJoin") cost_to_join = Field(0, alias="costToJoin")
@ -93,7 +104,7 @@ class RelayPublicSpec(FilterSpec, EventSpec, StorageSpec, PaymentSpec):
def is_read_only_relay(self): def is_read_only_relay(self):
self.free_storage_value == 0 and not self.is_paid_relay self.free_storage_value == 0 and not self.is_paid_relay
class RelaySpec(RelayPublicSpec, AuthorSpec, WalletSpec): class RelaySpec(RelayPublicSpec, AuthorSpec, WalletSpec, AuthSpec):
pass pass
@ -135,6 +146,7 @@ class NostrEventType(str, Enum):
EVENT = "EVENT" EVENT = "EVENT"
REQ = "REQ" REQ = "REQ"
CLOSE = "CLOSE" CLOSE = "CLOSE"
AUTH = "AUTH"
class NostrEvent(BaseModel): class NostrEvent(BaseModel):
@ -167,6 +179,9 @@ class NostrEvent(BaseModel):
def is_replaceable_event(self) -> bool: def is_replaceable_event(self) -> bool:
return self.kind in [0, 3] return self.kind in [0, 3]
def is_ephemeral_event(self) -> bool:
return self.kind in [22242]
def is_delete_event(self) -> bool: def is_delete_event(self) -> bool:
return self.kind == 5 return self.kind == 5

View file

@ -328,6 +328,72 @@
</div> </div>
</div> </div>
<q-separator></q-separator> <q-separator></q-separator>
<div class="row items-center no-wrap q-mb-md q-mt-md">
<div class="col-3 q-pr-lg">Require Auth :</div>
<div class="col-2 col-sm-4 q-pr-lg">
<q-toggle
color="secodary"
class="q-ml-md q-mr-md"
v-model="relay.config.requireAuthFilter"
>For Filters</q-toggle
>
</div>
<div class="col-2 col-sm-4 q-pr-lg">
<q-toggle
color="secodary"
class="q-ml-md q-mr-md"
v-model="relay.config.requireAuthEvents"
>For Events</q-toggle
>
</div>
<div class="col-5 col-sm-5">
<q-icon name="info" class="cursor-pointer">
<q-tooltip>
Require client authentication for accessing different types of
resources.
</q-tooltip></q-icon
>
</div>
</div>
<div
v-if="relay.config.requireAuthEvents"
class="row items-center no-wrap q-mb-md q-mt-md"
>
<div class="col-3 q-pr-lg">Skip Auth For Events:</div>
<div class="col-1">
<q-input
filled
dense
v-model.trim="skipEventKind"
type="number"
min="0"
></q-input>
</div>
<div class="col-1">
<q-btn
unelevated
color="secondary"
icon="add"
@click="addSkipAuthForEvent()"
></q-btn>
</div>
<div class="col-7">
<q-chip
v-for="e in relay.config.skipedAuthEvents"
:key="e"
removable
@remove="removeSkipAuthForEvent(e)"
color="primary"
text-color="white"
>
{{ e }}
</q-chip>
<!-- <q-badge color="secondary" class="q-ml-sm" multi-line>
{{ relay.config.skipedAuthEvents }}
</q-badge> -->
</div>
</div>
<q-separator></q-separator>
<div class="row items-center no-wrap q-mb-md q-mt-md"> <div class="row items-center no-wrap q-mb-md q-mt-md">
<div class="col-3 q-pr-lg">Full Storage Action:</div> <div class="col-3 q-pr-lg">Full Storage Action:</div>
<div class="col-3 col-sm-4 q-pr-lg"> <div class="col-3 col-sm-4 q-pr-lg">

View file

@ -17,7 +17,8 @@ async function relayDetails(path) {
name: '', name: '',
description: '' description: ''
} }
} },
skipEventKind: 0
} }
}, },
@ -128,6 +129,18 @@ async function relayDetails(path) {
deleteBlockedPublicKey: function (pubKey) { deleteBlockedPublicKey: function (pubKey) {
this.relay.config.blockedPublicKeys = this.relay.config.blockedPublicKeys =
this.relay.config.blockedPublicKeys.filter(p => p !== pubKey) this.relay.config.blockedPublicKeys.filter(p => p !== pubKey)
},
addSkipAuthForEvent: function () {
value = +this.skipEventKind
if (this.relay.config.skipedAuthEvents.indexOf(value) != -1) {
return
}
this.relay.config.skipedAuthEvents.push(value)
},
removeSkipAuthForEvent: function (eventKind) {
value = +eventKind
this.relay.config.skipedAuthEvents =
this.relay.config.skipedAuthEvents.filter(e => e !== value)
} }
}, },

View file

@ -3,13 +3,13 @@ import asyncio
from loguru import logger from loguru import logger
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import create_account, get_account, update_account from .crud import create_account, get_account, update_account
from .models import NostrAccount from .models import NostrAccount
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name()) register_invoice_listener(invoice_queue, get_current_extension_name())

View file

@ -69,13 +69,9 @@
</q-td> </q-td>
<q-td key="id" :props="props"> <q-td key="id" :props="props">
<a <a style="color: unset" :href="props.row.id" target="_blank">
style="color: unset" {{props.row.id}}</a
:href="props.row.id" >
target="_blank"
>
{{props.row.id}}</a
>
</q-td> </q-td>
<q-td key="toggle" :props="props"> <q-td key="toggle" :props="props">
<q-toggle <q-toggle

View file

@ -1,8 +1,8 @@
from http import HTTPStatus from http import HTTPStatus
from typing import List, Optional from typing import List, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from fastapi import Depends, WebSocket, Request
from fastapi import Depends, Request, WebSocket
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from loguru import logger from loguru import logger
from pydantic.types import UUID4 from pydantic.types import UUID4