Skip to content

Commit

Permalink
Add NIP-98 auth to the API and fix tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
ibz committed Feb 23, 2024
1 parent 14343fe commit d1a269a
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 28 deletions.
21 changes: 19 additions & 2 deletions api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -696,6 +702,12 @@ def get_put_delete_entity(key, cls, singular):

return jsonify({})

@api_blueprint.route('/api/auctions/<key>/follow', methods=['PUT'])
@nip98_auth_required
def follow_auction(pubkey, key):
# TODO
return jsonify({})

@api_blueprint.route('/api/auctions/<key>/media',
defaults={'cls': m.Auction, 'singular': 'auction'},
methods=['POST'])
Expand Down Expand Up @@ -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'],
Expand Down
20 changes: 12 additions & 8 deletions api/api_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="[email protected]")
token_2 = self.nostr_auth(PrivateKey(), twitter_username='fixie_buyer')

# GET listings to see there are none there
Expand Down Expand Up @@ -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="[email protected]")
token_2 = self.nostr_auth(PrivateKey(), twitter_username='auction_user_2', wallet=OTHER_XPUB, lightning_address="[email protected]")

# GET user auctions if not logged in is OK
code, response = self.get("/api/users/auction_user_1/auctions")
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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",
Expand All @@ -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)

Expand Down
23 changes: 21 additions & 2 deletions api/main.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import base64
import boto3
from botocore.config import Config
import click
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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!")
Expand Down
43 changes: 27 additions & 16 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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!
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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']
Expand All @@ -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)

Expand Down
32 changes: 32 additions & 0 deletions api/nostr_utils.py
Original file line number Diff line number Diff line change
@@ -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__()
Expand All @@ -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']

0 comments on commit d1a269a

Please sign in to comment.