Delete user view, session auth and png avatars (#588)

This commit is contained in:
Reckless_Satoshi 2023-08-14 14:21:12 +00:00 committed by GitHub
parent 13a1fac202
commit ca3f7cb222
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 8 additions and 531 deletions

View File

@ -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,
)

View File

@ -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 <key-id | email | name>
```
and it's private key can be exported in ascii armored format with:
```shell
gpg --export-secret-keys --armor <key-id | email | name>
```
"""
),
"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_token>",
},
"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

View File

@ -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,

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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)
# Django rest_framework authtokens are limited to 40 characters.
# We use base91 so we can store the full entropy in the field.