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:
callebtc
2025-01-29 22:48:51 -06:00
committed by GitHub
parent b67ffd8705
commit a0ef44dba0
58 changed files with 8188 additions and 701 deletions

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python
import asyncio
import getpass
import os
import time
from datetime import datetime, timezone
@@ -41,6 +42,7 @@ from ...wallet.crud import (
)
from ...wallet.wallet import Wallet as Wallet
from ..api.api_server import start_api_server
from ..auth.auth import WalletAuth
from ..cli.cli_helpers import (
get_mint_wallet,
get_unit_wallet,
@@ -84,6 +86,49 @@ def coro(f):
return wrapper
def init_auth_wallet(func):
"""Decorator to pass auth_db and auth_keyset_id to the Wallet object."""
@wraps(func)
async def wrapper(*args, **kwargs):
ctx = args[0] # Assuming the first argument is 'ctx'
wallet: Wallet = ctx.obj["WALLET"]
db_location = wallet.db.db_location
auth_wallet = await WalletAuth.with_db(
url=ctx.obj["HOST"],
db=db_location,
)
requires_auth = await auth_wallet.init_auth_wallet(wallet.mint_info)
if not requires_auth:
logger.debug("Mint does not require clear auth.")
return await func(*args, **kwargs)
# Pass auth_db and auth_keyset_id to the wallet object
wallet.auth_db = auth_wallet.db
wallet.auth_keyset_id = auth_wallet.keyset_id
# pass the mint_info so the wallet doesn't need to re-fetch it
wallet.mint_info = auth_wallet.mint_info
# Pass the auth_wallet to context
args[0].obj["AUTH_WALLET"] = auth_wallet
# Proceed to the original function
ret = await func(*args, **kwargs)
if settings.debug:
await auth_wallet.load_proofs(reload=True)
logger.debug(
f"Auth balance: {auth_wallet.unit.str(auth_wallet.available_balance)}"
)
return ret
return wrapper
@click.group(cls=NaturalOrderGroup)
@click.option(
"--host",
@@ -176,6 +221,10 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool):
ctx.obj["HOST"], db_path, name=walletname, unit=unit
)
# if we have never seen this mint before, we load its information
if not wallet.mint_info:
await wallet.load_mint()
assert wallet, "Wallet not found."
ctx.obj["WALLET"] = wallet
@@ -205,6 +254,7 @@ async def cli(ctx: Context, host: str, walletname: str, unit: str, tests: bool):
)
@click.pass_context
@coro
@init_auth_wallet
async def pay(
ctx: Context, invoice: str, amount: Optional[int] = None, yes: bool = False
):
@@ -291,6 +341,7 @@ async def pay(
)
@click.pass_context
@coro
@init_auth_wallet
async def invoice(
ctx: Context,
amount: float,
@@ -451,6 +502,7 @@ async def invoice(
@cli.command("swap", help="Swap funds between mints.")
@click.pass_context
@coro
@init_auth_wallet
async def swap(ctx: Context):
print("Select the mint to swap from:")
outgoing_wallet: Wallet = await get_mint_wallet(ctx, force_select=True)
@@ -621,8 +673,9 @@ async def balance(ctx: Context, verbose):
)
@click.pass_context
@coro
@init_auth_wallet
async def send_command(
ctx,
ctx: Context,
amount: int,
memo: str,
nostr: str,
@@ -668,6 +721,7 @@ async def send_command(
)
@click.pass_context
@coro
@init_auth_wallet
async def receive_cli(
ctx: Context,
token: str,
@@ -685,6 +739,8 @@ async def receive_cli(
mint_url,
os.path.join(settings.cashu_dir, wallet.name),
unit=token_obj.unit,
auth_db=wallet.auth_db.db_location if wallet.auth_db else None,
auth_keyset_id=wallet.auth_keyset_id,
)
await verify_mint(mint_wallet, mint_url)
receive_wallet = await receive(mint_wallet, token_obj)
@@ -830,7 +886,7 @@ async def pending(ctx: Context, legacy, number: int, offset: int):
@cli.command("lock", help="Generate receiving lock.")
@click.pass_context
@coro
async def lock(ctx):
async def lock(ctx: Context):
wallet: Wallet = ctx.obj["WALLET"]
pubkey = await wallet.create_p2pk_pubkey()
@@ -851,7 +907,7 @@ async def lock(ctx):
@cli.command("locks", help="Show unused receiving locks.")
@click.pass_context
@coro
async def locks(ctx):
async def locks(ctx: Context):
wallet: Wallet = ctx.obj["WALLET"]
# P2PK lock
pubkey = await wallet.create_p2pk_pubkey()
@@ -899,7 +955,7 @@ async def locks(ctx):
)
@click.pass_context
@coro
async def invoices(ctx, paid: bool, unpaid: bool, pending: bool, mint: bool):
async def invoices(ctx: Context, paid: bool, unpaid: bool, pending: bool, mint: bool):
wallet: Wallet = ctx.obj["WALLET"]
if paid and unpaid:
@@ -1001,7 +1057,7 @@ async def invoices(ctx, paid: bool, unpaid: bool, pending: bool, mint: bool):
@cli.command("wallets", help="List of all available wallets.")
@click.pass_context
@coro
async def wallets(ctx):
async def wallets(ctx: Context):
# list all directories
wallets = [
d for d in listdir(settings.cashu_dir) if isdir(join(settings.cashu_dir, d))
@@ -1013,7 +1069,7 @@ async def wallets(ctx):
for w in wallets:
wallet = Wallet(ctx.obj["HOST"], os.path.join(settings.cashu_dir, w))
try:
await wallet.load_proofs()
await wallet.load_proofs(reload=True, all_keysets=True)
if wallet.proofs and len(wallet.proofs):
active_wallet = False
if w == ctx.obj["WALLET_NAME"]:
@@ -1031,9 +1087,10 @@ async def wallets(ctx):
@cli.command("info", help="Information about Cashu wallet.")
@click.option("--mint", default=False, is_flag=True, help="Fetch mint information.")
@click.option("--mnemonic", default=False, is_flag=True, help="Show your mnemonic.")
@click.option("--reload", default=False, is_flag=True, help="Reload mint info.")
@click.pass_context
@coro
async def info(ctx: Context, mint: bool, mnemonic: bool):
async def info(ctx: Context, mint: bool, mnemonic: bool, reload: bool):
wallet: Wallet = ctx.obj["WALLET"]
await wallet.load_keysets_from_db(unit=None)
@@ -1057,7 +1114,11 @@ async def info(ctx: Context, mint: bool, mnemonic: bool):
if mint:
wallet.url = mint_url
try:
mint_info: dict = (await wallet.load_mint_info()).dict()
mint_info_obj = await wallet.load_mint_info(reload)
if not mint_info_obj:
print(" - Mint information not available.")
continue
mint_info = mint_info_obj.dict()
if mint_info:
print(f" - Mint name: {mint_info['name']}")
if mint_info.get("description"):
@@ -1126,6 +1187,7 @@ async def info(ctx: Context, mint: bool, mnemonic: bool):
)
@click.pass_context
@coro
@init_auth_wallet
async def restore(ctx: Context, to: int, batch: int):
wallet: Wallet = ctx.obj["WALLET"]
# check if there is already a mnemonic in the database
@@ -1160,6 +1222,7 @@ async def restore(ctx: Context, to: int, batch: int):
# @click.option("--all", default=False, is_flag=True, help="Execute on all available mints.")
@click.pass_context
@coro
@init_auth_wallet
async def selfpay(ctx: Context, all: bool = False):
wallet = await get_mint_wallet(ctx, force_select=True)
await wallet.load_mint()
@@ -1183,3 +1246,46 @@ async def selfpay(ctx: Context, all: bool = False):
print(token)
token_obj = TokenV4.deserialize(token)
await receive(wallet, token_obj)
@cli.command("auth", help="Authenticate with mint.")
@click.option("--mint", "-m", default=False, is_flag=True, help="Mint new auth tokens.")
@click.option(
"--force", "-f", default=False, is_flag=True, help="Force authentication."
)
@click.option(
"--password",
"-p",
default=False,
is_flag=True,
help="Use username and password for authentication.",
)
@click.pass_context
@coro
async def auth(ctx: Context, mint: bool, force: bool, password: bool):
# auth_wallet: WalletAuth = ctx.obj["AUTH_WALLET"]
wallet: Wallet = ctx.obj["WALLET"]
username = None
password_str = None
if password:
username = input("Enter username: ")
password_str = getpass.getpass("Enter password: ")
auth_wallet = await WalletAuth.with_db(
url=ctx.obj["HOST"],
db=wallet.db.db_location,
username=username,
password=password_str,
)
requires_auth = await auth_wallet.init_auth_wallet(
wallet.mint_info, mint_auth_proofs=False, force_auth=force
)
if not requires_auth:
print("Mint does not require authentication.")
return
if mint:
new_proofs = await auth_wallet.mint_blind_auth()
print(f"Minted {auth_wallet.unit.str(sum_proofs(new_proofs))} auth tokens.")
print(f"Auth balance: {auth_wallet.unit.str(auth_wallet.available_balance)}")