Skip to content

Commit

Permalink
OTP MFA! (ZcashFoundation#253)
Browse files Browse the repository at this point in the history
  • Loading branch information
skyl authored Jan 14, 2024
1 parent 45da8da commit f4b0435
Show file tree
Hide file tree
Showing 43 changed files with 1,251 additions and 437 deletions.
2 changes: 1 addition & 1 deletion WORKSPACE
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ load(
container_pull(
name = "py3base",
# https://console.cloud.google.com/gcr/images/free2z/GLOBAL/py3base
digest = "sha256:9b2fbc4f068e8b69b3aff2272b606678849e355372c7725288bfbb7420d95c9b",
digest = "sha256:8e20fbe8fd10031412c6a893139a529dfa7ba11f2571d7ee509ada347f31f47d",
registry = "gcr.io",
repository = "free2z/py3base",
)
Expand Down
9 changes: 9 additions & 0 deletions docs/gcp/recaptcha.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@

Invisible captcha

https://www.google.com/recaptcha/admin/site/693074980/settings

https://developers.google.com/recaptcha/intro

https://github.com/dozoisch/react-google-recaptcha

Binary file modified k8s/free2z/secret.yaml.enc
Binary file not shown.
12 changes: 9 additions & 3 deletions py/dj/apps/emails/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def create(self, validated_data):


class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(required=True)
old_password = serializers.CharField(required=False)
new_password = serializers.CharField(required=True)

def validate_new_password(self, value):
Expand All @@ -26,6 +26,12 @@ def validate_new_password(self, value):

def validate_old_password(self, value):
user = self.context['request'].user
if not user.check_password(value):
raise serializers.ValidationError("Incorrect")
if user.has_usable_password() and not user.check_password(value):
raise serializers.ValidationError("Incorrect password.")
return value

def validate(self, data):
user = self.context['request'].user
if user.has_usable_password() and 'old_password' not in data:
raise serializers.ValidationError({"old_password": "This field is required."})
return data
3 changes: 2 additions & 1 deletion py/dj/apps/emails/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
ConfirmEmailView, AddEmailView, EmailStatusView, ResendEmailView,
ToggleEmailPublicView, ToggleEmailNotificationsView,
DeleteEmailView, OptOutEmailView, ChangePasswordView, RequestResetPasswordView,
ResetPasswordAPIView
ResetPasswordAPIView, PasswordIsSetView,
)

urlpatterns = [
path('password-is-set', PasswordIsSetView.as_view(), name='password-is-set'),
path('do-reset-password', ResetPasswordAPIView.as_view(), name='reset-password-api'),
path('reset-password', RequestResetPasswordView.as_view(), name='reset-password'),
path('change-password', ChangePasswordView.as_view(), name='change-password'),
Expand Down
17 changes: 13 additions & 4 deletions py/dj/apps/emails/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ def send_password_reset_email(email_instance, host):
return True


class PasswordIsSetView(APIView):
permission_classes = [IsAuthenticated]

def get(self, request:Request, *args, **kwargs):
user = request.user
return Response({'password_set': user.password_set}, status=status.HTTP_200_OK)


class ResendEmailView(APIView):

def post(self, request:Request, *args, **kwargs):
Expand Down Expand Up @@ -326,14 +334,14 @@ class ChangePasswordView(APIView):
def post(self, request, *args, **kwargs):
serializer = ChangePasswordSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
new_password = serializer.validated_data['new_password']
user = request.user
user.set_password(new_password)
user.set_password(serializer.validated_data['new_password'])
user.save()
u = authenticate(username=user.username, password=new_password)
# Re-authenticate and log the user in with the new password
u = authenticate(username=user.username, password=serializer.validated_data['new_password'])
login(request, u)

return Response({'status': 'password changed'}, status=status.HTTP_200_OK)
return Response(status=status.HTTP_200_OK)

return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

Expand All @@ -357,6 +365,7 @@ def post(self, request):


class ResetPasswordAPIView(APIView):

def post(self, request):
token = request.data.get('token')
new_password = request.data.get('password')
Expand Down
18 changes: 18 additions & 0 deletions py/dj/apps/g12f/migrations/0080_creator_password_set.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2024-01-13 23:01

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('g12f', '0079_remove_zpage_thumbnail_url'),
]

operations = [
migrations.AddField(
model_name='creator',
name='password_set',
field=models.BooleanField(default=True),
),
]
28 changes: 28 additions & 0 deletions py/dj/apps/g12f/migrations/0081_set_unusable_passwords.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 3.2.12 on 2024-01-13 23:04

from django.db import migrations
from django.contrib.auth.hashers import make_password


def set_unusable_passwords(apps, schema_editor):
Creator = apps.get_model('g12f', 'Creator')

def set_unusable_password(creator):
# Mimic the behavior of your set_unusable_password method
creator.password = make_password(None)
creator.password_set = False # Assuming you have this field in your model

for c in Creator.objects.filter(password=""):
set_unusable_password(c)
c.save(update_fields=['password', 'password_set'])


class Migration(migrations.Migration):

dependencies = [
('g12f', '0080_creator_password_set'),
]

operations = [
migrations.RunPython(set_unusable_passwords),
]
10 changes: 10 additions & 0 deletions py/dj/apps/g12f/models/creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,21 @@ class Creator(AbstractUser):
)
strikes = models.PositiveSmallIntegerField(default=0)

