feat: partial AUTH support
This commit is contained in:
parent
d0c6f1392b
commit
3648dc212c
7 changed files with 141 additions and 16 deletions
|
|
@ -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
|
||||||
17
models.py
17
models.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
2
tasks.py
2
tasks.py
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue