From d1a269a40f02c55479e7d7de04fcb19e415a34a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ioan=20Biz=C4=83u?= Date: Fri, 23 Feb 2024 12:15:14 +0200 Subject: [PATCH] Add NIP-98 auth to the API and fix tests. --- api/api.py | 21 +++++++++++++++++++-- api/api_tests.py | 20 ++++++++++++-------- api/main.py | 23 +++++++++++++++++++++-- api/models.py | 43 +++++++++++++++++++++++++++---------------- api/nostr_utils.py | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 28 deletions(-) diff --git a/api/api.py b/api/api.py index ab2592fc..1b82410a 100644 --- a/api/api.py +++ b/api/api.py @@ -21,7 +21,7 @@ from extensions import db import models as m from main import app, get_birdwatcher, get_lndhub_client, get_file_storage -from main import get_token_from_request, get_user_from_token, user_required +from main import get_token_from_request, get_user_from_token, user_required, nip98_auth_required from main import MempoolSpaceError from nostr_utils import EventValidationError, validate_event from utils import usd2sats, sats2usd, parse_github_tag, parse_xpub, UnknownKeyTypeError @@ -56,7 +56,13 @@ def status(): @api_blueprint.route('/api/update', methods=['PUT']) @user_required def update(_user: m.User): - # TODO: not every user should be able to perform the update + # TODO: not every user should be able to perform the update! + # But also... we don't really have the concept of "admin" user (for the back office) for now. + # This is not really an issue yet, because the instance at plebeian.market is not updatable anyway (since it is installed from a git clone) + # and for custom instances, which are more like personal websites, like chiefmonkey.art, + # we want to prevent new signups (after the first user). So they should really have only one user. + # But in the future we will want "community versions" + # and in that case we should have the concept of a "community admin" and only that user should be able to perform updates of the instance! if not app.config['RELEASE_VERSION']: # this would happen if the app was not installed from a GitHub build, but in some other way, @@ -696,6 +702,12 @@ def get_put_delete_entity(key, cls, singular): return jsonify({}) +@api_blueprint.route('/api/auctions//follow', methods=['PUT']) +@nip98_auth_required +def follow_auction(pubkey, key): + # TODO + return jsonify({}) + @api_blueprint.route('/api/auctions//media', defaults={'cls': m.Auction, 'singular': 'auction'}, methods=['POST']) @@ -996,6 +1008,11 @@ def post_merchant_message(pubkey): # NB: we only look for listings here. auction orders are generated in finalize-auctions! listing = m.Listing.query.filter_by(uuid=item['product_id']).first() if listing: + if not listing.nostr_event_id: + message = "Listing not active." + get_birdwatcher().send_dm(merchant_private_key, request.json['pubkey'], + json.dumps({'id': cleartext_content['id'], 'type': 2, 'paid': False, 'shipped': False, 'message': message})) + return jsonify({'message': message}), 403 if listing.available_quantity is not None and listing.available_quantity < item['quantity']: message = "Not enough items in stock!" get_birdwatcher().send_dm(merchant_private_key, request.json['pubkey'], diff --git a/api/api_tests.py b/api/api_tests.py index b6f2583f..1b138408 100644 --- a/api/api_tests.py +++ b/api/api_tests.py @@ -186,7 +186,7 @@ def nostr_auth(self, private_key, expect_success=True, **kwargs): self.assertNotEqual(code, 200) def test_listings(self): - token_1 = self.nostr_auth(PrivateKey(), twitter_username='fixie') + token_1 = self.nostr_auth(PrivateKey(), twitter_username='fixie', lightning_address="ibz@stacker.news") token_2 = self.nostr_auth(PrivateKey(), twitter_username='fixie_buyer') # GET listings to see there are none there @@ -568,8 +568,8 @@ def test_000_user(self): self.assertNotEqual(identity_1, identity_2) def test_auctions(self): - token_1 = self.nostr_auth(PrivateKey(), twitter_username='auction_user_1', contribution_percent=1, wallet=OTHER_XPUB) - token_2 = self.nostr_auth(PrivateKey(), twitter_username='auction_user_2', wallet=OTHER_XPUB) + token_1 = self.nostr_auth(PrivateKey(), twitter_username='auction_user_1', contribution_percent=1, wallet=OTHER_XPUB, lightning_address="ibz@stacker.news") + token_2 = self.nostr_auth(PrivateKey(), twitter_username='auction_user_2', wallet=OTHER_XPUB, lightning_address="ibz@stacker.news") # GET user auctions if not logged in is OK code, response = self.get("/api/users/auction_user_1/auctions") @@ -657,6 +657,10 @@ def test_auctions(self): self.assertEqual(response['auction']['key'], auction_key) self.assertIsNone(response['auction']['nostr_event_id']) + code, response = self.put(f"/api/auctions/{auction_key}/follow", {}) + self.assertEqual(code, 401) + self.assertIn("missing auth header", response['message'].lower()) + # create a 2nd auction, this time for the 2nd user code, response = self.post("/api/users/me/auctions", {'title': "His 2st", @@ -719,8 +723,8 @@ def test_auctions(self): code, response = self.get(f"/api/auctions/{auction_key_2}") self.assertEqual(code, 404) - # publish the auction - code, response = self.put(f"/api/auctions/{auction_key}/publish", {}, + # publish and start the auction + code, response = self.put(f"/api/auctions/{auction_key}/start", {}, headers=self.get_auth_headers(token_1)) self.assertEqual(code, 200) @@ -838,7 +842,7 @@ def test_auctions(self): signed_lower_event_json = json.loads(lower_bid_event.to_message())[1] code, response = self.post(f"/api/merchants/{auction_merchant_public_key}/auctions/{auction_after_edit_nostr_event_id}/bids", signed_lower_event_json) self.assertEqual(code, 400) - self.assertIn("your bid needs to be higher", response['message'].lower()) + self.assertIn("amount needs to be higher", response['message'].lower()) # create an auction without a start date code, response = self.post("/api/users/me/auctions", @@ -856,12 +860,12 @@ def test_auctions(self): auction_key_3 = response['auction']['key'] # another user can't start my auction - code, response = self.put(f"/api/auctions/{auction_key_3}/publish", {}, + code, response = self.put(f"/api/auctions/{auction_key_3}/start", {}, headers=self.get_auth_headers(token_1)) self.assertEqual(code, 401) # start the auction - code, response = self.put(f"/api/auctions/{auction_key_3}/publish", {}, + code, response = self.put(f"/api/auctions/{auction_key_3}/start", {}, headers=self.get_auth_headers(token_2)) self.assertEqual(code, 200) diff --git a/api/main.py b/api/main.py index 8c30a0d2..d4e9dab3 100644 --- a/api/main.py +++ b/api/main.py @@ -1,3 +1,4 @@ +import base64 import boto3 from botocore.config import Config import click @@ -28,7 +29,7 @@ from lnd_hub_client import LndHubClient, MockLndHubClient from extensions import cors, db, mail -from nostr_utils import EventValidationError, validate_event +from nostr_utils import EventValidationError, validate_event, get_nip98_pubkey from utils import hash_create LOG_LEVEL = os.environ.get('LOG_LEVEL', 'DEBUG') @@ -503,6 +504,24 @@ def decorator(*args, **kwargs): return f(user, *args, **kwargs) return decorator +def nip98_auth_required(f): + @wraps(f) + def decorator(*args, **kwargs): + auth = request.headers.get('Authorization') + if not auth: + return jsonify({'success': False, 'message': "Missing auth header."}), 401 + parts = auth.split(' ') + if len(parts) != 2: + return jsonify({'success': False, 'message': "Invalid auth header."}), 401 + if parts[0].lower() != 'nostr': + return jsonify({'success': False, 'message': "Nostr auth expected."}), 401 + event_json = json.loads(base64.b64decode(parts[1])) + pubkey = get_nip98_pubkey(event_json, request.url, request.method) + if not pubkey: + return jsonify({'success': False, 'message': "NIP-98 auth failed."}), 401 + return f(pubkey, *args, **kwargs) + return decorator + class MockBTCClient: def get_funding_txs(self, addr): order = db.session.query(m.Order).filter(m.Order.on_chain_address == addr).first() @@ -1070,7 +1089,7 @@ def configure_site(): for badge_def in [badge_def_skin_in_the_game, badge_def_og]: badge = m.Badge.query.filter_by(badge_id=badge_def['badge_id']).first() if badge is None: - badge = m.Badge(badge_id=badge_def['badge_id'], name=badge_def['name'], description=badge_def['description'], image_hash=image_hash) + badge = m.Badge(owner_public_key=SITE_ADMIN_CONFIG['nostr_private_key'].public_key.hex(), badge_id=badge_def['badge_id'], name=badge_def['name'], description=badge_def['description'], image_hash=image_hash) badge.nostr_event_id = birdwatcher.publish_badge_definition(badge.badge_id, badge.name, badge.description, badge_def['image_url']) if badge.nostr_event_id is None: app.logger.error("Failed to publish badge definition!") diff --git a/api/models.py b/api/models.py index cdf652cd..f875b923 100644 --- a/api/models.py +++ b/api/models.py @@ -357,16 +357,7 @@ class UserRelay(db.Model): relay = db.relationship('Relay') -class StateMixin: - @property - def state(self): - if not self.started and not self.ended: - return 'new' - elif self.started and not self.ended: - return 'active' - elif self.ended: - return 'past' - +class StateFilterMixin: def filter_state(self, state, for_user_id): is_owner = for_user_id == self.owner_id if state is None: @@ -404,7 +395,7 @@ def to_nostr_tags(self): tags.append(['t', cat_tag]) return tags -class Campaign(WalletMixin, GeneratedKeyMixin, StateMixin, db.Model): +class Campaign(WalletMixin, GeneratedKeyMixin, db.Model): """ Campaigns used to exist in the old (pre-Nostr) version of Plebeian Market. We didn't port them to Nostr, but keeping the model definition here so we don't lose the table from the DB! @@ -486,7 +477,7 @@ class ItemCategory(db.Model): item_id = db.Column(db.Integer, db.ForeignKey(Item.id), nullable=False) category_id = db.Column(db.Integer, db.ForeignKey(Category.id), nullable=False) -class Auction(GeneratedKeyMixin, StateMixin, NostrProductMixin, db.Model): +class Auction(GeneratedKeyMixin, StateFilterMixin, NostrProductMixin, db.Model): __tablename__ = 'auctions' REQUIRED_FIELDS = ['title', 'description', 'duration_hours', 'starting_bid', 'reserve_bid', 'extra_shipping_domestic_usd', 'extra_shipping_worldwide_usd'] @@ -514,6 +505,15 @@ def owner_id(self): def started(self): return self.start_date <= datetime.utcnow() if self.start_date else False + @property + def state(self): + if not self.started and not self.ended: + return 'new' + elif self.started and not self.ended: + return 'active' + elif self.ended: + return 'past' + # duration_hours reflects the initial duration, # but the auction can be extended when bids come in close to the end - hence the end_date duration_hours = db.Column(db.Float, nullable=False) @@ -685,7 +685,7 @@ def validate_dict(cls, d): raise ValidationError(f"{k.replace('_', ' ')} is invalid.".capitalize()) return validated -class Listing(GeneratedKeyMixin, StateMixin, NostrProductMixin, db.Model): +class Listing(GeneratedKeyMixin, StateFilterMixin, NostrProductMixin, db.Model): __tablename__ = 'listings' REQUIRED_FIELDS = ['title', 'description', 'price_usd', 'available_quantity', 'extra_shipping_domestic_usd', 'extra_shipping_worldwide_usd'] @@ -706,16 +706,27 @@ def owner_id(self): campaign_id = db.Column(db.Integer, db.ForeignKey(Campaign.id), nullable=True) - # TODO: we should probably retire this column since it doesn't make much sense for fixed price items + ########## + # TODO: we should probably retire `start_date`, `started` and `ended` + # since they don't make much sense for fixed price items! + ########## start_date = db.Column(db.DateTime, nullable=True) - @property def started(self): return self.start_date <= datetime.utcnow() if self.start_date else False - @property def ended(self): return self.available_quantity == 0 + ########## + + @property + def state(self): + if not self.nostr_event_id: + return 'new' + elif self.available_quantity != 0: + return 'active' + else: + return 'past' price_usd = db.Column(db.Float, nullable=False) diff --git a/api/nostr_utils.py b/api/nostr_utils.py index 05100c15..9a317072 100644 --- a/api/nostr_utils.py +++ b/api/nostr_utils.py @@ -1,7 +1,12 @@ +from datetime import datetime, timedelta +from enum import IntEnum from hashlib import sha256 import json import secp256k1 +class EventKind(IntEnum): + NIP98_AUTH = 27235 + class EventValidationError(Exception): def __init__(self, message): super().__init__() @@ -22,3 +27,30 @@ def validate_event(event_json): raise EventValidationError("Invalid event signature!") except ValueError: raise EventValidationError("Invalid event signature!") + +def get_nip98_pubkey(event_json, url, method): + try: + validate_event(event_json) + except EventValidationError: + return None + + if int(event_json['kind']) != EventKind.NIP98_AUTH or event_json['content'] != "": + return None + + now = datetime.now() + created_at = datetime.fromtimestamp(float(event_json['created_at'])) + if created_at < now - timedelta(minutes=1) or created_at > now + timedelta(minutes=1): + return None + + u_tag = None + method_tag = None + for tag in event_json['tags']: + match tag[0]: + case 'u': + u_tag = tag[1] + case 'method': + method_tag = tag[1] + if u_tag != url or method_tag != method: + return None + + return event_json['pubkey']