From ca3f7cb222c4867a7517034b8ec86ce3cdd77657 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi <90936742+Reckless-Satoshi@users.noreply.github.com> Date: Mon, 14 Aug 2023 14:21:12 +0000 Subject: [PATCH] Delete user view, session auth and png avatars (#588) --- api/models/robot.py | 2 +- api/oas_schemas.py | 237 ----------------------------------------- api/serializers.py | 58 ---------- api/views.py | 225 +------------------------------------- chat/views.py | 5 +- requirements.txt | 1 - robosats/middleware.py | 11 +- 7 files changed, 8 insertions(+), 531 deletions(-) diff --git a/api/models/robot.py b/api/models/robot.py index d0a1f987..b6a99f8d 100644 --- a/api/models/robot.py +++ b/api/models/robot.py @@ -65,7 +65,7 @@ class Robot(models.Model): # RoboHash avatar = models.ImageField( - default=("static/assets/avatars/" + "unknown_avatar.png"), + default=("static/assets/avatars/" + "unknown_avatar.webp"), verbose_name="Avatar", blank=True, ) diff --git a/api/oas_schemas.py b/api/oas_schemas.py index 4b8a425c..15ad1cde 100644 --- a/api/oas_schemas.py +++ b/api/oas_schemas.py @@ -356,241 +356,6 @@ class OrderViewSchema: } -class UserViewSchema: - post = { - "summary": "Create user", - "description": textwrap.dedent( - """ - Create a new Robot 🤖 - - `token_sha256` is the SHA256 hash of your token. Make sure you generate your token - using cryptographically secure methods. [Here's]() the function the Javascript - client uses to generate the tokens. Since the server only recieves the hash of the - token, it trusts the client with computing `length`, `counts` and `unique_values` - correctly. Check [here](https://github.com/RoboSats/robosats/blob/main/frontend/src/utils/token.js#L13) - to see how the Javascript client copmutes these values. These values are optional, - but if provided, the api computes the entropy of the token adds two additional - fields to the response JSON - `token_shannon_entropy` and `token_bits_entropy`. - - **Note: It is entirely the clients responsibilty to generate high entropy tokens, and the optional - parameters are provided to act as an aid to help determine sufficient entropy, but the server is happy - with just any sha256 hash you provide it** - - `public_key` - PGP key associated with the user (Armored ASCII format) - `encrypted_private_key` - Private PGP key. This is only stored on the backend for later fetching by - the frontend and the key can't really be used by the server since it's protected by the token - that only the client knows. Will be made an optional parameter in a future release. - On the Javascript client, It's passphrase is set to be the secret token generated. - - A gpg key can be created by: - - ```shell - gpg --full-gen-key - ``` - - it's public key can be exported in ascii armored format with: - - ```shell - gpg --export --armor - ``` - - and it's private key can be exported in ascii armored format with: - - ```shell - gpg --export-secret-keys --armor - ``` - - """ - ), - "responses": { - 201: { - "type": "object", - "properties": { - "encrypted_private_key": { - "type": "string", - "description": "Armored ASCII PGP private key block", - }, - "nickname": { - "type": "string", - "description": "Username generated (Robot name)", - }, - "public_key": { - "type": "string", - "description": "Armored ASCII PGP public key block", - }, - "token_bits_entropy": {"type": "integer"}, - "token_shannon_entropy": {"type": "integer"}, - "wants_stealth": { - "type": "boolean", - "default": False, - "description": "Whether the user prefers stealth invoices", - }, - }, - }, - 202: { - "type": "object", - "properties": { - "encrypted_private_key": { - "type": "string", - "description": "Armored ASCII PGP private key block", - }, - "nickname": { - "type": "string", - "description": "Username generated (Robot name)", - }, - "public_key": { - "type": "string", - "description": "Armored ASCII PGP public key block", - }, - "token_bits_entropy": {"type": "integer"}, - "token_shannon_entropy": {"type": "integer"}, - "wants_stealth": { - "type": "boolean", - "default": False, - "description": "Whether the user prefers stealth invoices", - }, - "found": {"type": "string", "description": "Welcome back message"}, - "tg_enabled": { - "type": "boolean", - "description": "The robot has telegram notifications enabled", - }, - "tg_token": { - "type": "string", - "description": "Token to enable telegram with /start ", - }, - "tg_bot_name": { - "type": "string", - "description": "Name of the coordinator's telegram bot", - }, - "last_login": { - "type": "string", - "format": "date-time", - "description": "Last time seen", - }, - "active_order_id": { - "type": "integer", - "description": "Active order id if present", - }, - "last_order_id": { - "type": "integer", - "description": "Last order id if present", - }, - }, - }, - 400: { - "oneOf": [ - { - "type": "object", - "properties": { - "active_order_id": { - "type": "string", - "description": "Order id the robot is a maker/taker of", - }, - "nickname": { - "type": "string", - "description": "Username (Robot name)", - }, - "bad_request": { - "type": "string", - "description": "Reason for the failure", - "default": "You are already logged in as {nickname} and have an active order", - }, - }, - "description": "Response when you already authenticated and have an order", - }, - { - "type": "object", - "properties": { - "bad_request": { - "type": "string", - "description": "Reason for the failure", - }, - }, - }, - ] - }, - 403: { - "type": "object", - "properties": { - "bad_request": { - "type": "string", - "description": "Reason for the failure", - "default": "Enter a different token", - }, - "found": { - "type": "string", - "default": "Bad luck, this nickname is taken", - }, - }, - }, - }, - "examples": [ - OpenApiExample( - "Successfuly created user", - value={ - "token_shannon_entropy": 0.7714559798089662, - "token_bits_entropy": 169.21582985307933, - "nickname": "StackerMan420", - "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n......\n......", - "encrypted_private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n\n......\n......", - "wants_stealth": False, - }, - status_codes=[201], - ), - OpenApiExample( - "Already authenticated and have an order", - value={ - "active_order_id": "42069", - "nickname": "StackerMan210", - "bad_request": "You are already logged in as {nickname} and have an active order", - }, - status_codes=[400], - ), - OpenApiExample( - "When required token entropy not met", - value={"bad_request": "The token does not have enough entropy"}, - status_codes=[400], - ), - OpenApiExample( - "Invalid PGP public key provided", - value={"bad_request": "Your PGP public key does not seem valid"}, - status_codes=[400], - ), - ], - } - - delete = { - "summary": "Delete user", - "description": textwrap.dedent( - """ - Delete a Robot. Deleting a robot is not allowed if the robot has an active order, has had completed trades or was created more than 30 mins ago. - Mainly used on the frontend to "Generate new Robot" without flooding the DB with discarded robots. - """ - ), - "responses": { - 403: {}, - 400: { - "type": "object", - "properties": { - "bad_request": { - "type": "string", - "description": "Reason for the failure", - }, - }, - }, - 301: { - "type": "object", - "properties": { - "user_deleted": { - "type": "string", - "default": "User deleted permanently", - }, - }, - }, - }, - } - - class BookViewSchema: get = { "summary": "Get public orders", @@ -627,8 +392,6 @@ class RobotViewSchema: "summary": "Get robot info", "description": textwrap.dedent( """ - DEPRECATED: Use `/robot` GET. - Get robot info 🤖 An authenticated request (has the token's sha256 hash encoded as base 91 in the Authorization header) will be diff --git a/api/serializers.py b/api/serializers.py index 82e0c408..0aeb7e1a 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -527,64 +527,6 @@ class UpdateOrderSerializer(serializers.Serializer): ) -class UserGenSerializer(serializers.Serializer): - # Mandatory fields - token_sha256 = serializers.CharField( - min_length=64, - max_length=64, - allow_null=False, - allow_blank=False, - required=True, - help_text="SHA256 of user secret", - ) - # Optional fields - # (PGP keys are mandatory for new users, but optional for logins) - public_key = serializers.CharField( - max_length=2000, - allow_null=False, - allow_blank=False, - required=False, - help_text="Armored ASCII PGP public key block", - ) - encrypted_private_key = serializers.CharField( - max_length=2000, - allow_null=False, - allow_blank=False, - required=False, - help_text="Armored ASCII PGP encrypted private key block", - ) - - ref_code = serializers.CharField( - max_length=30, - allow_null=True, - allow_blank=True, - required=False, - default=None, - help_text="Referal code", - ) - counts = serializers.ListField( - child=serializers.IntegerField(), - allow_null=True, - required=False, - default=None, - help_text="Counts of the unique characters in the token", - ) - length = serializers.IntegerField( - allow_null=True, - default=None, - required=False, - min_value=1, - help_text="Length of the token", - ) - unique_values = serializers.IntegerField( - allow_null=True, - default=None, - required=False, - min_value=1, - help_text="Number of unique values in the token", - ) - - class ClaimRewardSerializer(serializers.Serializer): invoice = serializers.CharField( max_length=2000, diff --git a/api/views.py b/api/views.py index 0c569ea1..ad5bc0ea 100644 --- a/api/views.py +++ b/api/views.py @@ -1,26 +1,18 @@ -import hashlib from datetime import datetime, timedelta -from math import log2 from pathlib import Path from decouple import config from django.conf import settings -from django.contrib.auth import authenticate, login, logout from django.contrib.auth.models import User from django.db.models import Q, Sum from django.utils import timezone from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets -from rest_framework.authentication import ( - SessionAuthentication, # DEPRECATE session authentication -) from rest_framework.authentication import TokenAuthentication from rest_framework.generics import CreateAPIView, ListAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from robohash import Robohash -from scipy.stats import entropy from api.logics import Logics from api.models import Currency, LNPayment, MarketTick, OnchainPayment, Order @@ -37,7 +29,6 @@ from api.oas_schemas import ( RobotViewSchema, StealthViewSchema, TickViewSchema, - UserViewSchema, ) from api.serializers import ( ClaimRewardSerializer, @@ -49,7 +40,6 @@ from api.serializers import ( StealthSerializer, TickSerializer, UpdateOrderSerializer, - UserGenSerializer, ) from api.utils import ( compute_avg_premium, @@ -57,14 +47,11 @@ from api.utils import ( get_cln_version, get_lnd_version, get_robosats_commit, - validate_pgp_keys, verify_signed_message, ) from chat.models import Message from control.models import AccountingDay, BalanceLog -from .nick_generator.nick_generator import NickGenerator - EXP_MAKER_BOND_INVOICE = int(config("EXP_MAKER_BOND_INVOICE")) RETRY_TIME = int(config("RETRY_TIME")) PUBLIC_DURATION = 60 * 60 * int(config("DEFAULT_PUBLIC_ORDER_DURATION")) - 1 @@ -79,7 +66,7 @@ avatar_path.mkdir(parents=True, exist_ok=True) class MakerView(CreateAPIView): serializer_class = MakeOrderSerializer - authentication_classes = [TokenAuthentication, SessionAuthentication] + authentication_classes = [TokenAuthentication] permission_classes = [IsAuthenticated] @extend_schema(**MakerViewSchema.post) @@ -190,7 +177,7 @@ class MakerView(CreateAPIView): class OrderView(viewsets.ViewSet): - authentication_classes = [TokenAuthentication, SessionAuthentication] + authentication_classes = [TokenAuthentication] permission_classes = [IsAuthenticated] serializer_class = UpdateOrderSerializer lookup_url_kwarg = "order_id" @@ -652,7 +639,7 @@ class OrderView(viewsets.ViewSet): class RobotView(APIView): - authentication_classes = [TokenAuthentication, SessionAuthentication] + authentication_classes = [TokenAuthentication] permission_classes = [IsAuthenticated] @extend_schema(**RobotViewSchema.get) @@ -692,208 +679,6 @@ class RobotView(APIView): return Response(context, status=status.HTTP_200_OK) -class UserView(APIView): - """ - Deprecated. UserView will be completely replaced by the smaller RobotView in - combination with the RobotTokenSHA256 middleware (on-the-fly robot generation) - """ - - NickGen = NickGenerator( - lang="English", use_adv=False, use_adj=True, use_noun=True, max_num=999 - ) - - serializer_class = UserGenSerializer - - @extend_schema(**UserViewSchema.post) - def post(self, request, format=None): - """ - Get a new user derived from a high entropy token - - - Request has a hash of a high-entropy token - - Request includes pubKey and encrypted privKey - - Generates new nickname and avatar. - - Creates login credentials (new User object) - - Response with Avatar, Nickname, pubKey, privKey. - """ - context = {} - serializer = self.serializer_class(data=request.data) - - # Return bad request if serializer is not valid - if not serializer.is_valid(): - context = {"bad_request": "Invalid serializer"} - return Response(context, status=status.HTTP_400_BAD_REQUEST) - - # The new way. The token is never sent. Only its SHA256 - token_sha256 = serializer.data.get("token_sha256") - public_key = serializer.data.get("public_key") - encrypted_private_key = serializer.data.get("encrypted_private_key") - - # Now the server only receives a hash of the token. So server trusts the client - # with computing length, counts and unique_values to confirm the high entropy of the token - # In any case, it is up to the client if they want to create a bad high entropy token. - - # Submitting the three params needed to compute token entropy is not mandatory - # If not submitted, avatars can be created with garbage entropy token. Frontend will always submit them. - try: - unique_values = serializer.data.get("unique_values") - counts = serializer.data.get("counts") - length = serializer.data.get("length") - - shannon_entropy = entropy(counts, base=62) - bits_entropy = log2(unique_values**length) - - # Payload - context = { - "token_shannon_entropy": shannon_entropy, - "token_bits_entropy": bits_entropy, - } - - # Deny user gen if entropy below 128 bits or 0.7 shannon heterogeneity - if bits_entropy < 128 or shannon_entropy < 0.7: - context["bad_request"] = "The token does not have enough entropy" - return Response(context, status=status.HTTP_400_BAD_REQUEST) - except Exception: - pass - - # Hash the token_sha256, only 1 iteration. (this is the second SHA256 of the user token, aka RoboSats ID) - hash = hashlib.sha256(token_sha256.encode("utf-8")).hexdigest() - - # Generate nickname deterministically - nickname = self.NickGen.short_from_SHA256(hash, max_length=18)[0] - context["nickname"] = nickname - - # Generate avatar - rh = Robohash(hash) - rh.assemble(roboset="set1", bgset="any") # for backgrounds ON - - # Does not replace image if existing (avoid re-avatar in case of nick collusion) - # Deprecate "png" and keep "webp" only after v0.4.4 - image_path = avatar_path.joinpath(nickname + ".webp") - if not image_path.exists(): - with open(image_path, "wb") as f: - rh.img.save(f, format="WEBP", quality=80) - - image_small_path = avatar_path.joinpath(nickname + ".small.webp") - with open(image_small_path, "wb") as f: - resized_img = rh.img.resize((80, 80)) - resized_img.save(f, format="WEBP", quality=80) - - png_path = avatar_path.joinpath(nickname + ".png") - with open(png_path, "wb") as f: - rh.img.save(f, format="png", optimize=True) - - # Create new credentials and login if nickname is new - if len(User.objects.filter(username=nickname)) == 0: - if not public_key or not encrypted_private_key: - context[ - "bad_request" - ] = "Must provide valid 'pub' and 'enc_priv' PGP keys" - return Response(context, status.HTTP_400_BAD_REQUEST) - ( - valid, - bad_keys_context, - public_key, - encrypted_private_key, - ) = validate_pgp_keys(public_key, encrypted_private_key) - if not valid: - return Response(bad_keys_context, status.HTTP_400_BAD_REQUEST) - - User.objects.create_user( - username=nickname, password=token_sha256, is_staff=False - ) - user = authenticate(request, username=nickname, password=token_sha256) - login(request, user) - - user.robot.avatar = "static/assets/avatars/" + nickname + ".webp" - - # Noticed some PGP keys replaced at re-login. Should not happen. - # Let's implement this sanity check "If robot has not keys..." - if not user.robot.public_key: - user.robot.public_key = public_key - if not user.robot.encrypted_private_key: - user.robot.encrypted_private_key = encrypted_private_key - - user.robot.save() - - context = {**context, **Telegram.get_context(user)} - context["public_key"] = user.robot.public_key - context["encrypted_private_key"] = user.robot.encrypted_private_key - context["wants_stealth"] = user.robot.wants_stealth - return Response(context, status=status.HTTP_201_CREATED) - - # log in user and return pub/priv keys if existing - else: - user = authenticate(request, username=nickname, password=token_sha256) - if user is not None: - login(request, user) - context["public_key"] = user.robot.public_key - context["encrypted_private_key"] = user.robot.encrypted_private_key - context["earned_rewards"] = user.robot.earned_rewards - context["wants_stealth"] = user.robot.wants_stealth - - # Adds/generate telegram token and whether it is enabled - context = {**context, **Telegram.get_context(user)} - - # return active order or last made order if any - has_no_active_order, _, order = Logics.validate_already_maker_or_taker( - request.user - ) - if not has_no_active_order: - context["active_order_id"] = order.id - else: - last_order = Order.objects.filter( - Q(maker=request.user) | Q(taker=request.user) - ).last() - if last_order: - context["last_order_id"] = last_order.id - - # Sends the welcome back message. - context["found"] = "We found your Robot avatar. Welcome back!" - return Response(context, status=status.HTTP_202_ACCEPTED) - else: - # It is unlikely, but maybe the nickname is taken (1 in 20 Billion chance) - context["found"] = "Bad luck, this nickname is taken" - context["bad_request"] = "Enter a different token" - return Response(context, status.HTTP_403_FORBIDDEN) - - @extend_schema(**UserViewSchema.delete) - def delete(self, request): - """Pressing "give me another" deletes the logged in user""" - user = request.user - if not user.is_authenticated: - return Response(status.HTTP_403_FORBIDDEN) - - # Only delete if user life is shorter than 30 minutes. Helps to avoid deleting users by mistake - if user.date_joined < (timezone.now() - timedelta(minutes=30)): - return Response(status.HTTP_400_BAD_REQUEST) - - # Check if it is not a maker or taker! - not_participant, _, _ = Logics.validate_already_maker_or_taker(user) - if not not_participant: - return Response( - { - "bad_request": "Maybe a mistake? User cannot be deleted while he is part of an order" - }, - status.HTTP_400_BAD_REQUEST, - ) - # Check if has already a robot with - if user.robot.total_contracts > 0: - return Response( - { - "bad_request": "Maybe a mistake? User cannot be deleted as it has completed trades" - }, - status.HTTP_400_BAD_REQUEST, - ) - - logout(request) - user.delete() - return Response( - {"user_deleted": "User deleted permanently"}, - status.HTTP_301_MOVED_PERMANENTLY, - ) - - class BookView(ListAPIView): serializer_class = OrderPublicSerializer queryset = Order.objects.filter(status=Order.Status.PUB) @@ -1016,7 +801,7 @@ class InfoView(ListAPIView): class RewardView(CreateAPIView): - authentication_classes = [TokenAuthentication, SessionAuthentication] + authentication_classes = [TokenAuthentication] permission_classes = [IsAuthenticated] serializer_class = ClaimRewardSerializer @@ -1156,7 +941,7 @@ class HistoricalView(ListAPIView): class StealthView(APIView): - authentication_classes = [TokenAuthentication, SessionAuthentication] + authentication_classes = [TokenAuthentication] permission_classes = [IsAuthenticated] serializer_class = StealthSerializer diff --git a/chat/views.py b/chat/views.py index 9f0839f1..2b6a230c 100644 --- a/chat/views.py +++ b/chat/views.py @@ -5,9 +5,6 @@ from channels.layers import get_channel_layer from django.contrib.auth.models import User from django.utils import timezone from rest_framework import status, viewsets -from rest_framework.authentication import ( - SessionAuthentication, # DEPRECATE session Authentication -) from rest_framework.authentication import TokenAuthentication from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -20,7 +17,7 @@ from chat.serializers import ChatSerializer, PostMessageSerializer class ChatView(viewsets.ViewSet): serializer_class = PostMessageSerializer - authentication_classes = [TokenAuthentication, SessionAuthentication] + authentication_classes = [TokenAuthentication] permission_classes = [IsAuthenticated] lookup_url_kwarg = ["order_id", "offset"] diff --git a/requirements.txt b/requirements.txt index c71ba3bc..9b836949 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,6 @@ python-decouple==3.8 requests==2.31.0 ring==0.10.1 git+https://github.com/RoboSats/Robohash.git -scipy==1.10.1 gunicorn==21.2.0 psycopg2==2.9.7 SQLAlchemy==2.0.16 diff --git a/robosats/middleware.py b/robosats/middleware.py index fbd9878c..5a669ec2 100644 --- a/robosats/middleware.py +++ b/robosats/middleware.py @@ -5,7 +5,6 @@ from channels.db import database_sync_to_async from channels.middleware import BaseMiddleware from django.conf import settings from django.contrib.auth.models import AnonymousUser, User, update_last_login -from django.db import IntegrityError from rest_framework.authtoken.models import Token from rest_framework.exceptions import AuthenticationFailed from robohash import Robohash @@ -97,15 +96,7 @@ class RobotTokenSHA256AuthenticationMiddleWare: # Generate nickname deterministically nickname = NickGen.short_from_SHA256(hash, max_length=18)[0] - # DEPRECATE. Using Try and Except only as a temporary measure. - # This will allow existing robots to be added upgraded with a token.key - # After v0.5.0, only the following should remain - # `user = User.objects.create_user(username=nickname, password=None)` - try: - user = User.objects.create_user(username=nickname, password=None) - except IntegrityError: - # UNIQUE constrain failed, user exist. Get it. - user = User.objects.get(username=nickname) + user = User.objects.create_user(username=nickname, password=None) # Django rest_framework authtokens are limited to 40 characters. # We use base91 so we can store the full entropy in the field.