Skip to content

Commit

Permalink
Introducing Reset Password flow (uselotus#221)
Browse files Browse the repository at this point in the history
Co-authored-by: Diego Escobedo <[email protected]>
  • Loading branch information
MarianoArg and diego-escobedo authored Oct 14, 2022
1 parent bc09c76 commit 276cfe0
Show file tree
Hide file tree
Showing 14 changed files with 439 additions and 50 deletions.
1 change: 1 addition & 0 deletions backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ social-auth-app-django = "*"
model-bakery = "*"
django-simple-history = "<3.1.1"
django-rest-knox = "*"
django-anymail = {extras = ["mailgun"], version = "*"}

[dev-packages]
platformdirs = "*"
Expand Down
47 changes: 29 additions & 18 deletions backend/Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 28 additions & 1 deletion backend/lotus/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
posthog.disabled = True
POSTHOG_PERSON = hash(SECRET_KEY) if SELF_HOSTED else None

if DEBUG:
if DEBUG or SELF_HOSTED:
ALLOWED_HOSTS = ["*"]
else:
ALLOWED_HOSTS = [
Expand All @@ -105,7 +105,34 @@
"drf_spectacular",
"simple_history",
"knox",
"anymail",
]

ANYMAIL = {
"MAILGUN_API_KEY": os.environ.get("MAILGUN_API_KEY"),
"MAILGUN_DOMAIN": os.environ.get("MAILGUN_DOMAIN"),
"MAILGUN_PUBLIC_KEY": os.environ.get("MAILGUN_PUBLIC_KEY"),
"MAILGUN_SMTP_LOGIN": os.environ.get("MAILGUN_SMTP_LOGIN"),
"MAILGUN_SMTP_PASSWORD": os.environ.get("MAILGUN_SMTP_PASSWORD"),
"MAILGUN_SMTP_PORT": os.environ.get("MAILGUN_SMTP_PORT"),
"MAILGUN_SMTP_SERVER": os.environ.get("MAILGUN_SMTP_SERVER"),
}

if DEBUG:
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
APP_URL = "http://localhost:8000"
elif SELF_HOSTED:
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
APP_URL = "http://localhost"
else:
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend"
APP_URL = "https://app.uselotus.io"


DEFAULT_FROM_EMAIL = "[email protected]"
SECURITY_FROM_EMAIL = "[email protected]"
SERVER_EMAIL = "[email protected]" # ditto (default from-email for Django errors)

if PROFILER_ENABLED:
INSTALLED_APPS.append("silk")

Expand Down
10 changes: 10 additions & 0 deletions backend/lotus/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@
# path("api/whoami/", auth_views.whoami_view, name="api-whoami"),
path("api/register/", auth_views.RegisterView.as_view(), name="register"),
# path("csrf/", csrf_exempt(auth_views.csrf), name="csrf"),
path(
"api/user/password/reset/init/",
auth_views.InitResetPasswordView.as_view(),
name="reset-password",
),
path(
"api/user/password/reset/",
auth_views.ResetPasswordView.as_view(),
name="set-new-password",
),
]

if PROFILER_ENABLED:
Expand Down
7 changes: 7 additions & 0 deletions backend/metering_billing/serializers/internal_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,10 @@ class ExperimentalToActiveRequestSerializer(serializers.Serializer):
slug_field="billing_plan_id",
read_only=False,
)


class EmailSerializer(serializers.Serializer):
email = serializers.EmailField()

class Meta:
fields = ("email",)
90 changes: 90 additions & 0 deletions backend/metering_billing/services/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.contrib.auth.tokens import default_token_generator
from django.core import exceptions as django_exceptions
from django.core.mail import BadHeaderError, EmailMultiAlternatives
from rest_framework.authtoken.models import Token


class UserAlreadyExists(Exception):
pass


class UserService(object):
def __init__(self, User):
self.User = User

def get(self, user_id=None, email=None):
try:
if user_id:
return self.User.objects.get(id=user_id)
if email:
return self.User.objects.get(email=email.lower())
except User.DoesNotExist:
return None

