From 16442b336ca35b0ebb660f33a98af37be018d995 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 27 Jul 2026 05:07:42 -0700 Subject: [PATCH] Fix password reset flow (#1) (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Closes #1 - Replace broken `csrf_exempt` `reset_password` FBV (responses never returned; missing `requests` import) with working DRF `ResetUserPassword` - Deduplicate reset email helper; build set-password links from `FRONTEND_BASE_URL` - Harden `SetUserPassword`: require unusable password, min 8 chars, handle missing slug - Accept reCAPTCHA v2 (success only) and v3 (score ≥ 0.5); avoid email enumeration (200 after valid captcha) - Add unit tests for reset + set-password edge cases ## Test plan - [ ] `manage.py test chat_backend.tests.test_views_users.ResetPasswordTestCase chat_backend.tests.test_views_users.SetPasswordTestCase` - [ ] With SMTP configured: request reset for known email → receive link → set password → sign in - [ ] Unknown email still returns 200 and sends no mail - [ ] Failed captcha returns 400 - [ ] Pair with chat_web_app `feature/password-reset-1` PRReviewed-on: https://git.aimloperations.com/ai_ml_operations/chat_backend/pulls/27 --- llm_be/chat_backend/admin.py | 105 ++++++++- llm_be/chat_backend/email_tasks.py | 164 ++++++++++++++ .../migrations/0024_user_auth_event.py | 30 +++ .../migrations/0025_outbound_email.py | 39 ++++ llm_be/chat_backend/models.py | 87 ++++++- .../templates/emails/base_email.html | 45 ++++ .../templates/emails/feedback_email.html | 117 ++-------- .../templates/emails/feedback_email.txt | 9 +- .../templates/emails/invite_email.html | 118 ++-------- .../templates/emails/invite_email.txt | 11 +- .../templates/emails/reset_email.html | 119 +++------- .../templates/emails/reset_email.txt | 12 +- llm_be/chat_backend/tests/test_models.py | 2 +- llm_be/chat_backend/tests/test_views_users.py | 129 ++++++++++- llm_be/chat_backend/urls.py | 5 +- llm_be/chat_backend/views.py | 214 ++++++++---------- llm_be/llm_be/settings.py | 8 + 17 files changed, 773 insertions(+), 441 deletions(-) create mode 100644 llm_be/chat_backend/email_tasks.py create mode 100644 llm_be/chat_backend/migrations/0024_user_auth_event.py create mode 100644 llm_be/chat_backend/migrations/0025_outbound_email.py create mode 100644 llm_be/chat_backend/templates/emails/base_email.html diff --git a/llm_be/chat_backend/admin.py b/llm_be/chat_backend/admin.py index 8c4a631..285539a 100644 --- a/llm_be/chat_backend/admin.py +++ b/llm_be/chat_backend/admin.py @@ -11,10 +11,10 @@ from .models import ( PromptMetric, DocumentWorkspace, Document, + UserAuthEvent, + OutboundEmail, ) -# Register your models here. - class AnnouncmentAdmin(admin.ModelAdmin): model = Announcement @@ -24,6 +24,32 @@ class CompanyAdmin(admin.ModelAdmin): model = Company +class UserAuthEventInline(admin.TabularInline): + model = UserAuthEvent + extra = 0 + can_delete = False + fields = ("created", "event_type", "detail", "ip_address") + readonly_fields = ("created", "event_type", "detail", "ip_address") + ordering = ("-created",) + show_change_link = False + + def has_add_permission(self, request, obj=None): + return False + + +class OutboundEmailInline(admin.TabularInline): + model = OutboundEmail + extra = 0 + can_delete = False + fields = ("created", "kind", "status", "subject", "to_email", "sent_at") + readonly_fields = ("created", "kind", "status", "subject", "to_email", "sent_at") + ordering = ("-created",) + show_change_link = True + + def has_add_permission(self, request, obj=None): + return False + + class CustomUserAdmin(admin.ModelAdmin): model = CustomUser list_display = ( @@ -41,7 +67,63 @@ class CustomUserAdmin(admin.ModelAdmin): "slug", "get_set_password_url", ) - search_fields = ("fields", "username", "first_name", "last_name", "slug") + search_fields = ("email", "username", "first_name", "last_name", "slug") + readonly_fields = ("last_login", "date_joined", "get_set_password_url", "slug") + inlines = (UserAuthEventInline, OutboundEmailInline) + + +class UserAuthEventAdmin(admin.ModelAdmin): + model = UserAuthEvent + list_display = ("created", "user", "event_type", "detail", "ip_address") + list_filter = ("event_type",) + search_fields = ("user__email", "user__username", "detail", "ip_address") + readonly_fields = ("user", "event_type", "created", "detail", "ip_address") + ordering = ("-created",) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + +class OutboundEmailAdmin(admin.ModelAdmin): + model = OutboundEmail + list_display = ( + "created", + "kind", + "status", + "subject", + "to_email", + "user", + "sent_at", + ) + list_filter = ("kind", "status") + search_fields = ("to_email", "subject", "user__email", "id") + readonly_fields = ( + "id", + "kind", + "status", + "to_email", + "from_email", + "subject", + "html_template", + "text_template", + "context", + "error_message", + "user", + "created", + "updated", + "sent_at", + ) + ordering = ("-created",) + date_hierarchy = "created" + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False class FeedbackAdmin(admin.ModelAdmin): @@ -59,6 +141,7 @@ class LLMModelsAdmin(admin.ModelAdmin): class PromptInline(admin.TabularInline): model = Prompt + class ConversationAdmin(admin.ModelAdmin): model = Conversation list_display = ( @@ -70,12 +153,14 @@ class ConversationAdmin(admin.ModelAdmin): "tokens_total", ) search_fields = ("title",) - inlines = [PromptInline,] + inlines = [ + PromptInline, + ] def _token_sum(self, conversation, field): - total = PromptMetric.objects.filter( - conversation_id=conversation.id - ).aggregate(total=Sum(field))["total"] + total = PromptMetric.objects.filter(conversation_id=conversation.id).aggregate( + total=Sum(field) + )["total"] return total or 0 @admin.display(description="Tokens in") @@ -93,7 +178,7 @@ class ConversationAdmin(admin.ModelAdmin): class PromptAdmin(admin.ModelAdmin): model = Prompt - list_display = ("id","message", "user_created", "get_conversation_title","created") + list_display = ("id", "message", "user_created", "get_conversation_title", "created") search_fields = ("message",) @@ -110,7 +195,7 @@ class PromptMetricAdmin(admin.ModelAdmin): "has_file", "file_type", "get_duration", - "created" + "created", ) list_filter = ("event", "model_name", "has_file") @@ -136,6 +221,8 @@ class DocumentAdmin(admin.ModelAdmin): admin.site.register(Announcement, AnnouncmentAdmin) admin.site.register(Company, CompanyAdmin) admin.site.register(CustomUser, CustomUserAdmin) +admin.site.register(UserAuthEvent, UserAuthEventAdmin) +admin.site.register(OutboundEmail, OutboundEmailAdmin) admin.site.register(LLMModels, LLMModelsAdmin) admin.site.register(Conversation, ConversationAdmin) diff --git a/llm_be/chat_backend/email_tasks.py b/llm_be/chat_backend/email_tasks.py new file mode 100644 index 0000000..6fbe911 --- /dev/null +++ b/llm_be/chat_backend/email_tasks.py @@ -0,0 +1,164 @@ +"""Outbound email helpers. + +Uses Django 6 Tasks API so callers enqueue work the same way today and when a +real worker backend is configured later. ImmediateBackend (default) still runs +the send inside the current process. + +Each send creates an OutboundEmail row for admin visibility (queued/sent/failed). +""" + +from __future__ import annotations + +import logging +from functools import partial + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.db import transaction +from django.tasks import task +from django.template.loader import get_template +from django.utils import timezone + +from .models import OutboundEmail + +logger = logging.getLogger(__name__) + +DEFAULT_FROM_EMAIL = "ryan@aimloperations.com" +SUPPORT_EMAIL = "ryan@aimloperations.com" +SITE_NAME = "Hesychia" +COMPANY_NAME = "AI ML Operations, LLC" + + +def brand_email_context(**extra): + base = settings.FRONTEND_BASE_URL.rstrip("/") + return { + "site_name": SITE_NAME, + "company_name": COMPANY_NAME, + "site_url": base, + "support_email": SUPPORT_EMAIL, + **extra, + } + + +def set_password_url(slug: str) -> str: + base = settings.FRONTEND_BASE_URL.rstrip("/") + return f"{base}/set_password/?slug={slug}" + + +@task +def send_templated_email(outbound_email_id: str) -> bool: + """Send a previously queued OutboundEmail via SMTP (SMTP2GO in prod).""" + try: + record = OutboundEmail.objects.get(pk=outbound_email_id) + except OutboundEmail.DoesNotExist: + logger.error("OutboundEmail %s missing; cannot send", outbound_email_id) + return False + + record.status = OutboundEmail.Status.SENDING + record.save(update_fields=["status", "updated"]) + + logger.info( + "Sending email id=%s subject=%r to=%s", + record.id, + record.subject, + record.to_email, + ) + html_content = get_template(record.html_template).render(record.context) + text_content = get_template(record.text_template).render(record.context) + msg = EmailMultiAlternatives( + record.subject, text_content, record.from_email, [record.to_email] + ) + msg.attach_alternative(html_content, "text/html") + # Correlate with SMTP2GO webhooks later (custom header passthrough). + msg.extra_headers["X-Hesychia-Email-Id"] = str(record.id) + + try: + msg.send(fail_silently=False) + except Exception as exc: + logger.exception( + "Failed to send email id=%s subject=%r to=%s", + record.id, + record.subject, + record.to_email, + ) + record.status = OutboundEmail.Status.FAILED + record.error_message = str(exc)[:2000] + record.save(update_fields=["status", "error_message", "updated"]) + return False + + record.status = OutboundEmail.Status.SENT + record.sent_at = timezone.now() + record.error_message = "" + record.save(update_fields=["status", "sent_at", "error_message", "updated"]) + return True + + +def enqueue_email( + *, + kind: str, + subject: str, + to_email: str, + html_template: str, + text_template: str, + context: dict, + user=None, +) -> OutboundEmail: + """Create an OutboundEmail row and enqueue send after DB commit.""" + record = OutboundEmail.objects.create( + kind=kind, + status=OutboundEmail.Status.QUEUED, + to_email=to_email, + from_email=DEFAULT_FROM_EMAIL, + subject=subject, + html_template=html_template, + text_template=text_template, + context=context, + user=user, + ) + transaction.on_commit( + partial(send_templated_email.enqueue, outbound_email_id=str(record.id)) + ) + return record + + +def send_invite_email(slug: str, email_to: str, user=None) -> OutboundEmail: + url = set_password_url(slug) + logger.info("Queueing invite email url=%s", url) + return enqueue_email( + kind=OutboundEmail.Kind.INVITE, + subject=f"Welcome to {SITE_NAME}", + to_email=email_to, + html_template="emails/invite_email.html", + text_template="emails/invite_email.txt", + context=brand_email_context(url=url), + user=user, + ) + + +def send_password_reset_email(slug: str, email_to: str, user=None) -> OutboundEmail: + url = set_password_url(slug) + logger.info("Queueing password reset email url=%s", url) + return enqueue_email( + kind=OutboundEmail.Kind.PASSWORD_RESET, + subject=f"Reset your {SITE_NAME} password", + to_email=email_to, + html_template="emails/reset_email.html", + text_template="emails/reset_email.txt", + context=brand_email_context(url=url), + user=user, + ) + + +def send_feedback_email( + title: str, feedback_text: str, user=None +) -> OutboundEmail: + logger.info("Queueing feedback email") + return enqueue_email( + kind=OutboundEmail.Kind.FEEDBACK, + subject=f"New {SITE_NAME} feedback", + to_email=SUPPORT_EMAIL, + html_template="emails/feedback_email.html", + text_template="emails/feedback_email.txt", + context=brand_email_context(title=title, feedback_text=feedback_text), + user=user, + ) diff --git a/llm_be/chat_backend/migrations/0024_user_auth_event.py b/llm_be/chat_backend/migrations/0024_user_auth_event.py new file mode 100644 index 0000000..e03bd96 --- /dev/null +++ b/llm_be/chat_backend/migrations/0024_user_auth_event.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0 on 2026-07-27 11:38 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat_backend', '0023_promptmetric_tokens_in_promptmetric_tokens_out'), + ] + + operations = [ + migrations.CreateModel( + name='UserAuthEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_type', models.CharField(choices=[('password_reset_requested', 'Password reset requested'), ('password_set', 'Password set'), ('invite_sent', 'Invite sent')], max_length=64)), + ('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('detail', models.CharField(blank=True, default='', max_length=512)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_events', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created'], + }, + ), + ] diff --git a/llm_be/chat_backend/migrations/0025_outbound_email.py b/llm_be/chat_backend/migrations/0025_outbound_email.py new file mode 100644 index 0000000..ea38799 --- /dev/null +++ b/llm_be/chat_backend/migrations/0025_outbound_email.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0 on 2026-07-27 11:41 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat_backend', '0024_user_auth_event'), + ] + + operations = [ + migrations.CreateModel( + name='OutboundEmail', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('kind', models.CharField(choices=[('password_reset', 'Password reset'), ('invite', 'Invite'), ('feedback', 'Feedback')], max_length=32)), + ('status', models.CharField(choices=[('queued', 'Queued'), ('sending', 'Sending'), ('sent', 'Sent to SMTP'), ('failed', 'Failed')], db_index=True, default='queued', max_length=16)), + ('to_email', models.EmailField(max_length=254)), + ('from_email', models.EmailField(max_length=254)), + ('subject', models.CharField(max_length=255)), + ('html_template', models.CharField(max_length=255)), + ('text_template', models.CharField(max_length=255)), + ('context', models.JSONField(blank=True, default=dict)), + ('error_message', models.TextField(blank=True, default='')), + ('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('updated', models.DateTimeField(auto_now=True)), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='outbound_emails', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created'], + }, + ), + ] diff --git a/llm_be/chat_backend/models.py b/llm_be/chat_backend/models.py index 6f7b854..ecb934b 100644 --- a/llm_be/chat_backend/models.py +++ b/llm_be/chat_backend/models.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import AbstractUser from django.utils import timezone from autoslug import AutoSlugField from chat_backend.storage import DatabaseStorage +import uuid # Create your models here. @@ -74,7 +75,91 @@ class CustomUser(AbstractUser): ) 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}" + + +class UserAuthEvent(models.Model): + """Audit trail for password reset / set actions, shown on user admin.""" + + class EventType(models.TextChoices): + PASSWORD_RESET_REQUESTED = ( + "password_reset_requested", + "Password reset requested", + ) + PASSWORD_SET = ("password_set", "Password set") + INVITE_SENT = ("invite_sent", "Invite sent") + + user = models.ForeignKey( + CustomUser, + on_delete=models.CASCADE, + related_name="auth_events", + ) + event_type = models.CharField(max_length=64, choices=EventType.choices) + created = models.DateTimeField(default=timezone.now, db_index=True) + detail = models.CharField(max_length=512, blank=True, default="") + ip_address = models.GenericIPAddressField(null=True, blank=True) + + class Meta: + ordering = ["-created"] + + def __str__(self): + return f"{self.get_event_type_display()} @ {self.created.isoformat()}" + + @classmethod + def log(cls, user, event_type, *, detail="", ip_address=None): + return cls.objects.create( + user=user, + event_type=event_type, + detail=detail or "", + ip_address=ip_address, + ) + + +class OutboundEmail(models.Model): + """Record of emails queued/sent by the app (visible in admin).""" + + class Kind(models.TextChoices): + PASSWORD_RESET = "password_reset", "Password reset" + INVITE = "invite", "Invite" + FEEDBACK = "feedback", "Feedback" + + class Status(models.TextChoices): + QUEUED = "queued", "Queued" + SENDING = "sending", "Sending" + SENT = "sent", "Sent to SMTP" + FAILED = "failed", "Failed" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + kind = models.CharField(max_length=32, choices=Kind.choices) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.QUEUED, db_index=True + ) + to_email = models.EmailField() + from_email = models.EmailField() + subject = models.CharField(max_length=255) + html_template = models.CharField(max_length=255) + text_template = models.CharField(max_length=255) + context = models.JSONField(default=dict, blank=True) + error_message = models.TextField(blank=True, default="") + user = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="outbound_emails", + ) + created = models.DateTimeField(default=timezone.now, db_index=True) + updated = models.DateTimeField(auto_now=True) + sent_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created"] + + def __str__(self): + return f"{self.subject} → {self.to_email} ({self.status})" FEEDBACK_CHOICE = ( diff --git a/llm_be/chat_backend/templates/emails/base_email.html b/llm_be/chat_backend/templates/emails/base_email.html new file mode 100644 index 0000000..8a26376 --- /dev/null +++ b/llm_be/chat_backend/templates/emails/base_email.html @@ -0,0 +1,45 @@ + + + + + + + {% block title %}{{ site_name }}{% endblock %} + + + + + + +
+ + + + + + + + + + + + + +
+
+ {{ site_name }} +
+
+ by {{ company_name }} +
+
+
+
+ {% block content %}{% endblock %} +
+

This is an automated message. Please do not reply.

+

© {% now "Y" %} {{ company_name }}. All rights reserved.

+
+
+ + diff --git a/llm_be/chat_backend/templates/emails/feedback_email.html b/llm_be/chat_backend/templates/emails/feedback_email.html index 3dd5f4a..1ae8232 100644 --- a/llm_be/chat_backend/templates/emails/feedback_email.html +++ b/llm_be/chat_backend/templates/emails/feedback_email.html @@ -1,105 +1,16 @@ - - - - - - New Feedback Submission - - - - - - - -
- - -
- - \ No newline at end of file +{% block content %} +

New feedback

+

A new feedback item was submitted in {{ site_name }}.

+
+
Title
+
{{ title }}
+
+
+
Feedback
+
{{ feedback_text }}
+
+{% endblock %} diff --git a/llm_be/chat_backend/templates/emails/feedback_email.txt b/llm_be/chat_backend/templates/emails/feedback_email.txt index 1c5c6bb..d9fd3bd 100644 --- a/llm_be/chat_backend/templates/emails/feedback_email.txt +++ b/llm_be/chat_backend/templates/emails/feedback_email.txt @@ -1,3 +1,8 @@ -New feedback for Chat by AI ML Operations, LLC +New {{ site_name }} feedback -"New Feedback. {{ title }}. {{ feedback_text }}" \ No newline at end of file +Title: {{ title }} + +Feedback: +{{ feedback_text }} + +— {{ site_name }} by {{ company_name }} diff --git a/llm_be/chat_backend/templates/emails/invite_email.html b/llm_be/chat_backend/templates/emails/invite_email.html index b358c97..c279857 100644 --- a/llm_be/chat_backend/templates/emails/invite_email.html +++ b/llm_be/chat_backend/templates/emails/invite_email.html @@ -1,97 +1,29 @@ - - - - - - Invitation to Chat by AI ML Operations, LLC - - - - +{% extends "emails/base_email.html" %} + +{% block title %}Welcome to {{ site_name }}{% endblock %} + +{% block content %} +

You're invited

+

Hello,

+

+ You have been invited to {{ site_name }} by {{ company_name }}. + Set a password to activate your account and start chatting. +

+
-
- - + + + Set your password +
- - \ No newline at end of file +

+ Or copy this link:
+ {{ url }} +

+

+ Once ready, + open {{ site_name }}. +

+{% endblock %} diff --git a/llm_be/chat_backend/templates/emails/invite_email.txt b/llm_be/chat_backend/templates/emails/invite_email.txt index 0ba347b..2dc2ca3 100644 --- a/llm_be/chat_backend/templates/emails/invite_email.txt +++ b/llm_be/chat_backend/templates/emails/invite_email.txt @@ -1,3 +1,10 @@ -Welcome to AI ML Operations, LLC Chat Services +You're invited to {{ site_name }} -"Welcome to chat.aimloperations.com. Please use {{ url }} to set your password" \ No newline at end of file +You have been invited to {{ site_name }} by {{ company_name }}. +Set a password to activate your account: + +{{ url }} + +Then open {{ site_url }} + +— {{ site_name }} by {{ company_name }} diff --git a/llm_be/chat_backend/templates/emails/reset_email.html b/llm_be/chat_backend/templates/emails/reset_email.html index 1a127d7..2565b74 100644 --- a/llm_be/chat_backend/templates/emails/reset_email.html +++ b/llm_be/chat_backend/templates/emails/reset_email.html @@ -1,97 +1,30 @@ - - - - - - Reset Password for Chat by AI ML Operations, LLC - - - - +{% extends "emails/base_email.html" %} + +{% block title %}Reset your {{ site_name }} password{% endblock %} + +{% block content %} +

Reset your password

+

Hello,

+

+ We received a request to reset the password for your {{ site_name }} account. + If you did not make this request, you can ignore this email or contact + {{ support_email }}. +

+
-
- - + + + Set a new password +
- - \ No newline at end of file +

+ Or copy this link:
+ {{ url }} +

+

+ After you set your password, + sign in to {{ site_name }}. +

+{% endblock %} diff --git a/llm_be/chat_backend/templates/emails/reset_email.txt b/llm_be/chat_backend/templates/emails/reset_email.txt index f805092..7d7e1a3 100644 --- a/llm_be/chat_backend/templates/emails/reset_email.txt +++ b/llm_be/chat_backend/templates/emails/reset_email.txt @@ -1,3 +1,11 @@ -Password Reset for AI ML Operations, LLC Chat Services +Reset your {{ site_name }} password -"Password reset for chat.aimloperations.com. Please use {{ url }} to set your password" \ No newline at end of file +We received a request to reset the password for your {{ site_name }} account. +If you did not make this request, ignore this email or contact {{ support_email }}. + +Set a new password: +{{ url }} + +Then sign in at {{ site_url }} + +— {{ site_name }} by {{ company_name }} diff --git a/llm_be/chat_backend/tests/test_models.py b/llm_be/chat_backend/tests/test_models.py index 198ffb0..50fffee 100644 --- a/llm_be/chat_backend/tests/test_models.py +++ b/llm_be/chat_backend/tests/test_models.py @@ -72,7 +72,7 @@ class CompanyAndUserTestCase(TestCase): user = make_user(email="person@example.com") self.assertEqual( 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): diff --git a/llm_be/chat_backend/tests/test_views_users.py b/llm_be/chat_backend/tests/test_views_users.py index 5d1108e..fc41425 100644 --- a/llm_be/chat_backend/tests/test_views_users.py +++ b/llm_be/chat_backend/tests/test_views_users.py @@ -1,10 +1,17 @@ from django.core import mail from django.urls import reverse +from unittest import mock from rest_framework import status from rest_framework.test import APITestCase from rest_framework_simplejwt.tokens import RefreshToken -from chat_backend.models import Announcement, CustomUser, Feedback +from chat_backend.models import ( + Announcement, + CustomUser, + Feedback, + OutboundEmail, + UserAuthEvent, +) from .factories import make_company, make_user @@ -179,6 +186,8 @@ class SetPasswordTestCase(APITestCase): self.assertEqual(self.client.get(url).status_code, status.HTTP_200_OK) def test_post_sets_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": "brandnewpass"}, format="json") @@ -186,6 +195,97 @@ class SetPasswordTestCase(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.user.refresh_from_db() self.assertTrue(self.user.check_password("brandnewpass")) + event = UserAuthEvent.objects.get(user=self.user) + self.assertEqual(event.event_type, UserAuthEvent.EventType.PASSWORD_SET) + + 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): + with self.captureOnCommitCallbacks(execute=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.assertIn("Hesychia", mail.outbox[0].subject) + self.assertEqual(mail.outbox[0].to, [self.user.email]) + event = UserAuthEvent.objects.get(user=self.user) + self.assertEqual( + event.event_type, UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED + ) + outbound = OutboundEmail.objects.get(user=self.user) + self.assertEqual(outbound.kind, OutboundEmail.Kind.PASSWORD_RESET) + self.assertEqual(outbound.status, OutboundEmail.Status.SENT) + self.assertEqual(outbound.to_email, self.user.email) + self.assertIsNotNone(outbound.sent_at) + + 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): @@ -211,9 +311,10 @@ class UserInviteTestCase(APITestCase): def test_manager_invites_new_user_and_email_is_sent(self): self.client.force_authenticate(user=self.manager) - response = self.client.post( - self.url, {"email": "newhire@example.com"}, format="json" - ) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + self.url, {"email": "newhire@example.com"}, format="json" + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) invited = CustomUser.objects.get(email="newhire@example.com") @@ -221,7 +322,13 @@ class UserInviteTestCase(APITestCase): self.assertEqual(invited.username, "newhire@example.com") self.assertEqual(len(mail.outbox), 1) self.assertIn(invited.slug, mail.outbox[0].body) + self.assertIn("Hesychia", mail.outbox[0].subject) self.assertEqual(mail.outbox[0].to, ["newhire@example.com"]) + event = UserAuthEvent.objects.get(user=invited) + self.assertEqual(event.event_type, UserAuthEvent.EventType.INVITE_SENT) + outbound = OutboundEmail.objects.get(user=invited) + self.assertEqual(outbound.kind, OutboundEmail.Kind.INVITE) + self.assertEqual(outbound.status, OutboundEmail.Status.SENT) def test_non_manager_cannot_invite(self): member = make_user(email="member@example.com", company=self.company) @@ -268,17 +375,21 @@ class FeedbackViewTestCase(APITestCase): self.url = reverse("feedbacks") def test_post_creates_feedback_and_notifies(self): - response = self.client.post( - self.url, - {"title": "Broken button", "text": "It does nothing"}, - format="json", - ) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + self.url, + {"title": "Broken button", "text": "It does nothing"}, + format="json", + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) feedback = Feedback.objects.get() self.assertEqual(feedback.user, self.user) self.assertEqual(len(mail.outbox), 1) self.assertIn("Broken button", mail.outbox[0].body) + outbound = OutboundEmail.objects.get(user=self.user) + self.assertEqual(outbound.kind, OutboundEmail.Kind.FEEDBACK) + self.assertEqual(outbound.status, OutboundEmail.Status.SENT) def test_post_without_text_is_rejected(self): response = self.client.post(self.url, {"title": "no body"}, format="json") diff --git a/llm_be/chat_backend/urls.py b/llm_be/chat_backend/urls.py index 510b75a..41418f3 100644 --- a/llm_be/chat_backend/urls.py +++ b/llm_be/chat_backend/urls.py @@ -21,7 +21,6 @@ from .views import ( UserConversationAnalytics, CompanyUsageAnalytics, AdminAnalytics, - reset_password, DocumentWorkspaceView, DocumentUploadView, DocumentDetailView, @@ -35,7 +34,9 @@ urlpatterns = [ path("user/create/", CustomUserCreate.as_view(), name="create_user"), path("public/settings/", PublicSettingsView.as_view(), name="public_settings"), 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( "user/set_password//", SetUserPassword.as_view(), name="set_password" ), diff --git a/llm_be/chat_backend/views.py b/llm_be/chat_backend/views.py index d589bee..8e82656 100644 --- a/llm_be/chat_backend/views.py +++ b/llm_be/chat_backend/views.py @@ -28,6 +28,7 @@ from .models import ( PromptMetric, DocumentWorkspace, Document, + UserAuthEvent, ) from django.views.decorators.cache import never_cache from django.http import JsonResponse @@ -47,12 +48,6 @@ import pandas as pd import io from chat_backend.services.assistant_identity import ASSISTANT_SYSTEM_PROMPT -# For email support -from django.core.mail import EmailMultiAlternatives -from django.template.loader import render_to_string -from django.utils.html import strip_tags -from django.template.loader import get_template -from django.template import Context from django.utils import timezone from django.core.files import File from django.core.files.base import ContentFile @@ -62,9 +57,14 @@ import pytz from langchain_ollama import OllamaEmbeddings from dateutil.relativedelta import relativedelta -from django.views.decorators.csrf import csrf_exempt +import requests from .utils import last_day_of_month +from .email_tasks import ( + send_feedback_email, + send_invite_email, + send_password_reset_email, +) from .services.llm_service import AsyncLLMService from .services.rag_services import AsyncRAGService from .services.title_generator import title_generator @@ -87,6 +87,13 @@ CHANNEL_NAME: str = "llm_messages" MODEL_NAME: str = ollama_model() +def _client_ip(request): + forwarded = request.META.get("HTTP_X_FORWARDED_FOR") + if forwarded: + return forwarded.split(",")[0].strip() + return request.META.get("REMOTE_ADDR") + + # Create your views here. class CustomObtainTokenView(TokenObtainPairView): permission_classes = (permissions.AllowAny,) @@ -138,66 +145,6 @@ class CustomUserCreate(APIView): ) -def send_invite_email(slug, email_to_invite): - logger.info("Sending invite email") - logger.info(f"url : https://chat.aimloperations.com/set_password?slug={slug}") - url = f"https://chat.aimloperations.com/set_password?slug={slug}" - subject = "Welcome to AI ML Operations, LLC Chat Services" - from_email = "ryan@aimloperations.com" - to = email_to_invite - d = {"url": url} - html_content = get_template(r"emails/invite_email.html").render(d) - text_content = get_template(r"emails/invite_email.txt").render(d) - - msg = EmailMultiAlternatives(subject, text_content, from_email, [to]) - msg.attach_alternative(html_content, "text/html") - msg.send(fail_silently=True) - - -def send_password_reset_email(slug, email_to_invite): - logger.info("Sending reset email") - logger.info(f"url : https://www.chat.aimloperations.com/set_password?slug={slug}") - url = f"https://www.chat.aimloperations.com/set_password?slug={slug}" - subject = "Password reset for AI ML Operations, LLC Chat Services" - 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) - - -def send_feedback_email(feedback_obj): - logger.info("Sending feedback email") - subject = "New Feedback for Chat by AI ML Operations, LLC" - from_email = "ryan@aimloperations.com" - to = "ryan@aimloperations.com" - d = {"title": feedback_obj.title, "feedback_text": feedback_obj.text} - html_content = get_template(r"emails/feedback_email.html").render(d) - text_content = get_template(r"emails/feedback_email.txt").render(d) - - msg = EmailMultiAlternatives(subject, text_content, from_email, [to]) - msg.attach_alternative(html_content, "text/html") - 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): http_method_names = ["post"] @@ -228,72 +175,68 @@ class CustomUserInvite(APIView): company=request.user.company, ) - # send an email - send_invite_email(user.slug, email_to_invite) + send_invite_email(user.slug, email_to_invite, user=user) + UserAuthEvent.log( + user, + UserAuthEvent.EventType.INVITE_SENT, + detail=f"Invited by {request.user.email}", + ip_address=_client_ip(request), + ) 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): - http_method_names = [ - "post", - ] + """Request a password-reset email. Invalidates the current password when sent.""" + + http_method_names = ["post"] permission_classes = (permissions.AllowAny,) authentication_classes = () def post(self, request, format="json"): - """ - Send an email with a set password link to the set password page - Also disable the account - """ - logger.info(f"Password reset for requests. {request.data}") + logger.info("Password reset requested") + email = request.data.get("email") token = request.data.get("recaptchaToken") + if not email: + return Response(status=status.HTTP_400_BAD_REQUEST) + payload = { "secret": settings.CAPTCHA_SECRET_KEY, - "response": recaptchaToken, + "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: - user = CustomUser.objects.filter(email=email).first() - if user: - user.set_unusable_password() - user.save() + try: + captcha_response = requests.post( + "https://www.google.com/recaptcha/api/siteverify", + data=payload, + timeout=10, + ) + result = captcha_response.json() + except requests.RequestException as exc: + logger.error("Captcha verification request failed: %s", exc) + return Response(status=status.HTTP_400_BAD_REQUEST) - # send the email - send_password_reset_email(user.slug, email) - else: - logger.error("Captcha secret failed") + # 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() + if user: + user.set_unusable_password() + user.save(update_fields=["password"]) + send_password_reset_email(user.slug, email, user=user) + UserAuthEvent.log( + user, + UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED, + detail="Password reset email queued", + ip_address=_client_ip(request), + ) + + # Always 200 after valid captcha to avoid email enumeration. return Response(status=status.HTTP_200_OK) @@ -303,16 +246,37 @@ class SetUserPassword(APIView): authentication_classes = () def get(self, request, slug): - user = CustomUser.objects.get(slug=slug) + try: + user = CustomUser.objects.get(slug=slug) + except CustomUser.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) if user.has_usable_password(): 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"): - user = CustomUser.objects.get(slug=slug) - user.set_password(request.data["password"]) + try: + user = CustomUser.objects.get(slug=slug) + 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() + UserAuthEvent.log( + user, + UserAuthEvent.EventType.PASSWORD_SET, + detail="Password set via email link", + ip_address=_client_ip(request), + ) return Response(status=status.HTTP_200_OK) @@ -347,7 +311,9 @@ class FeedbackView(APIView): feedback_obj.user = request.user feedback_obj.save() - send_feedback_email(feedback_obj) + send_feedback_email( + feedback_obj.title, feedback_obj.text, user=request.user + ) return Response(serializer.data, status=status.HTTP_201_CREATED) else: logger.error(serializer.errors) diff --git a/llm_be/llm_be/settings.py b/llm_be/llm_be/settings.py index 7bda6ac..0538678 100644 --- a/llm_be/llm_be/settings.py +++ b/llm_be/llm_be/settings.py @@ -285,6 +285,14 @@ EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "") or "" EMAIL_PORT = int(env("EMAIL_PORT", "2525") or "2525") EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True) +# Django 6 Tasks: ImmediateBackend runs in-process (no worker yet). Swap BACKEND +# to a durable queue + worker when SMTP should leave the request thread. +TASKS = { + "default": { + "BACKEND": "django.tasks.backends.immediate.ImmediateBackend", + } +} + CAPTCHA_SECRET_KEY = env("CAPTCHA_SECRET_KEY", "") or "" USE_TLS_PROXY = env_bool("USE_TLS_PROXY", DJANGO_ENV in {"prod", "beta"})