[feat] access control lists (with access tokens) (#2864)
This commit is contained in:
parent
f415a92914
commit
b164317121
25 changed files with 2131 additions and 67 deletions
|
|
@ -58,6 +58,7 @@ from .users import (
|
||||||
get_account_by_username_or_email,
|
get_account_by_username_or_email,
|
||||||
get_accounts,
|
get_accounts,
|
||||||
get_user,
|
get_user,
|
||||||
|
get_user_access_control_lists,
|
||||||
get_user_from_account,
|
get_user_from_account,
|
||||||
update_account,
|
update_account,
|
||||||
)
|
)
|
||||||
|
|
@ -145,6 +146,7 @@ __all__ = [
|
||||||
"get_accounts",
|
"get_accounts",
|
||||||
"get_user",
|
"get_user",
|
||||||
"get_user_from_account",
|
"get_user_from_account",
|
||||||
|
"get_user_access_control_lists",
|
||||||
"update_account",
|
"update_account",
|
||||||
# wallets
|
# wallets
|
||||||
"create_wallet",
|
"create_wallet",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from uuid import uuid4
|
||||||
from lnbits.core.crud.extensions import get_user_active_extensions_ids
|
from lnbits.core.crud.extensions import get_user_active_extensions_ids
|
||||||
from lnbits.core.crud.wallets import get_wallets
|
from lnbits.core.crud.wallets import get_wallets
|
||||||
from lnbits.core.db import db
|
from lnbits.core.db import db
|
||||||
|
from lnbits.core.models import UserAcls
|
||||||
from lnbits.db import Connection, Filters, Page
|
from lnbits.db import Connection, Filters, Page
|
||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
|
|
@ -185,3 +186,20 @@ async def get_user_from_account(
|
||||||
super_user=account.is_super_user,
|
super_user=account.is_super_user,
|
||||||
has_password=account.password_hash is not None,
|
has_password=account.password_hash is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_user_access_control_list(user_acls: UserAcls):
|
||||||
|
user_acls.updated_at = datetime.now(timezone.utc)
|
||||||
|
await db.update("accounts", user_acls)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_access_control_lists(
|
||||||
|
user_id: str, conn: Optional[Connection] = None
|
||||||
|
) -> UserAcls:
|
||||||
|
user_acls = await (conn or db).fetchone(
|
||||||
|
"SELECT id, access_control_list FROM accounts WHERE id = :id",
|
||||||
|
{"id": user_id},
|
||||||
|
UserAcls,
|
||||||
|
)
|
||||||
|
|
||||||
|
return user_acls or UserAcls(id=user_id)
|
||||||
|
|
|
||||||
|
|
@ -684,3 +684,11 @@ async def m029_create_audit_table(db: Connection):
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m030_add_user_api_tokens_column(db: Connection):
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE accounts ADD COLUMN access_control_list TEXT
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ from .users import (
|
||||||
UpdateUserPassword,
|
UpdateUserPassword,
|
||||||
UpdateUserPubkey,
|
UpdateUserPubkey,
|
||||||
User,
|
User,
|
||||||
|
UserAcls,
|
||||||
UserExtra,
|
UserExtra,
|
||||||
)
|
)
|
||||||
from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
|
from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
|
||||||
|
|
@ -73,6 +74,7 @@ __all__ = [
|
||||||
"Account",
|
"Account",
|
||||||
"AccountFilters",
|
"AccountFilters",
|
||||||
"AccountOverview",
|
"AccountOverview",
|
||||||
|
"UserAcls",
|
||||||
"CreateUser",
|
"CreateUser",
|
||||||
"RegisterUser",
|
"RegisterUser",
|
||||||
"LoginUsernamePassword",
|
"LoginUsernamePassword",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ class SimpleStatus(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class DbVersion(BaseModel):
|
class DbVersion(BaseModel):
|
||||||
db: str
|
db: str
|
||||||
version: int
|
version: int
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from fastapi import Query
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from lnbits.core.models.misc import SimpleItem
|
||||||
from lnbits.db import FilterModel
|
from lnbits.db import FilterModel
|
||||||
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
|
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
@ -28,6 +29,66 @@ class UserExtra(BaseModel):
|
||||||
provider: Optional[str] = "lnbits" # auth provider
|
provider: Optional[str] = "lnbits" # auth provider
|
||||||
|
|
||||||
|
|
||||||
|
class EndpointAccess(BaseModel):
|
||||||
|
path: str
|
||||||
|
name: str
|
||||||
|
read: bool = False
|
||||||
|
write: bool = False
|
||||||
|
|
||||||
|
def supports_method(self, method: str) -> bool:
|
||||||
|
# all http methods
|
||||||
|
if method in ["GET", "OPTIONS", "HEAD"]:
|
||||||
|
return self.read
|
||||||
|
if method in ["POST", "PUT", "PATCH", "DELETE"]:
|
||||||
|
return self.write
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class AccessControlList(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
endpoints: list[EndpointAccess] = []
|
||||||
|
token_id_list: list[SimpleItem] = []
|
||||||
|
|
||||||
|
def get_endpoint(self, path: str) -> Optional[EndpointAccess]:
|
||||||
|
for e in self.endpoints:
|
||||||
|
if e.path == path:
|
||||||
|
return e
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_token_by_id(self, token_id: str) -> Optional[SimpleItem]:
|
||||||
|
for t in self.token_id_list:
|
||||||
|
if t.id == token_id:
|
||||||
|
return t
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete_token_by_id(self, token_id: str):
|
||||||
|
self.token_id_list = [t for t in self.token_id_list if t.id != token_id]
|
||||||
|
|
||||||
|
|
||||||
|
class UserAcls(BaseModel):
|
||||||
|
id: str
|
||||||
|
access_control_list: list[AccessControlList] = []
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
def get_acl_by_id(self, acl_id: str) -> Optional[AccessControlList]:
|
||||||
|
for acl in self.access_control_list:
|
||||||
|
if acl.id == acl_id:
|
||||||
|
return acl
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete_acl_by_id(self, acl_id: str):
|
||||||
|
self.access_control_list = [
|
||||||
|
acl for acl in self.access_control_list if acl.id != acl_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_acl_by_token_id(self, token_id: str) -> Optional[AccessControlList]:
|
||||||
|
for acl in self.access_control_list:
|
||||||
|
if acl.get_token_by_id(token_id):
|
||||||
|
return acl
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Account(BaseModel):
|
class Account(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
|
|
@ -35,6 +96,7 @@ class Account(BaseModel):
|
||||||
pubkey: Optional[str] = None
|
pubkey: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
extra: UserExtra = UserExtra()
|
extra: UserExtra = UserExtra()
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
@ -193,8 +255,36 @@ class AccessTokenPayload(BaseModel):
|
||||||
usr: Optional[str] = None
|
usr: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
auth_time: Optional[int] = 0
|
auth_time: Optional[int] = 0
|
||||||
|
api_token_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UpdateBalance(BaseModel):
|
class UpdateBalance(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
amount: int
|
amount: int
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenRequest(BaseModel):
|
||||||
|
acl_id: str
|
||||||
|
token_name: str
|
||||||
|
password: str
|
||||||
|
expiration_time_minutes: int
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
api_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateAccessControlList(AccessControlList):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteAccessControlList(BaseModel):
|
||||||
|
id: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteTokenRequest(BaseModel):
|
||||||
|
id: str
|
||||||
|
acl_id: str
|
||||||
|
password: str
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
|
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
|
||||||
|
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div v-if="user" class="col-12 col-md-6 q-gutter-y-md">
|
<div v-if="user" class="col-md-12 col-lg-6 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -21,6 +21,11 @@
|
||||||
:label="$t('look_and_feel')"
|
:label="$t('look_and_feel')"
|
||||||
@update="val => tab = val.name"
|
@update="val => tab = val.name"
|
||||||
></q-tab>
|
></q-tab>
|
||||||
|
<q-tab
|
||||||
|
name="api_acls"
|
||||||
|
:label="$t('access_control_list')"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
></q-tab>
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
<q-tab-panels v-model="tab">
|
<q-tab-panels v-model="tab">
|
||||||
<q-tab-panel name="user">
|
<q-tab-panel name="user">
|
||||||
|
|
@ -505,6 +510,230 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
<q-tab-panel name="api_acls">
|
||||||
|
<div class="row q-mb-md">
|
||||||
|
<q-badge v-if="user.admin">
|
||||||
|
<span
|
||||||
|
v-text="$t('access_control_list_admin_warning')"
|
||||||
|
></span>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row q-mb-md q-gutter-y-md">
|
||||||
|
<div class="col-sm-12 col-md-6">
|
||||||
|
<q-select
|
||||||
|
v-model="selectedApiAcl.id"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
@update:model-value="handleApiACLSelected"
|
||||||
|
:options="apiAcl.data.map(t => ({label: t.name, value: t.id}))"
|
||||||
|
:label="$t('access_control_list')"
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-12 col-md-6">
|
||||||
|
<q-btn
|
||||||
|
@click="askPasswordAndRunFunction('newApiAclDialog')"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
icon="add"
|
||||||
|
:label="$t('access_control_list')"
|
||||||
|
color="grey"
|
||||||
|
class="float-right"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedApiAcl.id">
|
||||||
|
<div class="row q-mb-md">
|
||||||
|
<div class="col-sm-12 col-md-6">
|
||||||
|
<q-select
|
||||||
|
:options="selectedApiAcl.token_id_list.map(t => ({label: t.name, value: t.id}))"
|
||||||
|
v-model="apiAcl.selectedTokenId"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
:label="$t('api_tokens')"
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-sm-12 col-md-6 q-pl-sm">
|
||||||
|
<q-btn
|
||||||
|
v-if="apiAcl.selectedTokenId"
|
||||||
|
@click="askPasswordAndRunFunction('deleteToken')"
|
||||||
|
icon="delete"
|
||||||
|
filled
|
||||||
|
color="negative"
|
||||||
|
class="float-left"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
@click="askPasswordAndRunFunction('newTokenAclDialog')"
|
||||||
|
outline
|
||||||
|
icon="add"
|
||||||
|
:label="$t('api_token')"
|
||||||
|
filled
|
||||||
|
color="grey"
|
||||||
|
class="float-right"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="apiAcl.apiToken" class="row q-mb-md">
|
||||||
|
<div class="col-12">
|
||||||
|
<q-badge>
|
||||||
|
<span>Use this token in the HTTP</span>
|
||||||
|
<strong>
|
||||||
|
<code>Authorization</code>
|
||||||
|
|
||||||
|
</strong>
|
||||||
|
<span> header.</span>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<table
|
||||||
|
class="full-width lnbits__table-bordered"
|
||||||
|
style="
|
||||||
|
border-collapse: collapse;
|
||||||
|
background-color: grey;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<span class="float-left">Header Name</span>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<span class="float-left">Header Value</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>Authorization</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="row q-pt-sm">
|
||||||
|
<div class="col-2 q-mt-sm">
|
||||||
|
<strong>Bearer </strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-10">
|
||||||
|
<q-input
|
||||||
|
v-model="apiAcl.apiToken"
|
||||||
|
:label="$t('api_token_id')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
readonly
|
||||||
|
:type="selectedApiAcl.showId ? 'text': 'password'"
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
@click="selectedApiAcl.showId = !selectedApiAcl.showId"
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:icon="selectedApiAcl.showId ? 'visibility_off' : 'visibility'"
|
||||||
|
color="black"
|
||||||
|
></q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
@click="copyText(apiAcl.apiToken)"
|
||||||
|
icon="content_copy"
|
||||||
|
color="black"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
></q-btn>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<q-badge>
|
||||||
|
<span
|
||||||
|
>Please store this token. It cannot be later
|
||||||
|
retrieved, only revoked.</span
|
||||||
|
>
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-table
|
||||||
|
row-key="path"
|
||||||
|
:rows="selectedApiAcl.endpoints"
|
||||||
|
:columns="apiAcl.columns"
|
||||||
|
v-model:pagination="apiAcl.pagination"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th
|
||||||
|
v-for="col in props.cols"
|
||||||
|
:key="col.name"
|
||||||
|
:props="props"
|
||||||
|
>
|
||||||
|
<q-toggle
|
||||||
|
v-if="col.name == 'read'"
|
||||||
|
v-model="selectedApiAcl.allRead"
|
||||||
|
@update:model-value="handleAllEndpointsReadAccess()"
|
||||||
|
:label="$t('read')"
|
||||||
|
size="sm"
|
||||||
|
></q-toggle>
|
||||||
|
<q-toggle
|
||||||
|
v-else-if="col.name == 'write'"
|
||||||
|
v-model="selectedApiAcl.allWrite"
|
||||||
|
@update:model-value="handleAllEndpointsWriteAccess()"
|
||||||
|
:label="$t('write')"
|
||||||
|
size="sm"
|
||||||
|
></q-toggle>
|
||||||
|
<span v-else v-text="col.label"></span>
|
||||||
|
</q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td>
|
||||||
|
<span v-text="props.row.name"></span>
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<span v-text="props.row.path"></span>
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-toggle size="sm" v-model="props.row.read" />
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-toggle size="sm" v-model="props.row.write" />
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedApiAcl.id" class="row q-mt-md">
|
||||||
|
<div class="col-sm-12 col-md-6">
|
||||||
|
<q-btn
|
||||||
|
@click="askPasswordAndRunFunction('updateApiACLs')"
|
||||||
|
:label="$t('update')"
|
||||||
|
filled
|
||||||
|
color="primary"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-12 col-md-6">
|
||||||
|
<q-btn
|
||||||
|
@click="askPasswordAndRunFunction('deleteApiACL')"
|
||||||
|
:label="$t('delete')"
|
||||||
|
icon="delete"
|
||||||
|
color="negative"
|
||||||
|
class="float-right"
|
||||||
|
>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-tab-panel>
|
||||||
</q-tab-panels>
|
</q-tab-panels>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -519,4 +748,126 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="apiAcl.showPasswordDialog" position="top">
|
||||||
|
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||||
|
<strong>User Password</strong>
|
||||||
|
<div class="row q-mt-md q-col-gutter-md">
|
||||||
|
<div class="col-12">
|
||||||
|
<q-input
|
||||||
|
v-model="apiAcl.password"
|
||||||
|
type="password"
|
||||||
|
dense
|
||||||
|
filled
|
||||||
|
label="Password"
|
||||||
|
hint="User password is required for this action."
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
@click="runPasswordGuardedFunction()"
|
||||||
|
:label="$t('ok')"
|
||||||
|
color="primary"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
v-text="$t('cancel')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="apiAcl.showNewAclDialog" position="top">
|
||||||
|
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||||
|
<strong>New API Access Control List</strong>
|
||||||
|
<div class="row q-mt-md q-col-gutter-md">
|
||||||
|
<div class="col-12">
|
||||||
|
<q-input v-model="apiAcl.newAclName" dense filled label="ACL Name">
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn @click="addApiACL()" label="Create" color="primary"></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
v-text="$t('close')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
<q-dialog v-model="apiAcl.showNewTokenDialog" position="top">
|
||||||
|
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||||
|
<strong>New API Token</strong>
|
||||||
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
|
<div class="col-12">
|
||||||
|
<q-input v-model="apiAcl.newTokenName" dense filled label="Token Name">
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<q-input
|
||||||
|
v-model="apiAcl.newTokenExpiry"
|
||||||
|
dense
|
||||||
|
filled
|
||||||
|
label="Expiration"
|
||||||
|
hit="Expiration time in minutes (default xxx)"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="event" class="cursor-pointer">
|
||||||
|
<q-popup-proxy
|
||||||
|
cover
|
||||||
|
transition-show="scale"
|
||||||
|
transition-hide="scale"
|
||||||
|
>
|
||||||
|
<q-date v-model="apiAcl.newTokenExpiry" mask="YYYY-MM-DD HH:mm">
|
||||||
|
<div class="row items-center justify-end">
|
||||||
|
<q-btn v-close-popup label="Close" color="primary" flat />
|
||||||
|
</div>
|
||||||
|
</q-date>
|
||||||
|
</q-popup-proxy>
|
||||||
|
</q-icon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon name="access_time" class="cursor-pointer">
|
||||||
|
<q-popup-proxy
|
||||||
|
cover
|
||||||
|
transition-show="scale"
|
||||||
|
transition-hide="scale"
|
||||||
|
>
|
||||||
|
<q-time
|
||||||
|
v-model="apiAcl.newTokenExpiry"
|
||||||
|
mask="YYYY-MM-DD HH:mm"
|
||||||
|
format24h
|
||||||
|
>
|
||||||
|
<div class="row items-center justify-end">
|
||||||
|
<q-btn v-close-popup label="Close" color="primary" flat />
|
||||||
|
</div>
|
||||||
|
</q-time>
|
||||||
|
</q-popup-proxy>
|
||||||
|
</q-icon>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn @click="generateApiToken()" label="Create" color="primary"></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
v-text="$t('close')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ from fastapi import (
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from lnbits.core.crud import get_user
|
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
BaseWallet,
|
BaseWallet,
|
||||||
ConversionData,
|
ConversionData,
|
||||||
|
|
@ -55,16 +54,12 @@ async def health() -> dict:
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
|
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
|
||||||
async def health_check(wallet: WalletTypeInfo = Depends(require_invoice_key)) -> dict:
|
async def health_check(user: User = Depends(check_user_exists)) -> dict:
|
||||||
stat: dict[str, Any] = {
|
stat: dict[str, Any] = {
|
||||||
"server_time": int(time()),
|
"server_time": int(time()),
|
||||||
"up_time": int(time() - settings.server_startup_time),
|
"up_time": int(time() - settings.server_startup_time),
|
||||||
}
|
}
|
||||||
|
|
||||||
user = await get_user(wallet.wallet.user)
|
|
||||||
if not user:
|
|
||||||
return stat
|
|
||||||
|
|
||||||
stat["version"] = settings.version
|
stat["version"] = settings.version
|
||||||
if not user.admin:
|
if not user.admin:
|
||||||
return stat
|
return stat
|
||||||
|
|
@ -227,7 +222,7 @@ async def api_perform_lnurlauth(
|
||||||
|
|
||||||
@api_router.get(
|
@api_router.get(
|
||||||
"/api/v1/rate/history",
|
"/api/v1/rate/history",
|
||||||
dependencies=[Depends(require_invoice_key)],
|
dependencies=[Depends(check_user_exists)],
|
||||||
)
|
)
|
||||||
async def api_exchange_rate_history() -> list[dict]:
|
async def api_exchange_rate_history() -> list[dict]:
|
||||||
return settings.lnbits_exchange_rate_history
|
return settings.lnbits_exchange_rate_history
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,26 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from fastapi_sso.sso.base import OpenID, SSOBase
|
from fastapi_sso.sso.base import OpenID, SSOBase
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.core.crud.users import (
|
||||||
|
get_user_access_control_lists,
|
||||||
|
update_user_access_control_list,
|
||||||
|
)
|
||||||
|
from lnbits.core.models.misc import SimpleItem
|
||||||
|
from lnbits.core.models.users import (
|
||||||
|
ApiTokenRequest,
|
||||||
|
ApiTokenResponse,
|
||||||
|
DeleteAccessControlList,
|
||||||
|
DeleteTokenRequest,
|
||||||
|
EndpointAccess,
|
||||||
|
UpdateAccessControlList,
|
||||||
|
)
|
||||||
from lnbits.core.services import create_user_account
|
from lnbits.core.services import create_user_account
|
||||||
from lnbits.decorators import access_token_payload, check_user_exists
|
from lnbits.decorators import access_token_payload, check_user_exists
|
||||||
from lnbits.helpers import (
|
from lnbits.helpers import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
decrypt_internal_message,
|
decrypt_internal_message,
|
||||||
encrypt_internal_message,
|
encrypt_internal_message,
|
||||||
|
get_api_routes,
|
||||||
is_valid_email_address,
|
is_valid_email_address,
|
||||||
is_valid_username,
|
is_valid_username,
|
||||||
)
|
)
|
||||||
|
|
@ -44,6 +58,7 @@ from ..models import (
|
||||||
UpdateUserPassword,
|
UpdateUserPassword,
|
||||||
UpdateUserPubkey,
|
UpdateUserPubkey,
|
||||||
User,
|
User,
|
||||||
|
UserAcls,
|
||||||
UserExtra,
|
UserExtra,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -98,6 +113,124 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||||
return _auth_success_response(account.username, account.id, account.email)
|
return _auth_success_response(account.username, account.id, account.email)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.get("/acl")
|
||||||
|
async def api_get_user_acls(
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
) -> UserAcls:
|
||||||
|
api_routes = get_api_routes(request.app.router.routes)
|
||||||
|
|
||||||
|
acls = await get_user_access_control_lists(user.id)
|
||||||
|
|
||||||
|
# Add missing/new endpoints to the ACLs
|
||||||
|
for acl in acls.access_control_list:
|
||||||
|
acl_api_routes = {**api_routes}
|
||||||
|
for route in api_routes.keys():
|
||||||
|
if acl.get_endpoint(route):
|
||||||
|
acl_api_routes.pop(route, None)
|
||||||
|
|
||||||
|
for path, name in acl_api_routes.items():
|
||||||
|
acl.endpoints.append(EndpointAccess(path=path, name=name))
|
||||||
|
acl.endpoints.sort(key=lambda e: e.name.lower())
|
||||||
|
|
||||||
|
return UserAcls(id=user.id, access_control_list=acls.access_control_list)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.put("/acl")
|
||||||
|
@auth_router.patch("/acl")
|
||||||
|
async def api_update_user_acl(
|
||||||
|
request: Request,
|
||||||
|
data: UpdateAccessControlList,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
) -> UserAcls:
|
||||||
|
account = await get_account(user.id)
|
||||||
|
if not account or not account.verify_password(data.password):
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||||
|
|
||||||
|
user_acls = await get_user_access_control_lists(user.id)
|
||||||
|
acl = user_acls.get_acl_by_id(data.id)
|
||||||
|
if acl:
|
||||||
|
user_acls.access_control_list.remove(acl)
|
||||||
|
else:
|
||||||
|
data.endpoints = []
|
||||||
|
data.id = uuid4().hex
|
||||||
|
|
||||||
|
api_routes = get_api_routes(request.app.router.routes)
|
||||||
|
for path, name in api_routes.items():
|
||||||
|
data.endpoints.append(EndpointAccess(path=path, name=name))
|
||||||
|
|
||||||
|
api_paths = get_api_routes(request.app.router.routes).keys()
|
||||||
|
data.endpoints = [e for e in data.endpoints if e.path in api_paths]
|
||||||
|
data.endpoints.sort(key=lambda e: e.name.lower())
|
||||||
|
|
||||||
|
user_acls.access_control_list.append(data)
|
||||||
|
user_acls.access_control_list.sort(key=lambda t: t.name.lower())
|
||||||
|
await update_user_access_control_list(user_acls)
|
||||||
|
|
||||||
|
return user_acls
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.delete("/acl")
|
||||||
|
async def api_delete_user_acl(
|
||||||
|
data: DeleteAccessControlList,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
):
|
||||||
|
account = await get_account(user.id)
|
||||||
|
if not account or not account.verify_password(data.password):
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||||
|
|
||||||
|
user_acls = await get_user_access_control_lists(user.id)
|
||||||
|
user_acls.delete_acl_by_id(data.id)
|
||||||
|
await update_user_access_control_list(user_acls)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.post("/acl/token")
|
||||||
|
async def api_create_user_api_token(
|
||||||
|
data: ApiTokenRequest,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
) -> ApiTokenResponse:
|
||||||
|
assert data.expiration_time_minutes > 0, "Expiration time must be in the future."
|
||||||
|
account = await get_account(user.id)
|
||||||
|
if not account or not account.verify_password(data.password):
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||||
|
|
||||||
|
assert account.username, "Username must be configured."
|
||||||
|
|
||||||
|
acls = await get_user_access_control_lists(user.id)
|
||||||
|
acl = acls.get_acl_by_id(data.acl_id)
|
||||||
|
if not acl:
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
|
||||||
|
|
||||||
|
api_token_id = uuid4().hex
|
||||||
|
api_token = _auth_api_token_response(
|
||||||
|
account.username, api_token_id, data.expiration_time_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
acl.token_id_list.append(SimpleItem(id=api_token_id, name=data.token_name))
|
||||||
|
await update_user_access_control_list(acls)
|
||||||
|
return ApiTokenResponse(id=api_token_id, api_token=api_token)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.delete("/acl/token")
|
||||||
|
async def api_delete_user_api_token(
|
||||||
|
data: DeleteTokenRequest,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
):
|
||||||
|
|
||||||
|
account = await get_account(user.id)
|
||||||
|
if not account or not account.verify_password(data.password):
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||||
|
|
||||||
|
assert account.username, "Username must be configured."
|
||||||
|
|
||||||
|
acls = await get_user_access_control_lists(user.id)
|
||||||
|
acl = acls.get_acl_by_id(data.acl_id)
|
||||||
|
if not acl:
|
||||||
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
|
||||||
|
acl.delete_token_by_id(data.id)
|
||||||
|
await update_user_access_control_list(acls)
|
||||||
|
|
||||||
|
|
||||||
@auth_router.get("/{provider}", description="SSO Provider")
|
@auth_router.get("/{provider}", description="SSO Provider")
|
||||||
async def login_with_sso_provider(
|
async def login_with_sso_provider(
|
||||||
request: Request, provider: str, user_id: Optional[str] = None
|
request: Request, provider: str, user_id: Optional[str] = None
|
||||||
|
|
@ -370,6 +503,17 @@ def _auth_success_response(
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_api_token_response(
|
||||||
|
username: str, api_token_id: str, token_expire_minutes: int
|
||||||
|
):
|
||||||
|
payload = AccessTokenPayload(
|
||||||
|
sub=username, api_token_id=api_token_id, auth_time=int(time())
|
||||||
|
)
|
||||||
|
return create_access_token(
|
||||||
|
data=payload.dict(), token_expire_minutes=token_expire_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||||
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
||||||
access_token = create_access_token(data=payload.dict())
|
access_token = create_access_token(data=payload.dict())
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@ from lnbits.core.models import (
|
||||||
CreateWebPushSubscription,
|
CreateWebPushSubscription,
|
||||||
WebPushSubscription,
|
WebPushSubscription,
|
||||||
)
|
)
|
||||||
|
from lnbits.core.models.users import User
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
check_user_exists,
|
||||||
require_admin_key,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
|
|
@ -33,20 +33,20 @@ webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["Webpush"])
|
||||||
async def api_create_webpush_subscription(
|
async def api_create_webpush_subscription(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: CreateWebPushSubscription,
|
data: CreateWebPushSubscription,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
user: User = Depends(check_user_exists),
|
||||||
) -> WebPushSubscription:
|
) -> WebPushSubscription:
|
||||||
try:
|
try:
|
||||||
subscription = json.loads(data.subscription)
|
subscription = json.loads(data.subscription)
|
||||||
endpoint = subscription["endpoint"]
|
endpoint = subscription["endpoint"]
|
||||||
host = urlparse(str(request.url)).netloc
|
host = urlparse(str(request.url)).netloc
|
||||||
|
|
||||||
subscription = await get_webpush_subscription(endpoint, wallet.wallet.user)
|
subscription = await get_webpush_subscription(endpoint, user.id)
|
||||||
if subscription:
|
if subscription:
|
||||||
return subscription
|
return subscription
|
||||||
else:
|
else:
|
||||||
return await create_webpush_subscription(
|
return await create_webpush_subscription(
|
||||||
endpoint,
|
endpoint,
|
||||||
wallet.wallet.user,
|
user.id,
|
||||||
data.subscription,
|
data.subscription,
|
||||||
host,
|
host,
|
||||||
)
|
)
|
||||||
|
|
@ -61,13 +61,13 @@ async def api_create_webpush_subscription(
|
||||||
@webpush_router.delete("", status_code=HTTPStatus.OK)
|
@webpush_router.delete("", status_code=HTTPStatus.OK)
|
||||||
async def api_delete_webpush_subscription(
|
async def api_delete_webpush_subscription(
|
||||||
request: Request,
|
request: Request,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
user: User = Depends(check_user_exists),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
endpoint = unquote(
|
endpoint = unquote(
|
||||||
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
|
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
|
||||||
)
|
)
|
||||||
count = await delete_webpush_subscription(endpoint, wallet.wallet.user)
|
count = await delete_webpush_subscription(endpoint, user.id)
|
||||||
return {"count": count}
|
return {"count": count}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug(exc)
|
logger.debug(exc)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from lnbits.core.crud import (
|
||||||
get_user_from_account,
|
get_user_from_account,
|
||||||
get_wallet_for_key,
|
get_wallet_for_key,
|
||||||
)
|
)
|
||||||
|
from lnbits.core.crud.users import get_user_access_control_lists
|
||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
AccessTokenPayload,
|
AccessTokenPayload,
|
||||||
Account,
|
Account,
|
||||||
|
|
@ -140,7 +141,7 @@ async def check_user_exists(
|
||||||
usr: Optional[UUID4] = None,
|
usr: Optional[UUID4] = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
if access_token:
|
if access_token:
|
||||||
account = await _get_account_from_token(access_token)
|
account = await _get_account_from_token(access_token, r["path"], r["method"])
|
||||||
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||||
account = await get_account(usr.hex)
|
account = await get_account(usr.hex)
|
||||||
else:
|
else:
|
||||||
|
|
@ -161,13 +162,14 @@ async def check_user_exists(
|
||||||
|
|
||||||
|
|
||||||
async def optional_user_id(
|
async def optional_user_id(
|
||||||
|
r: Request,
|
||||||
access_token: Annotated[Optional[str], Depends(check_access_token)],
|
access_token: Annotated[Optional[str], Depends(check_access_token)],
|
||||||
usr: Optional[UUID4] = None,
|
usr: Optional[UUID4] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
if usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
if usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||||
return usr.hex
|
return usr.hex
|
||||||
if access_token:
|
if access_token:
|
||||||
account = await _get_account_from_token(access_token)
|
account = await _get_account_from_token(access_token, r["path"], r["method"])
|
||||||
return account.id if account else None
|
return account.id if account else None
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
@ -257,9 +259,8 @@ async def check_user_extension_access(
|
||||||
return SimpleStatus(success=True, message="OK")
|
return SimpleStatus(success=True, message="OK")
|
||||||
|
|
||||||
|
|
||||||
async def _check_user_extension_access(user_id: str, current_path: str):
|
async def _check_user_extension_access(user_id: str, path: str):
|
||||||
path = current_path.split("/")
|
ext_id = _path_segments(path)[0]
|
||||||
ext_id = path[3] if path[1] == "upgrades" else path[1]
|
|
||||||
status = await check_user_extension_access(user_id, ext_id)
|
status = await check_user_extension_access(user_id, ext_id)
|
||||||
if not status.success:
|
if not status.success:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -268,16 +269,14 @@ async def _check_user_extension_access(user_id: str, current_path: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_account_from_token(access_token) -> Optional[Account]:
|
async def _get_account_from_token(
|
||||||
|
access_token: str, path: str, method: str
|
||||||
|
) -> Optional[Account]:
|
||||||
try:
|
try:
|
||||||
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||||
user = await _get_user_from_jwt_payload(payload)
|
return await _get_account_from_jwt_payload(
|
||||||
if not user:
|
AccessTokenPayload(**payload), path, method
|
||||||
raise HTTPException(
|
)
|
||||||
HTTPStatus.UNAUTHORIZED, "Data missing for access token."
|
|
||||||
)
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
except jwt.ExpiredSignatureError as exc:
|
except jwt.ExpiredSignatureError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -288,11 +287,48 @@ async def _get_account_from_token(access_token) -> Optional[Account]:
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
|
||||||
|
|
||||||
|
|
||||||
async def _get_user_from_jwt_payload(payload) -> Optional[Account]:
|
async def _get_account_from_jwt_payload(
|
||||||
if "sub" in payload and payload.get("sub"):
|
payload: AccessTokenPayload, path: str, method: str
|
||||||
return await get_account_by_username(str(payload.get("sub")))
|
) -> Optional[Account]:
|
||||||
if "usr" in payload and payload.get("usr"):
|
account = None
|
||||||
return await get_account(str(payload.get("usr")))
|
if payload.sub is not None:
|
||||||
if "email" in payload and payload.get("email"):
|
account = await get_account_by_username(payload.sub)
|
||||||
return await get_account_by_email(str(payload.get("email")))
|
if payload.usr is not None:
|
||||||
return None
|
account = await get_account(payload.usr)
|
||||||
|
if payload.email is not None:
|
||||||
|
account = await get_account_by_email(payload.email)
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if payload.api_token_id:
|
||||||
|
await _check_account_api_access(account.id, payload.api_token_id, path, method)
|
||||||
|
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_account_api_access(
|
||||||
|
user_id: str, token_id: str, path: str, method: str
|
||||||
|
):
|
||||||
|
segments = path.split("/")
|
||||||
|
if len(segments) < 3:
|
||||||
|
raise HTTPException(HTTPStatus.FORBIDDEN, "Not an API endpoint.")
|
||||||
|
|
||||||
|
acls = await get_user_access_control_lists(user_id)
|
||||||
|
acl = acls.get_acl_by_token_id(token_id)
|
||||||
|
if not acl:
|
||||||
|
raise HTTPException(HTTPStatus.FORBIDDEN, "Invalid token id.")
|
||||||
|
|
||||||
|
path = "/" + "/".join(_path_segments(path)[:3])
|
||||||
|
endpoint = acl.get_endpoint(path)
|
||||||
|
if not endpoint:
|
||||||
|
raise HTTPException(HTTPStatus.FORBIDDEN, "Path not allowed.")
|
||||||
|
if not endpoint.supports_method(method):
|
||||||
|
raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.")
|
||||||
|
|
||||||
|
|
||||||
|
def _path_segments(path: str) -> list[str]:
|
||||||
|
segments = path.split("/")
|
||||||
|
if segments[1] == "upgrades":
|
||||||
|
return segments[3:]
|
||||||
|
return segments[1:]
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from urllib import request
|
||||||
import jinja2
|
import jinja2
|
||||||
import jwt
|
import jwt
|
||||||
import shortuuid
|
import shortuuid
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
from packaging import version
|
from packaging import version
|
||||||
from pydantic.schema import field_schema
|
from pydantic.schema import field_schema
|
||||||
|
|
||||||
|
|
@ -198,12 +199,11 @@ def is_valid_pubkey(pubkey: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict):
|
def create_access_token(data: dict, token_expire_minutes: Optional[int] = None) -> str:
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
minutes = token_expire_minutes or settings.auth_token_expire_minutes
|
||||||
minutes=settings.auth_token_expire_minutes
|
expire = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||||
)
|
to_encode = {k: v for k, v in data.items() if v is not None}
|
||||||
to_encode = data.copy()
|
to_encode.update({"exp": expire}) # todo:check expiration
|
||||||
to_encode.update({"exp": expire})
|
|
||||||
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
|
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -270,3 +270,19 @@ def file_hash(filename):
|
||||||
while n := f.readinto(mv):
|
while n := f.readinto(mv):
|
||||||
h.update(mv[:n])
|
h.update(mv[:n])
|
||||||
return h.hexdigest()
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_routes(routes: list) -> dict[str, str]:
|
||||||
|
data = {}
|
||||||
|
for route in routes:
|
||||||
|
if not isinstance(route, APIRoute):
|
||||||
|
continue
|
||||||
|
segments = route.path.split("/")
|
||||||
|
if len(segments) < 3:
|
||||||
|
continue
|
||||||
|
if "/".join(segments[1:3]) == "api/v1":
|
||||||
|
data["/".join(segments[0:4])] = segments[3].capitalize()
|
||||||
|
elif "/".join(segments[2:4]) == "api/v1":
|
||||||
|
data["/".join(segments[0:4])] = segments[1].capitalize()
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
|
||||||
2
lnbits/static/bundle-components.min.js
vendored
2
lnbits/static/bundle-components.min.js
vendored
File diff suppressed because one or more lines are too long
2
lnbits/static/bundle.min.css
vendored
2
lnbits/static/bundle.min.css
vendored
File diff suppressed because one or more lines are too long
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -482,6 +482,12 @@ body.body--dark .q-field--error .q-field__messages {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lnbits__table-bordered td,
|
||||||
|
.lnbits__table-bordered th {
|
||||||
|
border: 1px solid black;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
.q-table--dense th:first-child,
|
.q-table--dense th:first-child,
|
||||||
.q-table--dense td:first-child,
|
.q-table--dense td:first-child,
|
||||||
.q-table--dense .q-table__bottom {
|
.q-table--dense .q-table__bottom {
|
||||||
|
|
|
||||||
|
|
@ -85,9 +85,11 @@ window.localisation.en = {
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
scan: 'Scan',
|
scan: 'Scan',
|
||||||
read: 'Read',
|
read: 'Read',
|
||||||
|
write: 'Write',
|
||||||
pay: 'Pay',
|
pay: 'Pay',
|
||||||
memo: 'Memo',
|
memo: 'Memo',
|
||||||
date: 'Date',
|
date: 'Date',
|
||||||
|
path: 'Path',
|
||||||
processing_payment: 'Processing payment...',
|
processing_payment: 'Processing payment...',
|
||||||
not_enough_funds: 'Not enough funds!',
|
not_enough_funds: 'Not enough funds!',
|
||||||
search_by_tag_memo_amount: 'Search by tag, memo, amount',
|
search_by_tag_memo_amount: 'Search by tag, memo, amount',
|
||||||
|
|
@ -256,6 +258,13 @@ window.localisation.en = {
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
logout: 'Logout',
|
logout: 'Logout',
|
||||||
look_and_feel: 'Look and Feel',
|
look_and_feel: 'Look and Feel',
|
||||||
|
api_token: 'API Token',
|
||||||
|
api_tokens: 'API Tokens',
|
||||||
|
access_control_list: 'Access Control List',
|
||||||
|
access_control_list_admin_warning:
|
||||||
|
'This is an admin account. The generated tokens will have admin privileges.',
|
||||||
|
new_api_acl: 'New Access Control List',
|
||||||
|
api_token_id: 'Token Id',
|
||||||
toggle_gradient: 'Toggle Gradient',
|
toggle_gradient: 'Toggle Gradient',
|
||||||
gradient_background: 'Gradient Background',
|
gradient_background: 'Gradient Background',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,60 @@ window.AccountPageLogic = {
|
||||||
newPasswordRepeat: null,
|
newPasswordRepeat: null,
|
||||||
username: null,
|
username: null,
|
||||||
pubkey: null
|
pubkey: null
|
||||||
|
},
|
||||||
|
apiAcl: {
|
||||||
|
showNewAclDialog: false,
|
||||||
|
showPasswordDialog: false,
|
||||||
|
showNewTokenDialog: false,
|
||||||
|
data: [],
|
||||||
|
passwordGuardedFunction: null,
|
||||||
|
newAclName: '',
|
||||||
|
newTokenName: '',
|
||||||
|
password: '',
|
||||||
|
apiToken: null,
|
||||||
|
selectedTokenId: null,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'Name',
|
||||||
|
align: 'left',
|
||||||
|
label: this.$t('Name'),
|
||||||
|
field: 'Name',
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'path',
|
||||||
|
align: 'left',
|
||||||
|
label: this.$t('path'),
|
||||||
|
field: 'path',
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'read',
|
||||||
|
align: 'left',
|
||||||
|
label: this.$t('read'),
|
||||||
|
field: 'read',
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'write',
|
||||||
|
align: 'left',
|
||||||
|
label: this.$t('write'),
|
||||||
|
field: 'write',
|
||||||
|
sortable: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 100,
|
||||||
|
page: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedApiAcl: {
|
||||||
|
id: null,
|
||||||
|
name: null,
|
||||||
|
endpoints: [],
|
||||||
|
token_id_list: [],
|
||||||
|
allRead: false,
|
||||||
|
allWrite: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -151,6 +205,215 @@ window.AccountPageLogic = {
|
||||||
newPassword: null,
|
newPassword: null,
|
||||||
newPasswordRepeat: null
|
newPasswordRepeat: null
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
newApiAclDialog() {
|
||||||
|
this.apiAcl.newAclName = null
|
||||||
|
this.apiAcl.showNewAclDialog = true
|
||||||
|
},
|
||||||
|
newTokenAclDialog() {
|
||||||
|
this.apiAcl.newTokenName = null
|
||||||
|
this.apiAcl.newTokenExpiry = null
|
||||||
|
this.apiAcl.showNewTokenDialog = true
|
||||||
|
},
|
||||||
|
handleApiACLSelected(aclId) {
|
||||||
|
this.selectedApiAcl = {
|
||||||
|
id: null,
|
||||||
|
name: null,
|
||||||
|
endpoints: [],
|
||||||
|
token_id_list: []
|
||||||
|
}
|
||||||
|
this.apiAcl.selectedTokenId = null
|
||||||
|
if (!aclId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
const selectedApiAcl = this.apiAcl.data.find(t => t.id === aclId)
|
||||||
|
if (!this.selectedApiAcl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.selectedApiAcl = {...selectedApiAcl}
|
||||||
|
this.selectedApiAcl.allRead = this.selectedApiAcl.endpoints.every(
|
||||||
|
e => e.read
|
||||||
|
)
|
||||||
|
this.selectedApiAcl.allWrite = this.selectedApiAcl.endpoints.every(
|
||||||
|
e => e.write
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleAllEndpointsReadAccess() {
|
||||||
|
this.selectedApiAcl.endpoints.forEach(
|
||||||
|
e => (e.read = this.selectedApiAcl.allRead)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
handleAllEndpointsWriteAccess() {
|
||||||
|
this.selectedApiAcl.endpoints.forEach(
|
||||||
|
e => (e.write = this.selectedApiAcl.allWrite)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
async getApiACLs() {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request('GET', '/api/v1/auth/acl', null)
|
||||||
|
this.apiAcl.data = data.access_control_list
|
||||||
|
} catch (e) {
|
||||||
|
LNbits.utils.notifyApiError(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
askPasswordAndRunFunction(func) {
|
||||||
|
this.apiAcl.passwordGuardedFunction = func
|
||||||
|
this.apiAcl.showPasswordDialog = true
|
||||||
|
},
|
||||||
|
runPasswordGuardedFunction() {
|
||||||
|
this.apiAcl.showPasswordDialog = false
|
||||||
|
const func = this.apiAcl.passwordGuardedFunction
|
||||||
|
if (func) {
|
||||||
|
this[func]()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async addApiACL() {
|
||||||
|
if (!this.apiAcl.newAclName) {
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Name is required.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
'/api/v1/auth/acl',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
id: this.apiAcl.newAclName,
|
||||||
|
name: this.apiAcl.newAclName,
|
||||||
|
password: this.apiAcl.password
|
||||||
|
}
|
||||||
|
)
|
||||||
|
this.apiAcl.data = data.access_control_list
|
||||||
|
const acl = this.apiAcl.data.find(
|
||||||
|
t => t.name === this.apiAcl.newAclName
|
||||||
|
)
|
||||||
|
|
||||||
|
this.handleApiACLSelected(acl.id)
|
||||||
|
this.apiAcl.showNewAclDialog = false
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Access Control List created.'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
LNbits.utils.notifyApiError(e)
|
||||||
|
} finally {
|
||||||
|
this.apiAcl.name = ''
|
||||||
|
this.apiAcl.password = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
this.apiAcl.showNewAclDialog = false
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateApiACLs() {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
'/api/v1/auth/acl',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
id: this.user.id,
|
||||||
|
password: this.apiAcl.password,
|
||||||
|
...this.selectedApiAcl
|
||||||
|
}
|
||||||
|
)
|
||||||
|
this.apiAcl.data = data.access_control_list
|
||||||
|
} catch (e) {
|
||||||
|
LNbits.utils.notifyApiError(e)
|
||||||
|
} finally {
|
||||||
|
this.apiAcl.password = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteApiACL() {
|
||||||
|
if (!this.selectedApiAcl.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await LNbits.api.request('DELETE', '/api/v1/auth/acl', null, {
|
||||||
|
id: this.selectedApiAcl.id,
|
||||||
|
password: this.apiAcl.password
|
||||||
|
})
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Access Control List deleted.'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
LNbits.utils.notifyApiError(e)
|
||||||
|
} finally {
|
||||||
|
this.apiAcl.password = ''
|
||||||
|
}
|
||||||
|
this.apiAcl.data = this.apiAcl.data.filter(
|
||||||
|
t => t.id !== this.selectedApiAcl.id
|
||||||
|
)
|
||||||
|
this.handleApiACLSelected(this.apiAcl.data[0]?.id)
|
||||||
|
},
|
||||||
|
async generateApiToken() {
|
||||||
|
if (!this.selectedApiAcl.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const expirationTimeMilliseconds =
|
||||||
|
new Date(this.apiAcl.newTokenExpiry) - new Date()
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/auth/acl/token',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
acl_id: this.selectedApiAcl.id,
|
||||||
|
token_name: this.apiAcl.newTokenName,
|
||||||
|
password: this.apiAcl.password,
|
||||||
|
expiration_time_minutes: Math.trunc(
|
||||||
|
expirationTimeMilliseconds / 60000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.apiAcl.apiToken = data.api_token
|
||||||
|
this.apiAcl.selectedTokenId = data.id
|
||||||
|
Quasar.Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Token Generated.'
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.getApiACLs()
|
||||||
|
this.handleApiACLSelected(this.selectedApiAcl.id)
|
||||||
|
this.apiAcl.showNewTokenDialog = false
|
||||||
|
} catch (e) {
|
||||||
|
LNbits.utils.notifyApiError(e)
|
||||||
|
} finally {
|
||||||
|
this.apiAcl.password = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteToken() {
|
||||||
|
if (!this.apiAcl.selectedTokenId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await LNbits.api.request('DELETE', '/api/v1/auth/acl/token', null, {
|
||||||
|
id: this.apiAcl.selectedTokenId,
|
||||||
|
acl_id: this.selectedApiAcl.id,
|
||||||
|
password: this.apiAcl.password
|
||||||
|
})
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Token deleted.'
|
||||||
|
})
|
||||||
|
|
||||||
|
this.selectedApiAcl.token_id_list =
|
||||||
|
this.selectedApiAcl.token_id_list.filter(
|
||||||
|
t => t.id !== this.apiAcl.selectedTokenId
|
||||||
|
)
|
||||||
|
this.apiAcl.selectedTokenId = null
|
||||||
|
} catch (e) {
|
||||||
|
LNbits.utils.notifyApiError(e)
|
||||||
|
} finally {
|
||||||
|
this.apiAcl.password = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async created() {
|
async created() {
|
||||||
|
|
@ -166,5 +429,6 @@ window.AccountPageLogic = {
|
||||||
if (hash) {
|
if (hash) {
|
||||||
this.tab = hash
|
this.tab = hash
|
||||||
}
|
}
|
||||||
|
await this.getApiACLs()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -308,14 +308,9 @@ window.app.component('lnbits-notifications-btn', {
|
||||||
.subscribe(options)
|
.subscribe(options)
|
||||||
.then(subscription => {
|
.then(subscription => {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request('POST', '/api/v1/webpush', null, {
|
||||||
'POST',
|
subscription: JSON.stringify(subscription)
|
||||||
'/api/v1/webpush',
|
})
|
||||||
this.g.user.wallets[0].adminkey,
|
|
||||||
{
|
|
||||||
subscription: JSON.stringify(subscription)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.saveUserSubscribed(response.data.user)
|
this.saveUserSubscribed(response.data.user)
|
||||||
this.isSubscribed = true
|
this.isSubscribed = true
|
||||||
|
|
@ -337,7 +332,7 @@ window.app.component('lnbits-notifications-btn', {
|
||||||
.request(
|
.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
'/api/v1/webpush?endpoint=' + btoa(subscription.endpoint),
|
'/api/v1/webpush?endpoint=' + btoa(subscription.endpoint),
|
||||||
this.g.user.wallets[0].adminkey
|
null
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.removeUserSubscribed(this.g.user.id)
|
this.removeUserSubscribed(this.g.user.id)
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,12 @@ body.body--dark .q-field--error {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lnbits__table-bordered td,
|
||||||
|
.lnbits__table-bordered th {
|
||||||
|
border: 1px solid black;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
.q-table--dense {
|
.q-table--dense {
|
||||||
th:first-child,
|
th:first-child,
|
||||||
td:first-child,
|
td:first-child,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -9,8 +10,22 @@ import secp256k1
|
||||||
import shortuuid
|
import shortuuid
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from lnbits.core.crud.users import (
|
||||||
|
get_user_access_control_lists,
|
||||||
|
update_user_access_control_list,
|
||||||
|
)
|
||||||
from lnbits.core.models import AccessTokenPayload, User
|
from lnbits.core.models import AccessTokenPayload, User
|
||||||
|
from lnbits.core.models.misc import SimpleItem
|
||||||
|
from lnbits.core.models.users import (
|
||||||
|
AccessControlList,
|
||||||
|
ApiTokenRequest,
|
||||||
|
DeleteTokenRequest,
|
||||||
|
EndpointAccess,
|
||||||
|
UpdateAccessControlList,
|
||||||
|
UserAcls,
|
||||||
|
)
|
||||||
from lnbits.core.views.user_api import api_users_reset_password
|
from lnbits.core.views.user_api import api_users_reset_password
|
||||||
|
from lnbits.helpers import create_access_token
|
||||||
from lnbits.settings import AuthMethods, Settings
|
from lnbits.settings import AuthMethods, Settings
|
||||||
from lnbits.utils.nostr import hex_to_npub, sign_event
|
from lnbits.utils.nostr import hex_to_npub, sign_event
|
||||||
|
|
||||||
|
|
@ -1030,3 +1045,907 @@ async def test_reset_password_auth_threshold_expired(
|
||||||
" in the first 1 seconds."
|
" in the first 1 seconds."
|
||||||
" Please login again or ask a new reset key!"
|
" Please login again or ask a new reset key!"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
################################ ACL ################################
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_update_user_acl_success(http_client: AsyncClient, user_alan: User):
|
||||||
|
# Login to get access token
|
||||||
|
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
|
||||||
|
|
||||||
|
# Create a new ACL
|
||||||
|
data = UpdateAccessControlList(
|
||||||
|
id="", name="New ACL", password="secret1234", endpoints=[]
|
||||||
|
)
|
||||||
|
response = await http_client.put(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
json=data.dict(),
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACL should be created successfully."
|
||||||
|
user_acls = UserAcls(**response.json())
|
||||||
|
assert any(
|
||||||
|
acl.name == "New ACL" for acl in user_acls.access_control_list
|
||||||
|
), "ACL should be in the list."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_update_user_acl_invalid_password(
|
||||||
|
http_client: AsyncClient, user_alan: User
|
||||||
|
):
|
||||||
|
# Login to get access token
|
||||||
|
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
|
||||||
|
|
||||||
|
# Attempt to create a new ACL with an invalid password
|
||||||
|
data = UpdateAccessControlList(
|
||||||
|
id="", name="New ACL", password="wrong_password", endpoints=[]
|
||||||
|
)
|
||||||
|
response = await http_client.put(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
json=data.dict(),
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
response.status_code == 401
|
||||||
|
), "Invalid password should result in unauthorized error."
|
||||||
|
assert response.json().get("detail") == "Invalid credentials."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_update_user_acl_update_existing(
|
||||||
|
http_client: AsyncClient, user_alan: User
|
||||||
|
):
|
||||||
|
# Login to get access token
|
||||||
|
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
|
||||||
|
|
||||||
|
# Create a new ACL
|
||||||
|
data = UpdateAccessControlList(
|
||||||
|
id="", name="New ACL", password="secret1234", endpoints=[]
|
||||||
|
)
|
||||||
|
response = await http_client.put(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
json=data.dict(),
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACL should be created successfully."
|
||||||
|
user_acls = UserAcls(**response.json())
|
||||||
|
acl = next(acl for acl in user_acls.access_control_list if acl.name == "New ACL")
|
||||||
|
|
||||||
|
# Update the existing ACL
|
||||||
|
data = UpdateAccessControlList(
|
||||||
|
id=acl.id, name="Updated ACL", password="secret1234", endpoints=[]
|
||||||
|
)
|
||||||
|
response = await http_client.put(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
json=data.dict(),
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACL should be updated successfully."
|
||||||
|
user_acls = UserAcls(**response.json())
|
||||||
|
assert any(
|
||||||
|
acl.name == "Updated ACL" for acl in user_acls.access_control_list
|
||||||
|
), "ACL should be updated in the list."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_update_user_acl_missing_password(
|
||||||
|
http_client: AsyncClient, user_alan: User
|
||||||
|
):
|
||||||
|
# Login to get access token
|
||||||
|
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
|
||||||
|
|
||||||
|
# Attempt to create a new ACL with a missing password
|
||||||
|
data = UpdateAccessControlList(id="", name="New ACL", password="", endpoints=[])
|
||||||
|
response = await http_client.put(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
json=data.dict(),
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
response.status_code == 401
|
||||||
|
), "Missing password should result in unauthorized error."
|
||||||
|
assert response.json().get("detail") == "Invalid credentials."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_get_user_acls_success(http_client: AsyncClient):
|
||||||
|
# Register a new user to obtain the access token
|
||||||
|
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
|
||||||
|
|
||||||
|
# Get user ACLs
|
||||||
|
response = await http_client.get(
|
||||||
|
"/api/v1/auth/acl", headers={"Authorization": f"Bearer {access_token}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACLs fetched successfully."
|
||||||
|
user_acls = UserAcls(**response.json())
|
||||||
|
assert user_acls.id is not None, "User ID should be set."
|
||||||
|
assert isinstance(user_acls.access_control_list, list), "ACL should be a list."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_get_user_acls_no_auth(http_client: AsyncClient):
|
||||||
|
# Attempt to get user ACLs without authentication
|
||||||
|
response = await http_client.get("/api/v1/auth/acl")
|
||||||
|
assert response.status_code == 401, "Unauthorized access."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_get_user_acls_invalid_token(http_client: AsyncClient):
|
||||||
|
# Attempt to get user ACLs with an invalid token
|
||||||
|
response = await http_client.get(
|
||||||
|
"/api/v1/auth/acl", headers={"Authorization": "Bearer invalid_token"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401, "Unauthorized access."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_get_user_acls_empty_acl(http_client: AsyncClient):
|
||||||
|
# Register a new user to obtain the access token
|
||||||
|
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
|
||||||
|
|
||||||
|
# Get user ACLs
|
||||||
|
response = await http_client.get(
|
||||||
|
"/api/v1/auth/acl", headers={"Authorization": f"Bearer {access_token}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACLs fetched successfully."
|
||||||
|
user_acls = UserAcls(**response.json())
|
||||||
|
assert user_acls.id is not None, "User ID should be set."
|
||||||
|
assert len(user_acls.access_control_list) == 0, "ACL should be empty."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_get_user_acls_with_acl(http_client: AsyncClient):
|
||||||
|
# Register a new user to obtain the access token
|
||||||
|
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
|
||||||
|
|
||||||
|
# Create a new ACL for the user
|
||||||
|
acl_data = UpdateAccessControlList(
|
||||||
|
id="",
|
||||||
|
name="Test ACL",
|
||||||
|
endpoints=[],
|
||||||
|
password="secret1234",
|
||||||
|
)
|
||||||
|
response = await http_client.put(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=acl_data.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACL created successfully."
|
||||||
|
|
||||||
|
# Get user ACLs
|
||||||
|
response = await http_client.get(
|
||||||
|
"/api/v1/auth/acl", headers={"Authorization": f"Bearer {access_token}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACLs fetched successfully."
|
||||||
|
user_acls = UserAcls(**response.json())
|
||||||
|
assert user_acls.id is not None, "User ID should be set."
|
||||||
|
assert len(user_acls.access_control_list) == 1, "ACL should contain one item."
|
||||||
|
assert user_acls.access_control_list[0].name == "Test ACL", "ACL name should match."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_get_user_acls_sorted(http_client: AsyncClient):
|
||||||
|
# Register a new user to obtain the access token
|
||||||
|
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
|
||||||
|
|
||||||
|
# Create some ACLs for the user
|
||||||
|
acl_names = ["zeta", "alpha", "gamma"]
|
||||||
|
for name in acl_names:
|
||||||
|
response = await http_client.put(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json={"id": name, "name": name, "password": "secret1234"},
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
response.status_code == 200
|
||||||
|
), f"ACL '{name}' should be created successfully."
|
||||||
|
|
||||||
|
# Get the user's ACLs
|
||||||
|
response = await http_client.get(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, "ACLs retrieved."
|
||||||
|
user_acls = UserAcls(**response.json())
|
||||||
|
|
||||||
|
# Check that the ACLs are sorted alphabetically by name
|
||||||
|
acl_names_sorted = sorted(acl_names)
|
||||||
|
retrieved_acl_names = [acl.name for acl in user_acls.access_control_list]
|
||||||
|
assert (
|
||||||
|
retrieved_acl_names == acl_names_sorted
|
||||||
|
), "ACLs are not sorted alphabetically by name."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_delete_user_acl_success(http_client: AsyncClient):
|
||||||
|
# Register a new user to obtain the access token
|
||||||
|
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
|
||||||
|
|
||||||
|
# Create an ACL for the user
|
||||||
|
response = await http_client.put(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json={
|
||||||
|
"id": "Test ACL",
|
||||||
|
"name": "Test ACL",
|
||||||
|
"password": "secret1234",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, "ACL created."
|
||||||
|
acl_id = response.json()["access_control_list"][0]["id"]
|
||||||
|
|
||||||
|
# Delete the ACL
|
||||||
|
response = await http_client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json={
|
||||||
|
"id": acl_id,
|
||||||
|
"password": "secret1234",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACL deleted."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_delete_user_acl_invalid_password(http_client: AsyncClient):
|
||||||
|
# Register a new user to obtain the access token
|
||||||
|
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
|
||||||
|
|
||||||
|
# Create an ACL for the user
|
||||||
|
response = await http_client.put(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json={
|
||||||
|
"id": "Test ACL",
|
||||||
|
"name": "Test ACL",
|
||||||
|
"password": "secret1234",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACL created."
|
||||||
|
acl_id = response.json()["access_control_list"][0]["id"]
|
||||||
|
|
||||||
|
# Attempt to delete the ACL with an invalid password
|
||||||
|
response = await http_client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json={
|
||||||
|
"id": acl_id,
|
||||||
|
"password": "wrongpassword",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401, "Invalid credentials."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_delete_user_acl_nonexistent_acl(http_client: AsyncClient):
|
||||||
|
# Register a new user to obtain the access token
|
||||||
|
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
|
||||||
|
|
||||||
|
# Attempt to delete a nonexistent ACL
|
||||||
|
response = await http_client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json={
|
||||||
|
"id": "nonexistent_acl_id",
|
||||||
|
"password": "secret1234",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACL deleted."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_delete_user_acl_missing_password(http_client: AsyncClient):
|
||||||
|
# Register a new user to obtain the access token
|
||||||
|
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
|
||||||
|
|
||||||
|
# Create an ACL for the user
|
||||||
|
response = await http_client.put(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json={
|
||||||
|
"id": "Test ACL",
|
||||||
|
"name": "Test ACL",
|
||||||
|
"password": "secret1234",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACL created."
|
||||||
|
acl_id = response.json()["access_control_list"][0]["id"]
|
||||||
|
|
||||||
|
# Attempt to delete the ACL without providing a password
|
||||||
|
response = await http_client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json={
|
||||||
|
"id": acl_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400, "Missing password."
|
||||||
|
|
||||||
|
|
||||||
|
################################ TOKEN ################################
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_create_user_api_token_success(
|
||||||
|
http_client: AsyncClient, settings: Settings
|
||||||
|
):
|
||||||
|
# Register a new 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
|
||||||
|
|
||||||
|
# Create a new ACL
|
||||||
|
acl_data = UpdateAccessControlList(
|
||||||
|
id="", password="secret1234", name="Test ACL", endpoints=[]
|
||||||
|
)
|
||||||
|
response = await http_client.put(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=acl_data.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACL created."
|
||||||
|
acl_id = response.json()["access_control_list"][0]["id"]
|
||||||
|
|
||||||
|
# Create API token
|
||||||
|
token_request = ApiTokenRequest(
|
||||||
|
acl_id=acl_id,
|
||||||
|
token_name="Test Token",
|
||||||
|
expiration_time_minutes=60,
|
||||||
|
password="secret1234",
|
||||||
|
)
|
||||||
|
response = await http_client.post(
|
||||||
|
"/api/v1/auth/acl/token",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=token_request.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "API token created."
|
||||||
|
api_token = response.json().get("api_token")
|
||||||
|
assert api_token is not None
|
||||||
|
|
||||||
|
# Verify the token exists
|
||||||
|
response = await http_client.get(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACLs fetched successfully."
|
||||||
|
acls = UserAcls(**response.json())
|
||||||
|
# Decode the access token to get the user ID
|
||||||
|
payload: dict = jwt.decode(api_token, settings.auth_secret_key, ["HS256"])
|
||||||
|
|
||||||
|
# Check the expiration time
|
||||||
|
expiration_time = payload.get("exp")
|
||||||
|
assert expiration_time is not None, "Expiration time should be set."
|
||||||
|
assert (
|
||||||
|
0 <= 3600 - (expiration_time - time.time()) <= 5
|
||||||
|
), "Expiration time should be 60 minutes from now."
|
||||||
|
|
||||||
|
token_id = payload["api_token_id"]
|
||||||
|
assert any(
|
||||||
|
token_id in [token.id for token in acl.token_id_list]
|
||||||
|
for acl in acls.access_control_list
|
||||||
|
), "API token should be part of at least one ACL."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_acl_api_token_access(user_alan: User, http_client: AsyncClient):
|
||||||
|
user_acls = await get_user_access_control_lists(user_alan.id)
|
||||||
|
acl = AccessControlList(id=uuid4().hex, name="Test ACL", endpoints=[])
|
||||||
|
user_acls.access_control_list = [acl]
|
||||||
|
|
||||||
|
api_token_id = uuid4().hex
|
||||||
|
payload = AccessTokenPayload(
|
||||||
|
sub=user_alan.username or user_alan.id,
|
||||||
|
api_token_id=api_token_id,
|
||||||
|
auth_time=int(time.time()),
|
||||||
|
)
|
||||||
|
|
||||||
|
api_token = create_access_token(data=payload.dict(), token_expire_minutes=10)
|
||||||
|
acl.token_id_list.append(SimpleItem(id=api_token_id, name="Test Token"))
|
||||||
|
await update_user_access_control_list(user_acls)
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {api_token}"}
|
||||||
|
response = await http_client.get("/api/v1/auth/acl", headers=headers)
|
||||||
|
assert response.status_code == 403, "Path not allowed."
|
||||||
|
assert response.json()["detail"] == "Path not allowed."
|
||||||
|
|
||||||
|
# Grant read access
|
||||||
|
endpoint = EndpointAccess(path="/api/v1/auth", name="Get User ACLs", read=True)
|
||||||
|
acl.endpoints.append(endpoint)
|
||||||
|
await update_user_access_control_list(user_acls)
|
||||||
|
|
||||||
|
response = await http_client.get("/api/v1/auth/acl", headers=headers)
|
||||||
|
assert response.status_code == 200, "Access granted."
|
||||||
|
|
||||||
|
response = await http_client.put("/api/v1/auth/acl", headers=headers)
|
||||||
|
assert response.status_code == 403, "Method not allowed."
|
||||||
|
|
||||||
|
response = await http_client.post(
|
||||||
|
"/api/v1/auth/acl/token", headers=headers, json={}
|
||||||
|
)
|
||||||
|
assert response.status_code == 403, "Method not allowed."
|
||||||
|
|
||||||
|
response = await http_client.patch("/api/v1/auth/acl", headers=headers)
|
||||||
|
assert response.status_code == 403, "Method not allowed."
|
||||||
|
|
||||||
|
response = await http_client.delete("/api/v1/auth/acl", headers=headers)
|
||||||
|
assert response.status_code == 403, "Method not allowed."
|
||||||
|
|
||||||
|
# Grant write access
|
||||||
|
endpoint.write = True
|
||||||
|
await update_user_access_control_list(user_acls)
|
||||||
|
response = await http_client.get("/api/v1/auth/acl", headers=headers)
|
||||||
|
assert response.status_code == 200, "Access granted."
|
||||||
|
|
||||||
|
response = await http_client.put("/api/v1/auth/acl", headers=headers)
|
||||||
|
assert response.status_code == 400, "Access granted, validation error expected."
|
||||||
|
|
||||||
|
response = await http_client.post(
|
||||||
|
"/api/v1/auth/acl/token", headers=headers, json={}
|
||||||
|
)
|
||||||
|
assert response.status_code == 400, "Access granted, validation error expected."
|
||||||
|
|
||||||
|
response = await http_client.patch("/api/v1/auth/acl", headers=headers)
|
||||||
|
assert response.status_code == 400, "Access granted, validation error expected."
|
||||||
|
|
||||||
|
response = await http_client.delete("/api/v1/auth/acl", headers=headers)
|
||||||
|
assert response.status_code == 400, "Access granted, validation error expected."
|
||||||
|
|
||||||
|
# Revoke read access
|
||||||
|
endpoint.read = False
|
||||||
|
await update_user_access_control_list(user_acls)
|
||||||
|
response = await http_client.get("/api/v1/auth/acl", headers=headers)
|
||||||
|
assert response.status_code == 403, "Method not allowed."
|
||||||
|
|
||||||
|
response = await http_client.put("/api/v1/auth/acl", headers=headers)
|
||||||
|
assert (
|
||||||
|
response.status_code == 400
|
||||||
|
), "Access still granted, validation error expected."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_create_user_api_token_invalid_password(http_client: AsyncClient):
|
||||||
|
# Register a new 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
|
||||||
|
|
||||||
|
# Create a new ACL
|
||||||
|
acl_data = UpdateAccessControlList(
|
||||||
|
password="secret1234", id="", name="Test ACL", endpoints=[]
|
||||||
|
)
|
||||||
|
response = await http_client.put(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=acl_data.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACL created."
|
||||||
|
acl_id = response.json()["access_control_list"][0]["id"]
|
||||||
|
|
||||||
|
# Create API token with invalid password
|
||||||
|
token_request = ApiTokenRequest(
|
||||||
|
acl_id=acl_id,
|
||||||
|
token_name="Test Token",
|
||||||
|
expiration_time_minutes=60,
|
||||||
|
password="wrongpassword",
|
||||||
|
)
|
||||||
|
response = await http_client.post(
|
||||||
|
"/api/v1/auth/acl/token",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=token_request.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 401, "Invalid credentials."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_create_user_api_token_invalid_acl_id(http_client: AsyncClient):
|
||||||
|
# Register a new 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
|
||||||
|
|
||||||
|
# Create API token with invalid ACL ID
|
||||||
|
token_request = ApiTokenRequest(
|
||||||
|
acl_id="invalid_acl_id",
|
||||||
|
token_name="Test Token",
|
||||||
|
expiration_time_minutes=60,
|
||||||
|
password="secret1234",
|
||||||
|
)
|
||||||
|
response = await http_client.post(
|
||||||
|
"/api/v1/auth/acl/token",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=token_request.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 401, "Invalid ACL id."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_create_user_api_token_expiration_time_invalid(
|
||||||
|
http_client: AsyncClient,
|
||||||
|
):
|
||||||
|
# Register a new 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
|
||||||
|
|
||||||
|
# Create a new ACL
|
||||||
|
acl_data = UpdateAccessControlList(
|
||||||
|
id="", password="secret1234", name="Test ACL", endpoints=[]
|
||||||
|
)
|
||||||
|
response = await http_client.put(
|
||||||
|
"/api/v1/auth/acl",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=acl_data.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "ACL created."
|
||||||
|
acl_id = response.json()["access_control_list"][0]["id"]
|
||||||
|
|
||||||
|
# Create API token with invalid expiration time
|
||||||
|
token_request = ApiTokenRequest(
|
||||||
|
acl_id=acl_id,
|
||||||
|
token_name="Test Token",
|
||||||
|
expiration_time_minutes=-1,
|
||||||
|
password="secret1234",
|
||||||
|
)
|
||||||
|
response = await http_client.post(
|
||||||
|
"/api/v1/auth/acl/token",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=token_request.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400, "Expiration time must be in the future."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_delete_user_api_token_success(
|
||||||
|
http_client: AsyncClient, settings: Settings
|
||||||
|
):
|
||||||
|
# Register a new 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
|
||||||
|
|
||||||
|
# Decode the access token to get the user ID
|
||||||
|
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||||
|
user_id = payload["usr"]
|
||||||
|
|
||||||
|
# Create a new ACL
|
||||||
|
acl_data = UpdateAccessControlList(
|
||||||
|
id="", name="Test ACL", endpoints=[], password="secret1234"
|
||||||
|
)
|
||||||
|
user_acls = await get_user_access_control_lists(user_id)
|
||||||
|
user_acls.access_control_list.append(acl_data)
|
||||||
|
await update_user_access_control_list(user_acls)
|
||||||
|
|
||||||
|
# Create a new API token
|
||||||
|
api_token_request = ApiTokenRequest(
|
||||||
|
acl_id=acl_data.id,
|
||||||
|
token_name="Test Token",
|
||||||
|
expiration_time_minutes=60,
|
||||||
|
password="secret1234",
|
||||||
|
)
|
||||||
|
response = await http_client.post(
|
||||||
|
"/api/v1/auth/acl/token",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=api_token_request.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "API token created."
|
||||||
|
api_token_id = response.json().get("id")
|
||||||
|
assert api_token_id is not None
|
||||||
|
|
||||||
|
# Delete the API token
|
||||||
|
delete_token_request = DeleteTokenRequest(
|
||||||
|
acl_id=acl_data.id, id=api_token_id, password="secret1234"
|
||||||
|
)
|
||||||
|
response = await http_client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/auth/acl/token",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=delete_token_request.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "API token deleted."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_delete_user_api_token_invalid_password(
|
||||||
|
http_client: AsyncClient, settings: Settings
|
||||||
|
):
|
||||||
|
# Register a new 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
|
||||||
|
|
||||||
|
# Decode the access token to get the user ID
|
||||||
|
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||||
|
user_id = payload["usr"]
|
||||||
|
|
||||||
|
# Create a new ACL
|
||||||
|
acl_data = UpdateAccessControlList(
|
||||||
|
id="", name="Test ACL", endpoints=[], password="secret1234"
|
||||||
|
)
|
||||||
|
user_acls = await get_user_access_control_lists(user_id)
|
||||||
|
user_acls.access_control_list.append(acl_data)
|
||||||
|
await update_user_access_control_list(user_acls)
|
||||||
|
|
||||||
|
# Create a new API token
|
||||||
|
api_token_request = ApiTokenRequest(
|
||||||
|
acl_id=acl_data.id,
|
||||||
|
token_name="Test Token",
|
||||||
|
expiration_time_minutes=60,
|
||||||
|
password="secret1234",
|
||||||
|
)
|
||||||
|
response = await http_client.post(
|
||||||
|
"/api/v1/auth/acl/token",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=api_token_request.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "API token created."
|
||||||
|
api_token_id = response.json().get("id")
|
||||||
|
assert api_token_id is not None
|
||||||
|
|
||||||
|
# Attempt to delete the API token with an invalid password
|
||||||
|
delete_token_request = DeleteTokenRequest(
|
||||||
|
acl_id=acl_data.id, id=api_token_id, password="wrong_password"
|
||||||
|
)
|
||||||
|
response = await http_client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/auth/acl/token",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=delete_token_request.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 401, "Invalid credentials."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_delete_user_api_token_invalid_acl_id(
|
||||||
|
http_client: AsyncClient,
|
||||||
|
):
|
||||||
|
# Register a new 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
|
||||||
|
|
||||||
|
# Attempt to delete an API token with an invalid ACL ID
|
||||||
|
delete_token_request = DeleteTokenRequest(
|
||||||
|
acl_id="invalid_acl_id", id="invalid_token_id", password="secret1234"
|
||||||
|
)
|
||||||
|
response = await http_client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/auth/acl/token",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=delete_token_request.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 401, "Invalid ACL id."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_api_delete_user_api_token_missing_token_id(
|
||||||
|
http_client: AsyncClient, settings: Settings
|
||||||
|
):
|
||||||
|
# Register a new 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
|
||||||
|
|
||||||
|
# Decode the access token to get the user ID
|
||||||
|
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||||
|
user_id = payload["usr"]
|
||||||
|
|
||||||
|
# Create a new ACL
|
||||||
|
acl_data = UpdateAccessControlList(
|
||||||
|
id="", name="Test ACL", endpoints=[], password="secret1234"
|
||||||
|
)
|
||||||
|
user_acls = await get_user_access_control_lists(user_id)
|
||||||
|
user_acls.access_control_list.append(acl_data)
|
||||||
|
await update_user_access_control_list(user_acls)
|
||||||
|
|
||||||
|
# Attempt to delete an API token with a missing token ID
|
||||||
|
delete_token_request = DeleteTokenRequest(
|
||||||
|
acl_id=acl_data.id, id="", password="secret1234"
|
||||||
|
)
|
||||||
|
response = await http_client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/auth/acl/token",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
json=delete_token_request.dict(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, "Does noting if token not found."
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,20 @@ import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_create___bad_body(client, adminkey_headers_from):
|
async def test_create___bad_body(client, user_headers_from):
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/webpush",
|
"/api/v1/webpush",
|
||||||
headers=adminkey_headers_from,
|
headers=user_headers_from,
|
||||||
json={"subscription": "bad_json"},
|
json={"subscription": "bad_json"},
|
||||||
)
|
)
|
||||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_create___missing_fields(client, adminkey_headers_from):
|
async def test_create___missing_fields(client, user_headers_from):
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/webpush",
|
"/api/v1/webpush",
|
||||||
headers=adminkey_headers_from,
|
headers=user_headers_from,
|
||||||
json={"subscription": """{"a": "x"}"""},
|
json={"subscription": """{"a": "x"}"""},
|
||||||
)
|
)
|
||||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
|
@ -34,30 +34,30 @@ async def test_create___bad_access_key(client, inkey_headers_from):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_delete__bad_endpoint_format(client, adminkey_headers_from):
|
async def test_delete__bad_endpoint_format(client, user_headers_from):
|
||||||
response = await client.delete(
|
response = await client.delete(
|
||||||
"/api/v1/webpush",
|
"/api/v1/webpush",
|
||||||
params={"endpoint": "https://this.should.be.base64.com"},
|
params={"endpoint": "https://this.should.be.base64.com"},
|
||||||
headers=adminkey_headers_from,
|
headers=user_headers_from,
|
||||||
)
|
)
|
||||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_delete__no_endpoint_param(client, adminkey_headers_from):
|
async def test_delete__no_endpoint_param(client, user_headers_from):
|
||||||
response = await client.delete(
|
response = await client.delete(
|
||||||
"/api/v1/webpush",
|
"/api/v1/webpush",
|
||||||
headers=adminkey_headers_from,
|
headers=user_headers_from,
|
||||||
)
|
)
|
||||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_delete__no_endpoint_found(client, adminkey_headers_from):
|
async def test_delete__no_endpoint_found(client, user_headers_from):
|
||||||
response = await client.delete(
|
response = await client.delete(
|
||||||
"/api/v1/webpush",
|
"/api/v1/webpush",
|
||||||
params={"endpoint": "aHR0cHM6Ly9kZW1vLmxuYml0cy5jb20="},
|
params={"endpoint": "aHR0cHM6Ly9kZW1vLmxuYml0cy5jb20="},
|
||||||
headers=adminkey_headers_from,
|
headers=user_headers_from,
|
||||||
)
|
)
|
||||||
assert response.status_code == HTTPStatus.OK
|
assert response.status_code == HTTPStatus.OK
|
||||||
assert response.json()["count"] == 0
|
assert response.json()["count"] == 0
|
||||||
|
|
@ -73,17 +73,17 @@ async def test_delete__bad_access_key(client, inkey_headers_from):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_create_and_delete(client, adminkey_headers_from):
|
async def test_create_and_delete(client, user_headers_from):
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/webpush",
|
"/api/v1/webpush",
|
||||||
headers=adminkey_headers_from,
|
headers=user_headers_from,
|
||||||
json={"subscription": """{"endpoint": "https://demo.lnbits.com"}"""},
|
json={"subscription": """{"endpoint": "https://demo.lnbits.com"}"""},
|
||||||
)
|
)
|
||||||
assert response.status_code == HTTPStatus.CREATED
|
assert response.status_code == HTTPStatus.CREATED
|
||||||
response = await client.delete(
|
response = await client.delete(
|
||||||
"/api/v1/webpush",
|
"/api/v1/webpush",
|
||||||
params={"endpoint": "aHR0cHM6Ly9kZW1vLmxuYml0cy5jb20="},
|
params={"endpoint": "aHR0cHM6Ly9kZW1vLmxuYml0cy5jb20="},
|
||||||
headers=adminkey_headers_from,
|
headers=user_headers_from,
|
||||||
)
|
)
|
||||||
assert response.status_code == HTTPStatus.OK
|
assert response.status_code == HTTPStatus.OK
|
||||||
assert response.json()["count"] == 1
|
assert response.json()["count"] == 1
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,18 @@ async def adminkey_headers_from(from_wallet):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def user_headers_from(client: AsyncClient, from_user: User):
|
||||||
|
response = await client.post("/api/v1/auth/usr", json={"usr": from_user.id})
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
access_token = response.json().get("access_token")
|
||||||
|
yield {
|
||||||
|
"Authorization": "Bearer " + access_token,
|
||||||
|
"Content-type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def inkey_headers_to(to_wallet):
|
async def inkey_headers_to(to_wallet):
|
||||||
wallet = to_wallet
|
wallet = to_wallet
|
||||||
|
|
|
||||||
48
tests/copilot_prompt.md
Normal file
48
tests/copilot_prompt.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# GitHub Copilot Prompts
|
||||||
|
|
||||||
|
Make sure to:
|
||||||
|
|
||||||
|
- select the code that you want to test. The prompt specifies the name of the file and the function to be tested (this redundancy is needed)
|
||||||
|
- open tabs with relevant files for the tests, for example: `conftest.py`, `test_auth.py`. This helps Copilot with context.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Create Comprehensive suite of unit tests
|
||||||
|
|
||||||
|
_Sample 1_
|
||||||
|
@workspace /tests Develop a comprehensive suite of unit tests for the selected code (only the function (only the function api_create_user_api_token in auth_api.py file) in auth_api.py file).
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- use register endpoint to obtain the access token (see example in test_register_ok)
|
||||||
|
- write multiple test functions that cover a wide range of scenarios, including the succes flow, edge cases, exception handling, and data validation
|
||||||
|
- for the success case create a new ACL before creating the token
|
||||||
|
|
||||||
|
_Sample 2_
|
||||||
|
@workspace /tests Develop a comprehensive suite of unit tests for the selected code (only the function check_user_exists in decorators.py file) .
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- write multiple test functions that cover a wide range of scenarios, including the succes flow, edge cases, security vulnerabilities, exception handling, and data validation
|
||||||
|
- use the login endpoint to obtain a valid access token. Use the `user_alan: User` fixture for the login params. Check the `test_login_alan_username_password_ok` function in the `test_auth.py` file as an example for login.
|
||||||
|
- do not use mocks. For the request parameter initialize the fastapi.Request class.
|
||||||
|
- make sure to cover all if-then-else branches
|
||||||
|
|
||||||
|
### Create tests for a particular usecase
|
||||||
|
|
||||||
|
_Sample 1_
|
||||||
|
@workspace /tests Develop a test for the selected code (only the function api_get_user_acls in auth_api.py file).
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- use register endpoint to obtain the access token (see example in test_register_ok)
|
||||||
|
- the test should only check that the ACLs are sorted alphabeticaly by name
|
||||||
|
|
||||||
|
_Sample 1_
|
||||||
|
@workspace /tests Develop a test for the selected code (only the function check_user_exists in decorators.py file).
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- use register endpoint to obtain the access token (see example in the file test_auth.py the function test_register_ok())
|
||||||
|
- the test should register a new user, obtain the access token then delete the user. Then check that check_user_exists() fails as expected
|
||||||
|
|
||||||
|
@workspace /tests Develop a test for the selected code (only the function check_user_exists in decorators.py file).
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- check only the branch where user_id_only login is allowed
|
||||||
138
tests/unit/test_decorators.py
Normal file
138
tests/unit/test_decorators.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
import shortuuid
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.exceptions import HTTPException
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from pydantic.types import UUID4
|
||||||
|
|
||||||
|
from lnbits.core.crud.users import delete_account
|
||||||
|
from lnbits.core.models import User
|
||||||
|
from lnbits.core.models.users import AccessTokenPayload
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
from lnbits.settings import AuthMethods, Settings, settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_check_user_exists_with_valid_access_token(
|
||||||
|
http_client: AsyncClient, user_alan: User
|
||||||
|
):
|
||||||
|
# Login to get a valid access token
|
||||||
|
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()["access_token"]
|
||||||
|
assert access_token is not None
|
||||||
|
|
||||||
|
request = Request({"type": "http", "path": "/some/path", "method": "GET"})
|
||||||
|
user = await check_user_exists(request, access_token=access_token)
|
||||||
|
|
||||||
|
assert user.id == user_alan.id
|
||||||
|
assert request.scope["user_id"] == user.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_check_user_exists_with_invalid_access_token():
|
||||||
|
request = Request({"type": "http", "path": "/some/path", "method": "GET"})
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await check_user_exists(request, access_token="invalid_token")
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
assert exc_info.value.detail == "Invalid access token."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_check_user_exists_with_missing_access_token():
|
||||||
|
request = Request({"type": "http", "path": "/some/path", "method": "GET"})
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await check_user_exists(request, access_token=None)
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
assert exc_info.value.detail == "Missing user ID or access token."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_check_user_exists_with_valid_user_id(user_alan: User):
|
||||||
|
request = Request({"type": "http", "path": "/some/path", "method": "GET"})
|
||||||
|
user = await check_user_exists(request, access_token=None, usr=UUID4(user_alan.id))
|
||||||
|
|
||||||
|
assert user.id == user_alan.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_check_user_exists_with_invalid_user_id():
|
||||||
|
request = Request({"type": "http", "path": "/some/path", "method": "GET"})
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await check_user_exists(request, access_token=None, usr=uuid4())
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
assert exc_info.value.detail == "User not found."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_check_user_exists_with_user_not_allowed(user_alan: User):
|
||||||
|
settings.lnbits_admin_users = []
|
||||||
|
request = Request({"type": "http", "path": "/some/path", "method": "GET"})
|
||||||
|
settings.lnbits_allowed_users = ["only_this_user_id"]
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await check_user_exists(request, access_token=None, usr=UUID4(user_alan.id))
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
assert exc_info.value.detail == "User not allowed."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_check_user_exists_after_user_deletion(http_client: AsyncClient):
|
||||||
|
# Register a new user
|
||||||
|
tiny_id = shortuuid.uuid()[:8]
|
||||||
|
register_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 register_response.status_code == 200, "User registers OK"
|
||||||
|
access_token = register_response.json()["access_token"]
|
||||||
|
assert access_token is not None
|
||||||
|
|
||||||
|
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||||
|
access_token_payload = AccessTokenPayload(**payload)
|
||||||
|
|
||||||
|
# Get the user ID
|
||||||
|
user_id = access_token_payload.usr
|
||||||
|
assert user_id, "User ID is not None"
|
||||||
|
|
||||||
|
# Delete the user
|
||||||
|
await delete_account(user_id)
|
||||||
|
|
||||||
|
# Attempt to check user existence with the deleted user's access token
|
||||||
|
request = Request({"type": "http", "path": "/some/path", "method": "GET"})
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await check_user_exists(request, access_token=access_token)
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
assert exc_info.value.detail == "User not found."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_check_user_exists_with_user_id_only_allowed(
|
||||||
|
user_alan: User, settings: Settings
|
||||||
|
):
|
||||||
|
settings.auth_allowed_methods = [AuthMethods.user_id_only.value]
|
||||||
|
request = Request({"type": "http", "path": "/some/path", "method": "GET"})
|
||||||
|
user = await check_user_exists(request, access_token=None, usr=UUID4(user_alan.id))
|
||||||
|
|
||||||
|
assert user.id == user_alan.id
|
||||||
|
assert request.scope["user_id"] == user.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_check_user_exists_with_user_id_only_not_allowed(user_alan: User):
|
||||||
|
settings.auth_allowed_methods = []
|
||||||
|
request = Request({"type": "http", "path": "/some/path", "method": "GET"})
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await check_user_exists(request, access_token=None, usr=UUID4(user_alan.id))
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
assert exc_info.value.detail == "Missing user ID or access token."
|
||||||
Loading…
Add table
Add a link
Reference in a new issue