password_set = models.BooleanField(default=True)

class Meta:
verbose_name = "Creator"
verbose_name_plural = "Creators"
ordering = ["-total"]

def set_unusable_password(self):
super().set_unusable_password()
self.password_set = False

def set_password(self, raw_password):
super().set_password(raw_password)
self.password_set = True

def get_thumbnail(self) -> str:
p = "https://free2z.com"

Expand Down
19 changes: 19 additions & 0 deletions py/dj/apps/g12f/serializers/creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
from dj.apps.uploads.models import GenericFile
from dj.apps.uploads.serializers import FeaturedImageSerializer

from dj.apps.otp.models import OTPSecret
from dj.apps.otp.utils import verify_token

UserModel = get_user_model()


Expand Down Expand Up @@ -257,10 +260,14 @@ class Meta:
class CreatorLoginSerializer(LoginSerializer):
email = None
captcha_result = serializers.CharField()
otp_token = serializers.CharField(required=False, write_only=True)

def validate(self, attrs):
# captcha_result = "foobar"
captcha_result = attrs.pop('captcha_result', '')
otp_token = attrs.pop('otp_token', None) # Pop the OTP token from attrs
# print("=========================================")
# print('otp_token', otp_token)

r = requests.post(
'https://www.google.com/recaptcha/api/siteverify',
Expand All @@ -274,6 +281,18 @@ def validate(self, attrs):
if not (r.json().get('success') or error_code == 'timeout-or-duplicate'):
raise ValidationError("Invalid captcha")

user = self.get_auth_user_using_orm(
attrs.get('username'), attrs.get('email'), attrs.get('password'))

# The whole thing depends on there being a user. HRM
# print('--------------++++++++++++++')
# print('user', user)
if user:
# If the user has OTP setup, validate the OTP token
otp_secret = OTPSecret.objects.filter(user=user, is_active=True).first()
if otp_secret and not verify_token(otp_secret.secret, otp_token):
raise ValidationError("Invalid OTP token")

return super().validate(attrs)

def get_auth_user_using_orm(self, username, email, password):
Expand Down
3 changes: 0 additions & 3 deletions py/dj/apps/g12f/views/creator.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from decimal import Decimal

import requests
# from django.core.exceptions import ValidationError
from django.conf import settings
Expand All @@ -9,7 +7,6 @@
from rest_framework import status, viewsets, mixins
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from dj_rest_auth.views import UserDetailsView

from dj.apps.g12f.models import Creator, zPage
Expand Down
Empty file added py/dj/apps/otp/__init__.py
Empty file.
8 changes: 8 additions & 0 deletions py/dj/apps/otp/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.contrib import admin
from .models import OTPSecret


@admin.register(OTPSecret)
class OTPSecretAdmin(admin.ModelAdmin):
list_display = ['user', 'created_at', 'last_used_at']
search_fields = ['user__username']
6 changes: 6 additions & 0 deletions py/dj/apps/otp/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class OtpConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'dj.apps.otp'
27 changes: 27 additions & 0 deletions py/dj/apps/otp/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 3.2.12 on 2024-01-11 05:15

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='OTPSecret',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('secret', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
18 changes: 18 additions & 0 deletions py/dj/apps/otp/migrations/0002_otpsecret_is_active.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2024-01-12 00:26

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('otp', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='otpsecret',
name='is_active',
field=models.BooleanField(default=False),
),
]
Empty file.
15 changes: 15 additions & 0 deletions py/dj/apps/otp/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from django.db import models
from django.contrib.auth import get_user_model

User = get_user_model()


class OTPSecret(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
secret = models.CharField(max_length=255)
is_active = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True, blank=True)

def __str__(self):
return f"OTP Secret for {self.user.username}"
6 changes: 6 additions & 0 deletions py/dj/apps/otp/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from rest_framework import serializers


class VerifyOTPSerializer(serializers.Serializer):
token = serializers.CharField(max_length=10)

11 changes: 11 additions & 0 deletions py/dj/apps/otp/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.urls import path
from .views import SetupOTPView, RetrieveOTPStatusView, EnableOTPView, DisableOTPView, LoginWithOTPView


urlpatterns = [
path('status/', RetrieveOTPStatusView.as_view(), name='mfa_status'),
path('setup/', SetupOTPView.as_view(), name='setup_mfa'),
path('enable/', EnableOTPView.as_view(), name='enable_mfa'),
path('disable/', DisableOTPView.as_view(), name='disable_mfa'),
path('login/', LoginWithOTPView.as_view(), name='login_with_mfa'),
]
18 changes: 18 additions & 0 deletions py/dj/apps/otp/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@

import pyotp

def generate_secret():
"""
Generates a secure random base32-encoded secret key for TOTP authentication.
"""
return pyotp.random_base32()


def verify_token(secret, token):
"""
Verifies a TOTP token.
:param secret: The user's secret key (base32 encoded).
:param token: The OTP token to verify.
"""
totp = pyotp.TOTP(secret)
return totp.verify(token)
Loading

0 comments on commit f4b0435

Please sign in to comment.