mirror of
https://github.com/aljazceru/nutshell.git
synced 2026-01-09 03:34:19 +01:00
Blind authentication (#675)
* auth server * cleaning up * auth ledger class * class variables -> instance variables * annotations * add models and api route * custom amount and api prefix * add auth db * blind auth token working * jwt working * clean up * JWT works * using openid connect server * use oauth server with password flow * new realm * add keycloak docker * hopefully not garbage * auth works * auth kinda working * fix cli * auth works for send and receive * pass auth_db to Wallet * auth in info * refactor * fix supported * cache mint info * fix settings and endpoints * add description to .env.example * track changes for openid connect client * store mint in db * store credentials * clean up v1_api.py * load mint info into auth wallet * fix first login * authenticate if refresh token fails * clear auth also middleware * use regex * add cli command * pw works * persist keyset amounts * add errors.py * do not start auth server if disabled in config * upadte poetry * disvoery url * fix test * support device code flow * adopt latest spec changes * fix code flow * mint max bat dynamic * mypy ignore * fix test * do not serialize amount in authproof * all auth flows working * fix tests * submodule * refactor * test * dont sleep * test * add wallet auth tests * test differently * test only keycloak for now * fix creds * daemon * fix test * install everything * install jinja * delete wallet for every test * auth: use global rate limiter * test auth rate limit * keycloak hostname * move keycloak test data * reactivate all tests * add readme * load proofs * remove unused code * remove unused code * implement change suggestions by ok300 * add error codes * test errors
This commit is contained in:
@@ -51,6 +51,7 @@ settings.mint_lnd_enable_mpp = True
|
||||
settings.mint_clnrest_enable_mpp = True
|
||||
settings.mint_input_fee_ppk = 0
|
||||
settings.db_connection_pool = True
|
||||
# settings.mint_require_auth = False
|
||||
|
||||
assert "test" in settings.cashu_dir
|
||||
shutil.rmtree(settings.cashu_dir, ignore_errors=True)
|
||||
|
||||
45
tests/keycloak_data/docker-compose-restore.yml
Normal file
45
tests/keycloak_data/docker-compose-restore.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16.4
|
||||
volumes:
|
||||
- ./postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: cashu
|
||||
POSTGRES_USER: cashu
|
||||
POSTGRES_PASSWORD: cashu
|
||||
networks:
|
||||
- keycloak_network
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:25.0.6
|
||||
command: start --import-realm
|
||||
volumes:
|
||||
- ./keycloak-export:/opt/keycloak/data/import
|
||||
environment:
|
||||
KC_HOSTNAME: localhost
|
||||
KC_HOSTNAME_PORT: 8080
|
||||
KC_HOSTNAME_STRICT_BACKCHANNEL: false
|
||||
KC_HTTP_ENABLED: true
|
||||
KC_HOSTNAME_STRICT_HTTPS: false
|
||||
KC_HEALTH_ENABLED: true
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: jdbc:postgresql://postgres/cashu
|
||||
KC_DB_USERNAME: cashu
|
||||
KC_DB_PASSWORD: cashu
|
||||
ports:
|
||||
- 8080:8080
|
||||
restart: always
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- keycloak_network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
keycloak_network:
|
||||
driver: bridge
|
||||
2021
tests/keycloak_data/keycloak-export/master-realm.json
Normal file
2021
tests/keycloak_data/keycloak-export/master-realm.json
Normal file
File diff suppressed because it is too large
Load Diff
26
tests/keycloak_data/keycloak-export/master-users-0.json
Normal file
26
tests/keycloak_data/keycloak-export/master-users-0.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"realm" : "master",
|
||||
"users" : [ {
|
||||
"id" : "0ff227f7-c163-4fca-9ae4-c8751c725421",
|
||||
"username" : "admin",
|
||||
"emailVerified" : false,
|
||||
"createdTimestamp" : 1727128354842,
|
||||
"enabled" : true,
|
||||
"totp" : false,
|
||||
"credentials" : [ {
|
||||
"id" : "11a5f9ed-19c9-4164-be31-28ce6e23955b",
|
||||
"type" : "password",
|
||||
"createdDate" : 1727128354904,
|
||||
"secretData" : "{\"value\":\"s/6M2/FCFd1fOyHJRMvOLvKM7e2JIOC6LZ3ovFVkGi8=\",\"salt\":\"Zjn7ChOL5688O84xf1ElGA==\",\"additionalParameters\":{}}",
|
||||
"credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
|
||||
} ],
|
||||
"disableableCredentialTypes" : [ ],
|
||||
"requiredActions" : [ ],
|
||||
"realmRoles" : [ "default-roles-master", "admin" ],
|
||||
"clientRoles" : {
|
||||
"nutshell-realm" : [ "query-realms", "query-users", "manage-identity-providers", "manage-authorization", "view-identity-providers", "view-realm", "view-authorization", "query-clients", "manage-clients", "create-client", "view-events", "manage-events", "manage-realm", "manage-users", "view-users", "view-clients", "query-groups" ]
|
||||
},
|
||||
"notBefore" : 0,
|
||||
"groups" : [ ]
|
||||
} ]
|
||||
}
|
||||
1902
tests/keycloak_data/keycloak-export/nutshell-realm.json
Normal file
1902
tests/keycloak_data/keycloak-export/nutshell-realm.json
Normal file
File diff suppressed because it is too large
Load Diff
53
tests/keycloak_data/keycloak-export/nutshell-users-0.json
Normal file
53
tests/keycloak_data/keycloak-export/nutshell-users-0.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"realm" : "nutshell",
|
||||
"users" : [ {
|
||||
"id" : "c4fc742a-700f-4c83-96f2-8777c8bb56d1",
|
||||
"username" : "asd@asd.com",
|
||||
"firstName" : "asd",
|
||||
"lastName" : "asd",
|
||||
"email" : "asd@asd.com",
|
||||
"emailVerified" : false,
|
||||
"createdTimestamp" : 1727128876722,
|
||||
"enabled" : true,
|
||||
"totp" : false,
|
||||
"credentials" : [ {
|
||||
"id" : "23ea2b79-9c09-4133-b53b-2708258da890",
|
||||
"type" : "password",
|
||||
"createdDate" : 1727128876754,
|
||||
"secretData" : "{\"value\":\"fDXqE3IjxS5uIYfn9eYgW5GwokWvGsg2wWY0lOgeYyE=\",\"salt\":\"Wlb5f8yPTh4QreuC99b7Zg==\",\"additionalParameters\":{}}",
|
||||
"credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
|
||||
} ],
|
||||
"disableableCredentialTypes" : [ ],
|
||||
"requiredActions" : [ ],
|
||||
"realmRoles" : [ "default-roles-nutshell" ],
|
||||
"clientConsents" : [ {
|
||||
"clientId" : "cashu-client",
|
||||
"grantedClientScopes" : [ "email", "roles", "profile" ],
|
||||
"createdDate" : 1732651444894,
|
||||
"lastUpdatedDate" : 1732651444908
|
||||
} ],
|
||||
"notBefore" : 0,
|
||||
"groups" : [ ]
|
||||
}, {
|
||||
"id" : "43a16bd6-f5c5-4dfa-bcd4-6a5540564797",
|
||||
"username" : "callebtc@protonmail.com",
|
||||
"firstName" : "asdasd",
|
||||
"lastName" : "asdasdasdasd",
|
||||
"email" : "callebtc@protonmail.com",
|
||||
"emailVerified" : false,
|
||||
"createdTimestamp" : 1732639511706,
|
||||
"enabled" : true,
|
||||
"totp" : false,
|
||||
"credentials" : [ ],
|
||||
"disableableCredentialTypes" : [ ],
|
||||
"requiredActions" : [ ],
|
||||
"federatedIdentities" : [ {
|
||||
"identityProvider" : "github",
|
||||
"userId" : "93376500",
|
||||
"userName" : "callebtc"
|
||||
} ],
|
||||
"realmRoles" : [ "default-roles-nutshell" ],
|
||||
"notBefore" : 0,
|
||||
"groups" : [ ]
|
||||
} ]
|
||||
}
|
||||
@@ -183,14 +183,14 @@ async def test_mint(wallet1: Wallet):
|
||||
assert wallet1.balance == 64
|
||||
|
||||
# verify that proofs in proofs_used db have the same mint_id as the invoice in the db
|
||||
mint_quote = await get_bolt11_mint_quote(db=wallet1.db, quote=mint_quote.quote)
|
||||
assert mint_quote
|
||||
mint_quote_2 = await get_bolt11_mint_quote(db=wallet1.db, quote=mint_quote.quote)
|
||||
assert mint_quote_2
|
||||
proofs_minted = await get_proofs(
|
||||
db=wallet1.db, mint_id=mint_quote.quote, table="proofs"
|
||||
db=wallet1.db, mint_id=mint_quote_2.quote, table="proofs"
|
||||
)
|
||||
assert len(proofs_minted) == len(expected_proof_amounts)
|
||||
assert all([p.amount in expected_proof_amounts for p in proofs_minted])
|
||||
assert all([p.mint_id == mint_quote.quote for p in proofs_minted])
|
||||
assert all([p.mint_id == mint_quote_2.quote for p in proofs_minted])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -356,7 +356,7 @@ async def test_swap_to_send_more_than_balance(wallet1: Wallet):
|
||||
await wallet1.mint(64, quote_id=mint_quote.quote)
|
||||
await assert_err(
|
||||
wallet1.swap_to_send(wallet1.proofs, 128, set_reserved=True),
|
||||
"balance too low.",
|
||||
"Balance too low",
|
||||
)
|
||||
assert wallet1.balance == 64
|
||||
assert wallet1.available_balance == 64
|
||||
|
||||
251
tests/test_wallet_auth.py
Normal file
251
tests/test_wallet_auth.py
Normal file
@@ -0,0 +1,251 @@
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from cashu.core.base import Unit
|
||||
from cashu.core.crypto.keys import random_hash
|
||||
from cashu.core.crypto.secp import PrivateKey
|
||||
from cashu.core.errors import (
|
||||
BlindAuthFailedError,
|
||||
BlindAuthRateLimitExceededError,
|
||||
ClearAuthFailedError,
|
||||
)
|
||||
from cashu.core.settings import settings
|
||||
from cashu.wallet.auth.auth import WalletAuth
|
||||
from cashu.wallet.wallet import Wallet
|
||||
from tests.conftest import SERVER_ENDPOINT
|
||||
from tests.helpers import assert_err
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def wallet():
|
||||
dirpath = Path("test_data/wallet")
|
||||
if dirpath.exists() and dirpath.is_dir():
|
||||
shutil.rmtree(dirpath)
|
||||
wallet = await Wallet.with_db(
|
||||
url=SERVER_ENDPOINT,
|
||||
db="test_data/wallet",
|
||||
name="wallet",
|
||||
)
|
||||
await wallet.load_mint()
|
||||
yield wallet
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not settings.mint_require_auth,
|
||||
reason="settings.mint_require_auth is False",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_auth_password(wallet: Wallet):
|
||||
auth_wallet = await WalletAuth.with_db(
|
||||
url=wallet.url,
|
||||
db=wallet.db.db_location,
|
||||
username="asd@asd.com",
|
||||
password="asdasd",
|
||||
)
|
||||
|
||||
requires_auth = await auth_wallet.init_auth_wallet(
|
||||
wallet.mint_info, mint_auth_proofs=False
|
||||
)
|
||||
assert requires_auth
|
||||
|
||||
# expect JWT (CAT) with format ey*.ey*
|
||||
assert auth_wallet.oidc_client.access_token
|
||||
assert auth_wallet.oidc_client.access_token.split(".")[0].startswith("ey")
|
||||
assert auth_wallet.oidc_client.access_token.split(".")[1].startswith("ey")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not settings.mint_require_auth,
|
||||
reason="settings.mint_require_auth is False",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_auth_wrong_password(wallet: Wallet):
|
||||
auth_wallet = await WalletAuth.with_db(
|
||||
url=wallet.url,
|
||||
db=wallet.db.db_location,
|
||||
username="asd@asd.com",
|
||||
password="wrong_password",
|
||||
)
|
||||
|
||||
await assert_err(auth_wallet.init_auth_wallet(wallet.mint_info), "401 Unauthorized")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not settings.mint_require_auth,
|
||||
reason="settings.mint_require_auth is False",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_auth_mint(wallet: Wallet):
|
||||
auth_wallet = await WalletAuth.with_db(
|
||||
url=wallet.url,
|
||||
db=wallet.db.db_location,
|
||||
username="asd@asd.com",
|
||||
password="asdasd",
|
||||
)
|
||||
|
||||
requires_auth = await auth_wallet.init_auth_wallet(wallet.mint_info)
|
||||
assert requires_auth
|
||||
|
||||
await auth_wallet.load_proofs()
|
||||
assert len(auth_wallet.proofs) == auth_wallet.mint_info.bat_max_mint
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not settings.mint_require_auth,
|
||||
reason="settings.mint_require_auth is False",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_auth_mint_manually(wallet: Wallet):
|
||||
auth_wallet = await WalletAuth.with_db(
|
||||
url=wallet.url,
|
||||
db=wallet.db.db_location,
|
||||
username="asd@asd.com",
|
||||
password="asdasd",
|
||||
)
|
||||
|
||||
requires_auth = await auth_wallet.init_auth_wallet(
|
||||
wallet.mint_info, mint_auth_proofs=False
|
||||
)
|
||||
assert requires_auth
|
||||
assert len(auth_wallet.proofs) == 0
|
||||
|
||||
await auth_wallet.mint_blind_auth()
|
||||
assert len(auth_wallet.proofs) == auth_wallet.mint_info.bat_max_mint
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not settings.mint_require_auth,
|
||||
reason="settings.mint_require_auth is False",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_auth_mint_manually_invalid_cat(wallet: Wallet):
|
||||
auth_wallet = await WalletAuth.with_db(
|
||||
url=wallet.url,
|
||||
db=wallet.db.db_location,
|
||||
username="asd@asd.com",
|
||||
password="asdasd",
|
||||
)
|
||||
|
||||
requires_auth = await auth_wallet.init_auth_wallet(
|
||||
wallet.mint_info, mint_auth_proofs=False
|
||||
)
|
||||
assert requires_auth
|
||||
assert len(auth_wallet.proofs) == 0
|
||||
|
||||
# invalidate CAT in the database
|
||||
auth_wallet.oidc_client.access_token = random_hash()
|
||||
|
||||
# this is the code executed in auth_wallet.mint_blind_auth():
|
||||
clear_auth_token = auth_wallet.oidc_client.access_token
|
||||
if not clear_auth_token:
|
||||
raise Exception("No clear auth token available.")
|
||||
|
||||
amounts = auth_wallet.mint_info.bat_max_mint * [1] # 1 AUTH tokens
|
||||
secrets = [hashlib.sha256(os.urandom(32)).hexdigest() for _ in amounts]
|
||||
rs = [PrivateKey(privkey=os.urandom(32), raw=True) for _ in amounts]
|
||||
outputs, rs = auth_wallet._construct_outputs(amounts, secrets, rs)
|
||||
|
||||
# should fail because of invalid CAT
|
||||
await assert_err(
|
||||
auth_wallet.blind_mint_blind_auth(clear_auth_token, outputs),
|
||||
ClearAuthFailedError.detail,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not settings.mint_require_auth,
|
||||
reason="settings.mint_require_auth is False",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_auth_invoice(wallet: Wallet):
|
||||
# should fail, wallet error
|
||||
await assert_err(wallet.mint_quote(10, Unit.sat), "Mint requires blind auth")
|
||||
|
||||
auth_wallet = await WalletAuth.with_db(
|
||||
url=wallet.url,
|
||||
db=wallet.db.db_location,
|
||||
username="asd@asd.com",
|
||||
password="asdasd",
|
||||
)
|
||||
requires_auth = await auth_wallet.init_auth_wallet(wallet.mint_info)
|
||||
assert requires_auth
|
||||
|
||||
await auth_wallet.load_proofs()
|
||||
assert len(auth_wallet.proofs) == auth_wallet.mint_info.bat_max_mint
|
||||
|
||||
wallet.auth_db = auth_wallet.db
|
||||
wallet.auth_keyset_id = auth_wallet.keyset_id
|
||||
|
||||
# should succeed
|
||||
await wallet.mint_quote(10, Unit.sat)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not settings.mint_require_auth,
|
||||
reason="settings.mint_require_auth is False",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_auth_invoice_invalid_bat(wallet: Wallet):
|
||||
# should fail, wallet error
|
||||
await assert_err(wallet.mint_quote(10, Unit.sat), "Mint requires blind auth")
|
||||
|
||||
auth_wallet = await WalletAuth.with_db(
|
||||
url=wallet.url,
|
||||
db=wallet.db.db_location,
|
||||
username="asd@asd.com",
|
||||
password="asdasd",
|
||||
)
|
||||
requires_auth = await auth_wallet.init_auth_wallet(wallet.mint_info)
|
||||
assert requires_auth
|
||||
|
||||
await auth_wallet.load_proofs()
|
||||
assert len(auth_wallet.proofs) == auth_wallet.mint_info.bat_max_mint
|
||||
|
||||
# invalidate blind auth proofs
|
||||
for p in auth_wallet.proofs:
|
||||
await auth_wallet.db.execute(
|
||||
f"UPDATE proofs SET secret = '{random_hash()}' WHERE secret = '{p.secret}'"
|
||||
)
|
||||
|
||||
wallet.auth_db = auth_wallet.db
|
||||
wallet.auth_keyset_id = auth_wallet.keyset_id
|
||||
|
||||
# blind auth failed
|
||||
await assert_err(wallet.mint_quote(10, Unit.sat), BlindAuthFailedError.detail)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not settings.mint_require_auth,
|
||||
reason="settings.mint_require_auth is False",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_auth_rate_limit(wallet: Wallet):
|
||||
auth_wallet = await WalletAuth.with_db(
|
||||
url=wallet.url,
|
||||
db=wallet.db.db_location,
|
||||
username="asd@asd.com",
|
||||
password="asdasd",
|
||||
)
|
||||
requires_auth = await auth_wallet.init_auth_wallet(
|
||||
wallet.mint_info, mint_auth_proofs=False
|
||||
)
|
||||
assert requires_auth
|
||||
|
||||
errored = False
|
||||
for _ in range(100):
|
||||
try:
|
||||
await auth_wallet.mint_blind_auth()
|
||||
except Exception as e:
|
||||
assert BlindAuthRateLimitExceededError.detail in str(e)
|
||||
errored = True
|
||||
break
|
||||
|
||||
assert errored
|
||||
|
||||
# should have minted at least twice
|
||||
assert len(auth_wallet.proofs) > auth_wallet.mint_info.bat_max_mint
|
||||
@@ -54,7 +54,7 @@ async def init_wallet():
|
||||
wallet = await Wallet.with_db(
|
||||
url=settings.mint_url,
|
||||
db="test_data/test_cli_wallet",
|
||||
name="wallet",
|
||||
name="test_cli_wallet",
|
||||
)
|
||||
await wallet.load_proofs()
|
||||
return wallet
|
||||
@@ -411,7 +411,7 @@ def test_wallets(cli_prefix):
|
||||
print("WALLETS")
|
||||
# on github this is empty
|
||||
if len(result.output):
|
||||
assert "test_cli_wallet" in result.output
|
||||
assert "wallet" in result.output
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
@@ -474,7 +474,7 @@ def test_send_too_much(mint, cli_prefix):
|
||||
cli,
|
||||
[*cli_prefix, "send", "100000"],
|
||||
)
|
||||
assert "balance too low" in str(result.exception)
|
||||
assert "Balance too low" in str(result.exception)
|
||||
|
||||
|
||||
def test_receive_tokenv3(mint, cli_prefix):
|
||||
|
||||
Reference in New Issue
Block a user