def get_or_create_token(self, user):
token, _ = Token.objects.get_or_create(user=user)
return token

def send_reset_password_email(self, reset_url, to):
subject = "Reset Your Password"
body = f"Use this link to reset your password: {reset_url}"
from_email = f"Lotus <{settings.SECURITY_FROM_EMAIL}>"
html = """
<p>Please <a href={url}>reset your password</a></p>""".format(
url=reset_url
)
msg = EmailMultiAlternatives(subject, body, from_email, [to])
msg.attach_alternative(html, "text/html")
msg.tags = ["reset_password"]
msg.track_clicks = True
try:
msg.send()
except BadHeaderError:
print("Invalid header found.")
return False

return True

def init_reset_password(self, email):
"""Given an email, creates a token and emails the user a reset link."""

# For security reasons, we don't error if the user doesn't exist
# since bad-actors cannot deduce which users exist
user = None
user = self.get(email=email)
if not user:
return False

token = default_token_generator.make_token(user)
path = "set-new-password?token=%s&userId=%s" % (token, user.id)
password_reset_url = "%s/%s" % (settings.APP_URL, path)

self.send_reset_password_email(reset_url=password_reset_url, to=email)

return True

def reset_password(self, user_id, raw_password, token):
"""Given a valid token, update user's password."""
user = self.get(user_id=user_id)
if not user:
return False

if not default_token_generator.check_token(user, token):
print(
{
"message": "User submitted invalid reset password token",
"userId": user.id,
}
)
return False

user.set_password(raw_password)
user.save()
return user


User = get_user_model()
user_service = UserService(User=User)
46 changes: 45 additions & 1 deletion backend/metering_billing/views/auth_views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import json

import posthog
from django.conf import settings
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.tokens import default_token_generator
from django.http import JsonResponse
from django.views.decorators.csrf import ensure_csrf_cookie
from drf_spectacular.utils import extend_schema, inline_serializer
Expand All @@ -12,8 +14,10 @@
from metering_billing.models import Organization, User
from metering_billing.serializers.internal_serializers import *
from metering_billing.serializers.model_serializers import *
from metering_billing.services.user import user_service
from rest_framework import status
from rest_framework.authentication import BasicAuthentication
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
Expand Down Expand Up @@ -73,7 +77,6 @@ def post(self, request, format=None):

class LogoutView(LogoutViewMixin, APIView):
def post(self, request, format=None):
print(request.user)
if not request.user.is_authenticated:
return JsonResponse(
{"detail": "You're not logged in."}, status=status.HTTP_400_BAD_REQUEST
Expand All @@ -88,6 +91,47 @@ def post(self, request, format=None):
return JsonResponse({"detail": "Successfully logged out."})


class InitResetPasswordView(APIView):
authentication_classes = []
permission_classes = [AllowAny]

def post(self, request, *args, **kwargs):
serializer = EmailSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
email = serializer.validated_data["email"]
user_service.init_reset_password(email=email)

return JsonResponse({"email": email})


class ResetPasswordView(APIView):
authentication_classes = []
permission_classes = [AllowAny]

def post(self, request, *args, **kwargs):
"""Verifies the token and resets the password."""
user_id = request.data.get("userId", None)
raw_password = request.data.get("password", None)
token = request.data.get("token", None)

if not (user_id and raw_password and token):
raise JsonResponse(status=status.HTTP_400_BAD_REQUEST)

user = user_service.reset_password(
user_id=user_id, raw_password=raw_password, token=token
)
if user:
login(request, user)
return Response(
{
"detail": "Successfully changed password.",
"token": AuthToken.objects.create(user)[1],
}
)

raise PermissionDenied({"message": "This reset link is no longer valid"})


@ensure_csrf_cookie
def session_view(request):

Expand Down
12 changes: 0 additions & 12 deletions env/.env.dev.example

This file was deleted.

Loading

0 comments on commit 276cfe0

Please sign in to comment.