Fix password reset flow so forgot-password emails work (#1)
Replace the broken csrf_exempt handler (responses never returned) with a working DRF endpoint, use FRONTEND_BASE_URL for reset links, and harden set-password against reuse and short passwords.
This commit is contained in:
@@ -74,7 +74,10 @@ class CustomUser(AbstractUser):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_set_password_url(self):
|
def get_set_password_url(self):
|
||||||
return f"https://chat.aimloperations.com/set_password?slug={self.slug}"
|
from django.conf import settings
|
||||||
|
|
||||||
|
base = settings.FRONTEND_BASE_URL.rstrip("/")
|
||||||
|
return f"{base}/set_password/?slug={self.slug}"
|
||||||
|
|
||||||
|
|
||||||
FEEDBACK_CHOICE = (
|
FEEDBACK_CHOICE = (
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class CompanyAndUserTestCase(TestCase):
|
|||||||
user = make_user(email="person@example.com")
|
user = make_user(email="person@example.com")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
user.get_set_password_url(),
|
user.get_set_password_url(),
|
||||||
f"https://chat.aimloperations.com/set_password?slug={user.slug}",
|
f"http://localhost:3000/set_password/?slug={user.slug}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_user_defaults(self):
|
def test_user_defaults(self):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from unittest import mock
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
@@ -179,6 +180,8 @@ class SetPasswordTestCase(APITestCase):
|
|||||||
self.assertEqual(self.client.get(url).status_code, status.HTTP_200_OK)
|
self.assertEqual(self.client.get(url).status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_post_sets_password(self):
|
def test_post_sets_password(self):
|
||||||
|
self.user.set_unusable_password()
|
||||||
|
self.user.save()
|
||||||
url = reverse("set_password", kwargs={"slug": self.user.slug})
|
url = reverse("set_password", kwargs={"slug": self.user.slug})
|
||||||
|
|
||||||
response = self.client.post(url, {"password": "brandnewpass"}, format="json")
|
response = self.client.post(url, {"password": "brandnewpass"}, format="json")
|
||||||
@@ -187,6 +190,84 @@ class SetPasswordTestCase(APITestCase):
|
|||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
self.assertTrue(self.user.check_password("brandnewpass"))
|
self.assertTrue(self.user.check_password("brandnewpass"))
|
||||||
|
|
||||||
|
def test_post_rejects_user_that_already_has_a_password(self):
|
||||||
|
url = reverse("set_password", kwargs={"slug": self.user.slug})
|
||||||
|
|
||||||
|
response = self.client.post(url, {"password": "brandnewpass"}, format="json")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_post_rejects_short_password(self):
|
||||||
|
self.user.set_unusable_password()
|
||||||
|
self.user.save()
|
||||||
|
url = reverse("set_password", kwargs={"slug": self.user.slug})
|
||||||
|
|
||||||
|
response = self.client.post(url, {"password": "short"}, format="json")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordTestCase(APITestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = make_user(email="person@example.com", password="testpass123")
|
||||||
|
self.url = reverse("reset_password")
|
||||||
|
|
||||||
|
def _mock_captcha(self, success=True, score=None):
|
||||||
|
result = {"success": success}
|
||||||
|
if score is not None:
|
||||||
|
result["score"] = score
|
||||||
|
return mock.patch(
|
||||||
|
"chat_backend.views.requests.post",
|
||||||
|
return_value=mock.Mock(json=mock.Mock(return_value=result)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reset_invalidates_password_and_sends_email(self):
|
||||||
|
with self._mock_captcha(success=True):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"email": self.user.email, "recaptchaToken": "token"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertFalse(self.user.has_usable_password())
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertIn(self.user.slug, mail.outbox[0].body)
|
||||||
|
self.assertEqual(mail.outbox[0].to, [self.user.email])
|
||||||
|
|
||||||
|
def test_reset_unknown_email_still_returns_ok(self):
|
||||||
|
with self._mock_captcha(success=True):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"email": "missing@example.com", "recaptchaToken": "token"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertTrue(self.user.has_usable_password())
|
||||||
|
|
||||||
|
def test_reset_rejects_failed_captcha(self):
|
||||||
|
with self._mock_captcha(success=False):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"email": self.user.email, "recaptchaToken": "bad"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
|
def test_reset_rejects_missing_email(self):
|
||||||
|
with self._mock_captcha(success=True):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, {"recaptchaToken": "token"}, format="json"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
class AcknowledgeTermsOfServiceTestCase(APITestCase):
|
class AcknowledgeTermsOfServiceTestCase(APITestCase):
|
||||||
def test_post_marks_tos_signed(self):
|
def test_post_marks_tos_signed(self):
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from .views import (
|
|||||||
UserConversationAnalytics,
|
UserConversationAnalytics,
|
||||||
CompanyUsageAnalytics,
|
CompanyUsageAnalytics,
|
||||||
AdminAnalytics,
|
AdminAnalytics,
|
||||||
reset_password,
|
|
||||||
DocumentWorkspaceView,
|
DocumentWorkspaceView,
|
||||||
DocumentUploadView,
|
DocumentUploadView,
|
||||||
DocumentDetailView,
|
DocumentDetailView,
|
||||||
@@ -35,7 +34,9 @@ urlpatterns = [
|
|||||||
path("user/create/", CustomUserCreate.as_view(), name="create_user"),
|
path("user/create/", CustomUserCreate.as_view(), name="create_user"),
|
||||||
path("public/settings/", PublicSettingsView.as_view(), name="public_settings"),
|
path("public/settings/", PublicSettingsView.as_view(), name="public_settings"),
|
||||||
path("user/invite/", CustomUserInvite.as_view(), name="invite_user"),
|
path("user/invite/", CustomUserInvite.as_view(), name="invite_user"),
|
||||||
path("user/reset_password/", reset_password, name="reset_password"),
|
path(
|
||||||
|
"user/reset_password/", ResetUserPassword.as_view(), name="reset_password"
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"user/set_password/<slug:slug>/", SetUserPassword.as_view(), name="set_password"
|
"user/set_password/<slug:slug>/", SetUserPassword.as_view(), name="set_password"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ import pytz
|
|||||||
from langchain_ollama import OllamaEmbeddings
|
from langchain_ollama import OllamaEmbeddings
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
import requests
|
||||||
|
|
||||||
from .utils import last_day_of_month
|
from .utils import last_day_of_month
|
||||||
from .services.llm_service import AsyncLLMService
|
from .services.llm_service import AsyncLLMService
|
||||||
@@ -154,18 +154,16 @@ def send_invite_email(slug, email_to_invite):
|
|||||||
msg.send(fail_silently=True)
|
msg.send(fail_silently=True)
|
||||||
|
|
||||||
|
|
||||||
def send_password_reset_email(slug, email_to_invite):
|
def send_password_reset_email(slug, email_to):
|
||||||
logger.info("Sending reset email")
|
logger.info("Sending Password reset email")
|
||||||
logger.info(f"url : https://www.chat.aimloperations.com/set_password?slug={slug}")
|
base = settings.FRONTEND_BASE_URL.rstrip("/")
|
||||||
url = f"https://www.chat.aimloperations.com/set_password?slug={slug}"
|
url = f"{base}/set_password/?slug={slug}"
|
||||||
subject = "Password reset for AI ML Operations, LLC Chat Services"
|
subject = "Password reset for Chat by AI ML Operations, LLC"
|
||||||
from_email = "ryan@aimloperations.com"
|
from_email = "ryan@aimloperations.com"
|
||||||
to = email_to_invite
|
|
||||||
d = {"url": url}
|
d = {"url": url}
|
||||||
html_content = get_template(r"emails/reset_email.html").render(d)
|
html_content = get_template(r"emails/reset_email.html").render(d)
|
||||||
text_content = get_template(r"emails/reset_email.txt").render(d)
|
text_content = get_template(r"emails/reset_email.txt").render(d)
|
||||||
|
msg = EmailMultiAlternatives(subject, text_content, from_email, [email_to])
|
||||||
msg = EmailMultiAlternatives(subject, text_content, from_email, [to])
|
|
||||||
msg.attach_alternative(html_content, "text/html")
|
msg.attach_alternative(html_content, "text/html")
|
||||||
msg.send(fail_silently=True)
|
msg.send(fail_silently=True)
|
||||||
|
|
||||||
@@ -184,20 +182,6 @@ def send_feedback_email(feedback_obj):
|
|||||||
msg.send(fail_silently=True)
|
msg.send(fail_silently=True)
|
||||||
|
|
||||||
|
|
||||||
def send_password_reset_email(slug, email_to_invite):
|
|
||||||
logger.info("Sending Password reset email")
|
|
||||||
url = f"https://www.chat.aimloperations.com/set_password?slug={slug}"
|
|
||||||
subject = "Password reset for Chat by AI ML Operations, LLC"
|
|
||||||
from_email = "ryan@aimloperations.com"
|
|
||||||
to = email_to_invite
|
|
||||||
d = {"url": url}
|
|
||||||
html_content = get_template(r"emails/reset_email.html").render(d)
|
|
||||||
text_content = get_template(r"emails/reset_email.txt").render(d)
|
|
||||||
msg = EmailMultiAlternatives(subject, text_content, from_email, [to])
|
|
||||||
msg.attach_alternative(html_content, "text/html")
|
|
||||||
msg.send(fail_silently=True)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomUserInvite(APIView):
|
class CustomUserInvite(APIView):
|
||||||
http_method_names = ["post"]
|
http_method_names = ["post"]
|
||||||
|
|
||||||
@@ -234,66 +218,51 @@ class CustomUserInvite(APIView):
|
|||||||
return Response(status=status.HTTP_201_CREATED)
|
return Response(status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
def reset_password(request):
|
|
||||||
if request.method == "POST":
|
|
||||||
data = json.loads(request.body)
|
|
||||||
token = data.get("recaptchaToken")
|
|
||||||
payload = {
|
|
||||||
"secret": settings.CAPTCHA_SECRET_KEY,
|
|
||||||
"response": token,
|
|
||||||
}
|
|
||||||
response = requests.post(
|
|
||||||
"https://www.google.com/recaptcha/api/siteverify", data=payload
|
|
||||||
)
|
|
||||||
result = response.json()
|
|
||||||
if result.get("success") and result.get("score") >= 0.5:
|
|
||||||
email = data.get("email")
|
|
||||||
user = CustomUser.objects.filter(email=email).first()
|
|
||||||
if user:
|
|
||||||
user.set_unusable_password()
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
# send the email
|
|
||||||
send_password_reset_email(user.slug, email)
|
|
||||||
JsonResponse(status=200)
|
|
||||||
|
|
||||||
JsonResponse(status=400)
|
|
||||||
|
|
||||||
|
|
||||||
class ResetUserPassword(APIView):
|
class ResetUserPassword(APIView):
|
||||||
http_method_names = [
|
"""Request a password-reset email. Invalidates the current password when sent."""
|
||||||
"post",
|
|
||||||
]
|
http_method_names = ["post"]
|
||||||
permission_classes = (permissions.AllowAny,)
|
permission_classes = (permissions.AllowAny,)
|
||||||
authentication_classes = ()
|
authentication_classes = ()
|
||||||
|
|
||||||
def post(self, request, format="json"):
|
def post(self, request, format="json"):
|
||||||
"""
|
logger.info("Password reset requested")
|
||||||
Send an email with a set password link to the set password page
|
email = request.data.get("email")
|
||||||
Also disable the account
|
|
||||||
"""
|
|
||||||
logger.info(f"Password reset for requests. {request.data}")
|
|
||||||
token = request.data.get("recaptchaToken")
|
token = request.data.get("recaptchaToken")
|
||||||
|
if not email:
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"secret": settings.CAPTCHA_SECRET_KEY,
|
"secret": settings.CAPTCHA_SECRET_KEY,
|
||||||
"response": recaptchaToken,
|
"response": token,
|
||||||
}
|
}
|
||||||
response = requests.post(
|
try:
|
||||||
"https://www.google.com/recaptcha/api/siteverify", data=payload
|
captcha_response = requests.post(
|
||||||
|
"https://www.google.com/recaptcha/api/siteverify",
|
||||||
|
data=payload,
|
||||||
|
timeout=10,
|
||||||
)
|
)
|
||||||
result = response.json()
|
result = captcha_response.json()
|
||||||
if result.get("success") and result.get("score") >= 0.5:
|
except requests.RequestException as exc:
|
||||||
|
logger.error("Captcha verification request failed: %s", exc)
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# v2 invisible returns success only; v3 also returns a score.
|
||||||
|
if not result.get("success"):
|
||||||
|
logger.error("Captcha verification failed: %s", result)
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
score = result.get("score")
|
||||||
|
if score is not None and score < 0.5:
|
||||||
|
logger.error("Captcha score too low: %s", score)
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
user = CustomUser.objects.filter(email=email).first()
|
user = CustomUser.objects.filter(email=email).first()
|
||||||
if user:
|
if user:
|
||||||
user.set_unusable_password()
|
user.set_unusable_password()
|
||||||
user.save()
|
user.save(update_fields=["password"])
|
||||||
|
|
||||||
# send the email
|
|
||||||
send_password_reset_email(user.slug, email)
|
send_password_reset_email(user.slug, email)
|
||||||
else:
|
|
||||||
logger.error("Captcha secret failed")
|
|
||||||
|
|
||||||
|
# Always 200 after valid captcha to avoid email enumeration.
|
||||||
return Response(status=status.HTTP_200_OK)
|
return Response(status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
@@ -303,15 +272,30 @@ class SetUserPassword(APIView):
|
|||||||
authentication_classes = ()
|
authentication_classes = ()
|
||||||
|
|
||||||
def get(self, request, slug):
|
def get(self, request, slug):
|
||||||
|
try:
|
||||||
user = CustomUser.objects.get(slug=slug)
|
user = CustomUser.objects.get(slug=slug)
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
if user.has_usable_password():
|
if user.has_usable_password():
|
||||||
return Response(status=status.HTTP_401_UNAUTHORIZED)
|
return Response(status=status.HTTP_401_UNAUTHORIZED)
|
||||||
else:
|
|
||||||
return Response(status=status.HTTP_200_OK)
|
return Response(status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def post(self, request, slug, format="json"):
|
def post(self, request, slug, format="json"):
|
||||||
|
try:
|
||||||
user = CustomUser.objects.get(slug=slug)
|
user = CustomUser.objects.get(slug=slug)
|
||||||
user.set_password(request.data["password"])
|
except CustomUser.DoesNotExist:
|
||||||
|
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
if user.has_usable_password():
|
||||||
|
return Response(status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
password = request.data.get("password")
|
||||||
|
if not password or len(password) < 8:
|
||||||
|
return Response(
|
||||||
|
{"password": "Password must be at least 8 characters."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
user.set_password(password)
|
||||||
user.save()
|
user.save()
|
||||||
return Response(status=status.HTTP_200_OK)
|
return Response(status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user