Compare commits
2
Commits
f8c29e09bb
...
be6ff471ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be6ff471ad | ||
|
|
16442b336c |
@@ -28,6 +28,18 @@ CAPTCHA_SECRET_KEY=
|
||||
# Self-serve sign-up (default false — set true to allow /user/create/)
|
||||
ENABLE_ACCOUNT_REGISTRATION=false
|
||||
|
||||
# OAuth SSO — Google / Microsoft (#24). Leave blank to hide SSO buttons.
|
||||
# Redirect URIs (register in each IdP console):
|
||||
# {OAUTH_CALLBACK_BASE_URL}/api/auth/oauth/google/callback/
|
||||
# {OAUTH_CALLBACK_BASE_URL}/api/auth/oauth/microsoft/callback/
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
MICROSOFT_OAUTH_CLIENT_ID=
|
||||
MICROSOFT_OAUTH_CLIENT_SECRET=
|
||||
MICROSOFT_OAUTH_TENANT=common
|
||||
# Optional; defaults to request host. Example local: http://127.0.0.1:8001
|
||||
OAUTH_CALLBACK_BASE_URL=http://127.0.0.1:8001
|
||||
|
||||
# Stripe / finance (optional local — required for checkout + webhooks)
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
|
||||
@@ -49,6 +49,17 @@ CAPTCHA_SECRET_KEY=replace-with-captcha-secret
|
||||
# public registration; set true in chat_backend_prod.env / chat_backend_beta.env.
|
||||
ENABLE_ACCOUNT_REGISTRATION=false
|
||||
|
||||
# OAuth SSO — Google / Microsoft (#24). Never commit real secrets.
|
||||
# Register redirect URIs:
|
||||
# https://chatbackend.aimloperations.com/api/auth/oauth/google/callback/
|
||||
# https://chatbackend.aimloperations.com/api/auth/oauth/microsoft/callback/
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
MICROSOFT_OAUTH_CLIENT_ID=
|
||||
MICROSOFT_OAUTH_CLIENT_SECRET=
|
||||
MICROSOFT_OAUTH_TENANT=common
|
||||
OAUTH_CALLBACK_BASE_URL=https://chatbackend.aimloperations.com
|
||||
|
||||
# Stripe / finance
|
||||
STRIPE_SECRET_KEY=replace-with-stripe-secret-key
|
||||
STRIPE_PUBLISHABLE_KEY=replace-with-stripe-publishable-key
|
||||
|
||||
@@ -11,10 +11,11 @@ from .models import (
|
||||
PromptMetric,
|
||||
DocumentWorkspace,
|
||||
Document,
|
||||
UserAuthEvent,
|
||||
OutboundEmail,
|
||||
OAuthIdentity,
|
||||
)
|
||||
|
||||
# Register your models here.
|
||||
|
||||
|
||||
class AnnouncmentAdmin(admin.ModelAdmin):
|
||||
model = Announcement
|
||||
@@ -24,6 +25,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 +68,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 +142,7 @@ class LLMModelsAdmin(admin.ModelAdmin):
|
||||
class PromptInline(admin.TabularInline):
|
||||
model = Prompt
|
||||
|
||||
|
||||
class ConversationAdmin(admin.ModelAdmin):
|
||||
model = Conversation
|
||||
list_display = (
|
||||
@@ -70,12 +154,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 +179,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 +196,7 @@ class PromptMetricAdmin(admin.ModelAdmin):
|
||||
"has_file",
|
||||
"file_type",
|
||||
"get_duration",
|
||||
"created"
|
||||
"created",
|
||||
)
|
||||
list_filter = ("event", "model_name", "has_file")
|
||||
|
||||
@@ -136,6 +222,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)
|
||||
@@ -145,3 +233,22 @@ admin.site.register(Feedback, FeedbackAdmin)
|
||||
|
||||
admin.site.register(DocumentWorkspace, DocumentWorkspaceAdmin)
|
||||
admin.site.register(Document, DocumentAdmin)
|
||||
|
||||
|
||||
class OAuthIdentityAdmin(admin.ModelAdmin):
|
||||
model = OAuthIdentity
|
||||
list_display = (
|
||||
"provider",
|
||||
"email",
|
||||
"subject",
|
||||
"user",
|
||||
"token_expires_at",
|
||||
"created",
|
||||
"last_modified",
|
||||
)
|
||||
list_filter = ("provider",)
|
||||
search_fields = ("email", "subject", "user__email")
|
||||
readonly_fields = ("created", "last_modified", "raw_profile")
|
||||
|
||||
|
||||
admin.site.register(OAuthIdentity, OAuthIdentityAdmin)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 6.0 on 2026-07-27 11:51
|
||||
|
||||
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='OAuthIdentity',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('last_modified', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('provider', models.CharField(choices=[('google', 'Google'), ('microsoft', 'Microsoft')], max_length=32)),
|
||||
('subject', models.CharField(help_text='OIDC subject (sub) from the identity provider', max_length=255)),
|
||||
('email', models.EmailField(blank=True, default='', max_length=254)),
|
||||
('access_token', models.TextField(blank=True, default='')),
|
||||
('refresh_token', models.TextField(blank=True, default='')),
|
||||
('token_expires_at', models.DateTimeField(blank=True, null=True)),
|
||||
('scopes', models.TextField(blank=True, default='')),
|
||||
('raw_profile', models.JSONField(blank=True, default=dict)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth_identities', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'OAuth identities',
|
||||
'constraints': [models.UniqueConstraint(fields=('provider', 'subject'), name='uniq_oauth_provider_subject'), models.UniqueConstraint(fields=('provider', 'user'), name='uniq_oauth_provider_user')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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,132 @@ 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})"
|
||||
|
||||
|
||||
class OAuthIdentity(TimeInfoBase):
|
||||
"""Linked IdP identity + tokens (SSO now; Drive OAuth reuse later — #11)."""
|
||||
|
||||
class Provider(models.TextChoices):
|
||||
GOOGLE = "google", "Google"
|
||||
MICROSOFT = "microsoft", "Microsoft"
|
||||
|
||||
user = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="oauth_identities",
|
||||
)
|
||||
provider = models.CharField(max_length=32, choices=Provider.choices)
|
||||
subject = models.CharField(
|
||||
max_length=255,
|
||||
help_text="OIDC subject (sub) from the identity provider",
|
||||
)
|
||||
email = models.EmailField(blank=True, default="")
|
||||
access_token = models.TextField(blank=True, default="")
|
||||
refresh_token = models.TextField(blank=True, default="")
|
||||
token_expires_at = models.DateTimeField(null=True, blank=True)
|
||||
scopes = models.TextField(blank=True, default="")
|
||||
raw_profile = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["provider", "subject"],
|
||||
name="uniq_oauth_provider_subject",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["provider", "user"],
|
||||
name="uniq_oauth_provider_user",
|
||||
),
|
||||
]
|
||||
verbose_name_plural = "OAuth identities"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.provider}:{self.subject} → {self.user_id}"
|
||||
|
||||
|
||||
FEEDBACK_CHOICE = (
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"""Google / Microsoft OIDC helpers for SSO (#24)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
from django.core import signing
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Company, CustomUser, OAuthIdentity
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_SALT = "chat_backend.oauth.state"
|
||||
STATE_MAX_AGE_SECONDS = 600
|
||||
|
||||
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||
|
||||
MICROSOFT_AUTH_URL_TMPL = (
|
||||
"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize"
|
||||
)
|
||||
MICROSOFT_TOKEN_URL_TMPL = (
|
||||
"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
||||
)
|
||||
|
||||
|
||||
class OAuthError(Exception):
|
||||
"""User-facing OAuth failure with a stable error code for the FE."""
|
||||
|
||||
def __init__(self, code: str, message: str = ""):
|
||||
self.code = code
|
||||
self.message = message or code
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderProfile:
|
||||
provider: str
|
||||
subject: str
|
||||
email: str
|
||||
email_verified: bool
|
||||
first_name: str
|
||||
last_name: str
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int | None
|
||||
scopes: str
|
||||
raw: dict[str, Any]
|
||||
|
||||
|
||||
def provider_configured(provider: str) -> bool:
|
||||
if provider == OAuthIdentity.Provider.GOOGLE:
|
||||
return bool(settings.GOOGLE_OAUTH_CLIENT_ID and settings.GOOGLE_OAUTH_CLIENT_SECRET)
|
||||
if provider == OAuthIdentity.Provider.MICROSOFT:
|
||||
return bool(
|
||||
settings.MICROSOFT_OAUTH_CLIENT_ID and settings.MICROSOFT_OAUTH_CLIENT_SECRET
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def configured_providers() -> dict[str, bool]:
|
||||
return {
|
||||
OAuthIdentity.Provider.GOOGLE: provider_configured(OAuthIdentity.Provider.GOOGLE),
|
||||
OAuthIdentity.Provider.MICROSOFT: provider_configured(
|
||||
OAuthIdentity.Provider.MICROSOFT
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def dump_oauth_state(*, provider: str, intent: str) -> str:
|
||||
return signing.dumps(
|
||||
{"provider": provider, "intent": intent},
|
||||
salt=STATE_SALT,
|
||||
)
|
||||
|
||||
|
||||
def load_oauth_state(state: str) -> dict[str, str]:
|
||||
try:
|
||||
data = signing.loads(state, salt=STATE_SALT, max_age=STATE_MAX_AGE_SECONDS)
|
||||
except signing.BadSignature as exc:
|
||||
raise OAuthError("invalid_state", "OAuth state is invalid or expired.") from exc
|
||||
provider = data.get("provider")
|
||||
intent = data.get("intent") or "login"
|
||||
if provider not in OAuthIdentity.Provider.values:
|
||||
raise OAuthError("invalid_state", "Unknown OAuth provider in state.")
|
||||
if intent not in {"login", "signup"}:
|
||||
raise OAuthError("invalid_state", "Invalid OAuth intent.")
|
||||
return {"provider": provider, "intent": intent}
|
||||
|
||||
|
||||
def _microsoft_tenant() -> str:
|
||||
return settings.MICROSOFT_OAUTH_TENANT or "common"
|
||||
|
||||
|
||||
def build_authorization_url(*, provider: str, redirect_uri: str, state: str) -> str:
|
||||
if not provider_configured(provider):
|
||||
raise OAuthError("provider_not_configured", f"{provider} OAuth is not configured.")
|
||||
|
||||
if provider == OAuthIdentity.Provider.GOOGLE:
|
||||
params = {
|
||||
"client_id": settings.GOOGLE_OAUTH_CLIENT_ID,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
"state": state,
|
||||
"access_type": "offline",
|
||||
"prompt": "select_account consent",
|
||||
"include_granted_scopes": "true",
|
||||
}
|
||||
return f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
|
||||
|
||||
if provider == OAuthIdentity.Provider.MICROSOFT:
|
||||
params = {
|
||||
"client_id": settings.MICROSOFT_OAUTH_CLIENT_ID,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"response_mode": "query",
|
||||
"scope": "openid email profile offline_access",
|
||||
"state": state,
|
||||
"prompt": "select_account",
|
||||
}
|
||||
auth_url = MICROSOFT_AUTH_URL_TMPL.format(tenant=_microsoft_tenant())
|
||||
return f"{auth_url}?{urlencode(params)}"
|
||||
|
||||
raise OAuthError("invalid_provider", f"Unsupported provider: {provider}")
|
||||
|
||||
|
||||
def _decode_id_token_claims(id_token: str | None) -> dict[str, Any]:
|
||||
if not id_token:
|
||||
return {}
|
||||
# Signature verified via TLS token endpoint + client secret; claims are trusted.
|
||||
return jwt.decode(
|
||||
id_token,
|
||||
options={"verify_signature": False, "verify_aud": False},
|
||||
)
|
||||
|
||||
|
||||
def exchange_code_for_profile(
|
||||
*, provider: str, code: str, redirect_uri: str
|
||||
) -> ProviderProfile:
|
||||
if provider == OAuthIdentity.Provider.GOOGLE:
|
||||
return _exchange_google(code=code, redirect_uri=redirect_uri)
|
||||
if provider == OAuthIdentity.Provider.MICROSOFT:
|
||||
return _exchange_microsoft(code=code, redirect_uri=redirect_uri)
|
||||
raise OAuthError("invalid_provider", f"Unsupported provider: {provider}")
|
||||
|
||||
|
||||
def _exchange_google(*, code: str, redirect_uri: str) -> ProviderProfile:
|
||||
with httpx.Client(timeout=20.0) as client:
|
||||
token_response = client.post(
|
||||
GOOGLE_TOKEN_URL,
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": settings.GOOGLE_OAUTH_CLIENT_ID,
|
||||
"client_secret": settings.GOOGLE_OAUTH_CLIENT_SECRET,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
if token_response.status_code >= 400:
|
||||
logger.warning("Google token exchange failed: %s", token_response.text)
|
||||
raise OAuthError("token_exchange_failed", "Google token exchange failed.")
|
||||
token_data = token_response.json()
|
||||
access_token = token_data.get("access_token") or ""
|
||||
if not access_token:
|
||||
raise OAuthError("token_exchange_failed", "Google did not return an access token.")
|
||||
|
||||
userinfo_response = client.get(
|
||||
GOOGLE_USERINFO_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
if userinfo_response.status_code >= 400:
|
||||
logger.warning("Google userinfo failed: %s", userinfo_response.text)
|
||||
raise OAuthError("profile_fetch_failed", "Could not load Google profile.")
|
||||
profile = userinfo_response.json()
|
||||
|
||||
claims = _decode_id_token_claims(token_data.get("id_token"))
|
||||
email = (profile.get("email") or claims.get("email") or "").strip().lower()
|
||||
email_verified = bool(
|
||||
profile.get("email_verified", claims.get("email_verified", False))
|
||||
)
|
||||
subject = str(profile.get("sub") or claims.get("sub") or "").strip()
|
||||
if not subject:
|
||||
raise OAuthError("profile_incomplete", "Google profile missing subject.")
|
||||
|
||||
return ProviderProfile(
|
||||
provider=OAuthIdentity.Provider.GOOGLE,
|
||||
subject=subject,
|
||||
email=email,
|
||||
email_verified=email_verified,
|
||||
first_name=(profile.get("given_name") or claims.get("given_name") or "").strip(),
|
||||
last_name=(profile.get("family_name") or claims.get("family_name") or "").strip(),
|
||||
access_token=access_token,
|
||||
refresh_token=token_data.get("refresh_token") or "",
|
||||
expires_in=_as_int(token_data.get("expires_in")),
|
||||
scopes=token_data.get("scope") or "openid email profile",
|
||||
raw={"userinfo": profile, "id_token_claims": claims},
|
||||
)
|
||||
|
||||
|
||||
def _exchange_microsoft(*, code: str, redirect_uri: str) -> ProviderProfile:
|
||||
token_url = MICROSOFT_TOKEN_URL_TMPL.format(tenant=_microsoft_tenant())
|
||||
with httpx.Client(timeout=20.0) as client:
|
||||
token_response = client.post(
|
||||
token_url,
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": settings.MICROSOFT_OAUTH_CLIENT_ID,
|
||||
"client_secret": settings.MICROSOFT_OAUTH_CLIENT_SECRET,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
"scope": "openid email profile offline_access",
|
||||
},
|
||||
)
|
||||
if token_response.status_code >= 400:
|
||||
logger.warning("Microsoft token exchange failed: %s", token_response.text)
|
||||
raise OAuthError("token_exchange_failed", "Microsoft token exchange failed.")
|
||||
token_data = token_response.json()
|
||||
|
||||
claims = _decode_id_token_claims(token_data.get("id_token"))
|
||||
email = (
|
||||
claims.get("email")
|
||||
or claims.get("preferred_username")
|
||||
or claims.get("upn")
|
||||
or ""
|
||||
)
|
||||
email = str(email).strip().lower()
|
||||
# Microsoft issues verified tenant emails; treat presence as verified when claim missing.
|
||||
email_verified = bool(claims.get("email_verified", True if email else False))
|
||||
subject = str(claims.get("oid") or claims.get("sub") or "").strip()
|
||||
if not subject:
|
||||
raise OAuthError("profile_incomplete", "Microsoft profile missing subject.")
|
||||
|
||||
name = (claims.get("name") or "").strip()
|
||||
first_name = (claims.get("given_name") or "").strip()
|
||||
last_name = (claims.get("family_name") or "").strip()
|
||||
if not first_name and name:
|
||||
parts = name.split(" ", 1)
|
||||
first_name = parts[0]
|
||||
last_name = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
return ProviderProfile(
|
||||
provider=OAuthIdentity.Provider.MICROSOFT,
|
||||
subject=subject,
|
||||
email=email,
|
||||
email_verified=email_verified,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
access_token=token_data.get("access_token") or "",
|
||||
refresh_token=token_data.get("refresh_token") or "",
|
||||
expires_in=_as_int(token_data.get("expires_in")),
|
||||
scopes=token_data.get("scope") or "openid email profile offline_access",
|
||||
raw={"id_token_claims": claims},
|
||||
)
|
||||
|
||||
|
||||
def _as_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value) if value is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _token_expiry(expires_in: int | None):
|
||||
if not expires_in:
|
||||
return None
|
||||
return timezone.now() + timedelta(seconds=expires_in)
|
||||
|
||||
|
||||
def upsert_identity(user: CustomUser, profile: ProviderProfile) -> OAuthIdentity:
|
||||
identity = OAuthIdentity.objects.filter(
|
||||
provider=profile.provider, subject=profile.subject
|
||||
).first()
|
||||
expires_at = _token_expiry(profile.expires_in)
|
||||
if identity:
|
||||
identity.user = user
|
||||
identity.email = profile.email
|
||||
identity.access_token = profile.access_token
|
||||
if profile.refresh_token:
|
||||
identity.refresh_token = profile.refresh_token
|
||||
identity.token_expires_at = expires_at
|
||||
identity.scopes = profile.scopes
|
||||
identity.raw_profile = profile.raw
|
||||
identity.save()
|
||||
return identity
|
||||
|
||||
return OAuthIdentity.objects.create(
|
||||
user=user,
|
||||
provider=profile.provider,
|
||||
subject=profile.subject,
|
||||
email=profile.email,
|
||||
access_token=profile.access_token,
|
||||
refresh_token=profile.refresh_token or "",
|
||||
token_expires_at=expires_at,
|
||||
scopes=profile.scopes,
|
||||
raw_profile=profile.raw,
|
||||
)
|
||||
|
||||
|
||||
def _create_sso_user(profile: ProviderProfile) -> CustomUser:
|
||||
company = Company.objects.create(
|
||||
name=f"{profile.email}'s workspace",
|
||||
state="NA",
|
||||
zipcode="00000",
|
||||
address="N/A",
|
||||
)
|
||||
user = CustomUser(
|
||||
username=profile.email,
|
||||
email=profile.email,
|
||||
first_name=profile.first_name,
|
||||
last_name=profile.last_name,
|
||||
company=company,
|
||||
is_company_manager=True,
|
||||
)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
def resolve_user_from_profile(
|
||||
*, profile: ProviderProfile, intent: str
|
||||
) -> tuple[CustomUser, bool]:
|
||||
"""
|
||||
Map IdP profile → CustomUser.
|
||||
|
||||
Returns (user, created).
|
||||
"""
|
||||
if not profile.email:
|
||||
raise OAuthError("email_missing", "Email was not provided by the identity provider.")
|
||||
if not profile.email_verified:
|
||||
raise OAuthError("email_unverified", "Email from the identity provider is not verified.")
|
||||
|
||||
existing_identity = (
|
||||
OAuthIdentity.objects.select_related("user")
|
||||
.filter(provider=profile.provider, subject=profile.subject)
|
||||
.first()
|
||||
)
|
||||
if existing_identity:
|
||||
return existing_identity.user, False
|
||||
|
||||
email_user = (
|
||||
CustomUser.objects.filter(email__iexact=profile.email).first()
|
||||
or CustomUser.objects.filter(username__iexact=profile.email).first()
|
||||
)
|
||||
if email_user:
|
||||
# Same provider already linked to a different subject → unsafe collision.
|
||||
other = (
|
||||
OAuthIdentity.objects.filter(provider=profile.provider, user=email_user)
|
||||
.exclude(subject=profile.subject)
|
||||
.first()
|
||||
)
|
||||
if other:
|
||||
raise OAuthError(
|
||||
"link_conflict",
|
||||
"This email is already linked to a different identity for this provider.",
|
||||
)
|
||||
upsert_identity(email_user, profile)
|
||||
return email_user, False
|
||||
|
||||
# New account path
|
||||
allow_create = settings.ENABLE_ACCOUNT_REGISTRATION
|
||||
if intent == "signup" and not allow_create:
|
||||
raise OAuthError("registration_disabled", "Account registration is disabled.")
|
||||
if intent == "login" and not allow_create:
|
||||
raise OAuthError(
|
||||
"account_not_found",
|
||||
"No account exists for this email. Contact your administrator.",
|
||||
)
|
||||
if not allow_create:
|
||||
raise OAuthError("registration_disabled", "Account registration is disabled.")
|
||||
|
||||
user = _create_sso_user(profile)
|
||||
upsert_identity(user, profile)
|
||||
return user, True
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>{% block title %}{{ site_name }}{% endblock %}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#0b0b14;font-family:Inter,Segoe UI,Roboto,Helvetica,Arial,sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#0b0b14;background-image:linear-gradient(160deg,#0b0b14 0%,#1a1a2e 55%,#12122a 100%);">
|
||||
<tr>
|
||||
<td align="center" style="padding:40px 16px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:560px;background-color:rgba(0,0,0,0.45);border:1px solid rgba(255,255,255,0.12);border-radius:16px;overflow:hidden;">
|
||||
<tr>
|
||||
<td align="center" style="padding:28px 28px 12px 28px;">
|
||||
<div style="font-size:28px;font-weight:700;letter-spacing:0.02em;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);-webkit-background-clip:text;background-clip:text;color:#a78bfa;">
|
||||
{{ site_name }}
|
||||
</div>
|
||||
<div style="margin-top:6px;font-size:12px;color:rgba(255,255,255,0.45);letter-spacing:0.08em;text-transform:uppercase;">
|
||||
by {{ company_name }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 28px 0 28px;">
|
||||
<div style="height:1px;background:linear-gradient(90deg,transparent,#667eea,#764ba2,transparent);"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:28px;color:rgba(255,255,255,0.88);font-size:15px;line-height:1.6;">
|
||||
{% block content %}{% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 28px 28px 28px;color:rgba(255,255,255,0.45);font-size:12px;line-height:1.5;text-align:center;">
|
||||
<p style="margin:0 0 8px 0;">This is an automated message. Please do not reply.</p>
|
||||
<p style="margin:0;">© {% now "Y" %} {{ company_name }}. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,105 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>New Feedback Submission</title>
|
||||
<style>
|
||||
/* Basic reset for email clients */
|
||||
body, table, td, a {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table, td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #dddddd;
|
||||
}
|
||||
.header {
|
||||
background-color: #007BFF;
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
color: #333333;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f4f4f4;
|
||||
color: #777777;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.feedback-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.feedback-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||
<tr>
|
||||
<td>
|
||||
<!-- Email Container -->
|
||||
<div class="email-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>New Feedback Submission</h1>
|
||||
</div>
|
||||
{% extends "emails/base_email.html" %}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
<p>A new feedback item has been submitted. Here are the details:</p>
|
||||
{% block title %}New {{ site_name }} feedback{% endblock %}
|
||||
|
||||
<!-- Feedback Title -->
|
||||
<div class="feedback-title">
|
||||
Title: <strong>{{ title }}</strong>
|
||||
{% block content %}
|
||||
<h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">New feedback</h1>
|
||||
<p style="margin:0 0 16px 0;">A new feedback item was submitted in {{ site_name }}.</p>
|
||||
<div style="margin:0 0 12px 0;padding:16px;border-radius:10px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);">
|
||||
<div style="font-size:12px;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px;">Title</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#ffffff;">{{ title }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Feedback Text -->
|
||||
<div class="feedback-text">
|
||||
<strong>Feedback:</strong><br>
|
||||
{{ feedback_text }}
|
||||
<div style="margin:0 0 16px 0;padding:16px;border-radius:10px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);">
|
||||
<div style="font-size:12px;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px;">Feedback</div>
|
||||
<div style="font-size:14px;color:rgba(255,255,255,0.85);white-space:pre-wrap;">{{ feedback_text }}</div>
|
||||
</div>
|
||||
|
||||
<p>Thank you for your attention.</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>This is an automated message. Please do not reply to this email.</p>
|
||||
<p>© 2025 AI ML Operations, LLC. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
New feedback for Chat by AI ML Operations, LLC
|
||||
New {{ site_name }} feedback
|
||||
|
||||
"New Feedback. {{ title }}. {{ feedback_text }}"
|
||||
Title: {{ title }}
|
||||
|
||||
Feedback:
|
||||
{{ feedback_text }}
|
||||
|
||||
— {{ site_name }} by {{ company_name }}
|
||||
|
||||
@@ -1,97 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Invitation to Chat by AI ML Operations, LLC</title>
|
||||
<style>
|
||||
/* Basic reset for email clients */
|
||||
body, table, td, a {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table, td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #dddddd;
|
||||
}
|
||||
.header {
|
||||
background-color: #007BFF;
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
color: #333333;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f4f4f4;
|
||||
color: #777777;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.feedback-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.feedback-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||
{% extends "emails/base_email.html" %}
|
||||
|
||||
{% block title %}Welcome to {{ site_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">You're invited</h1>
|
||||
<p style="margin:0 0 16px 0;">Hello,</p>
|
||||
<p style="margin:0 0 16px 0;">
|
||||
You have been invited to {{ site_name }} by {{ company_name }}.
|
||||
Set a password to activate your account and start chatting.
|
||||
</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:24px 0;">
|
||||
<tr>
|
||||
<td>
|
||||
<!-- Email Container -->
|
||||
<div class="email-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>Welcome to AI ML Operations, LLC Chat Services</h1>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
<p>You have been invited to use Chat by AI ML Operations, LLC.</p>
|
||||
|
||||
<p>Please click <a href="{{ url }}">link</a> to set your password.</p>
|
||||
<p>Once you have set your password go <a href="https://chat.aimloperations.com">here</a> to get started.</p>
|
||||
|
||||
<p>Thank you.</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>This is an automated message. Please do not reply to this email.</p>
|
||||
<p>© 2025 AI ML Operations, LLC. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
<td align="center" style="border-radius:8px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);">
|
||||
<a href="{{ url }}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">
|
||||
Set your password
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
<p style="margin:0 0 12px 0;font-size:13px;color:rgba(255,255,255,0.55);word-break:break-all;">
|
||||
Or copy this link:<br>
|
||||
<a href="{{ url }}" style="color:#a78bfa;text-decoration:none;">{{ url }}</a>
|
||||
</p>
|
||||
<p style="margin:0;">
|
||||
Once ready,
|
||||
<a href="{{ site_url }}" style="color:#a78bfa;text-decoration:none;">open {{ site_name }}</a>.
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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"
|
||||
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 }}
|
||||
|
||||
@@ -1,97 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reset Password for Chat by AI ML Operations, LLC</title>
|
||||
<style>
|
||||
/* Basic reset for email clients */
|
||||
body, table, td, a {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table, td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #dddddd;
|
||||
}
|
||||
.header {
|
||||
background-color: #007BFF;
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
color: #333333;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f4f4f4;
|
||||
color: #777777;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.feedback-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.feedback-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||
{% extends "emails/base_email.html" %}
|
||||
|
||||
{% block title %}Reset your {{ site_name }} password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">Reset your password</h1>
|
||||
<p style="margin:0 0 16px 0;">Hello,</p>
|
||||
<p style="margin:0 0 16px 0;">
|
||||
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
|
||||
<a href="mailto:{{ support_email }}" style="color:#a78bfa;text-decoration:none;">{{ support_email }}</a>.
|
||||
</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:24px 0;">
|
||||
<tr>
|
||||
<td>
|
||||
<!-- Email Container -->
|
||||
<div class="email-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>Password Reset for AI ML Operations, LLC Chat Services</h1>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
<p>There has been a request for a password reset. If you didn't requets this, please email ryan@aimloperations.com</p>
|
||||
|
||||
<p>Please click <a href="{{ url }}">link</a> to set your password.</p>
|
||||
<p>Once you have set your password go <a href="https://chat.aimloperations.com">here</a> to get started.</p>
|
||||
|
||||
<p>Thank you.</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>This is an automated message. Please do not reply to this email.</p>
|
||||
<p>© 2023-2025 AI ML Operations, LLC. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
<td align="center" style="border-radius:8px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);">
|
||||
<a href="{{ url }}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">
|
||||
Set a new password
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
<p style="margin:0 0 12px 0;font-size:13px;color:rgba(255,255,255,0.55);word-break:break-all;">
|
||||
Or copy this link:<br>
|
||||
<a href="{{ url }}" style="color:#a78bfa;text-decoration:none;">{{ url }}</a>
|
||||
</p>
|
||||
<p style="margin:0;">
|
||||
After you set your password,
|
||||
<a href="{{ site_url }}" style="color:#a78bfa;text-decoration:none;">sign in to {{ site_name }}</a>.
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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"
|
||||
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 }}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Tests for Google / Microsoft OAuth SSO (#24)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
|
||||
from chat_backend.models import CustomUser, OAuthIdentity
|
||||
from chat_backend.oauth import ProviderProfile, dump_oauth_state
|
||||
from chat_backend.tests.factories import make_user
|
||||
|
||||
OAUTH_SETTINGS = {
|
||||
"GOOGLE_OAUTH_CLIENT_ID": "google-client-id",
|
||||
"GOOGLE_OAUTH_CLIENT_SECRET": "google-client-secret",
|
||||
"MICROSOFT_OAUTH_CLIENT_ID": "ms-client-id",
|
||||
"MICROSOFT_OAUTH_CLIENT_SECRET": "ms-client-secret",
|
||||
"MICROSOFT_OAUTH_TENANT": "common",
|
||||
"FRONTEND_BASE_URL": "http://frontend.test",
|
||||
"OAUTH_CALLBACK_BASE_URL": "http://backend.test",
|
||||
"ENABLE_ACCOUNT_REGISTRATION": True,
|
||||
}
|
||||
|
||||
|
||||
def _google_profile(**overrides) -> ProviderProfile:
|
||||
data = dict(
|
||||
provider=OAuthIdentity.Provider.GOOGLE,
|
||||
subject="google-sub-1",
|
||||
email="sso.user@example.com",
|
||||
email_verified=True,
|
||||
first_name="Sso",
|
||||
last_name="User",
|
||||
access_token="access-token",
|
||||
refresh_token="refresh-token",
|
||||
expires_in=3600,
|
||||
scopes="openid email profile",
|
||||
raw={"userinfo": {"sub": "google-sub-1"}},
|
||||
)
|
||||
data.update(overrides)
|
||||
return ProviderProfile(**data)
|
||||
|
||||
|
||||
@override_settings(**OAUTH_SETTINGS)
|
||||
class PublicSettingsOAuthTestCase(APITestCase):
|
||||
def test_exposes_configured_oauth_providers(self):
|
||||
response = self.client.get(reverse("public_settings"))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data["oauth"]["google"])
|
||||
self.assertTrue(response.data["oauth"]["microsoft"])
|
||||
|
||||
@override_settings(GOOGLE_OAUTH_CLIENT_ID="", GOOGLE_OAUTH_CLIENT_SECRET="")
|
||||
def test_hides_unconfigured_provider(self):
|
||||
response = self.client.get(reverse("public_settings"))
|
||||
self.assertFalse(response.data["oauth"]["google"])
|
||||
self.assertTrue(response.data["oauth"]["microsoft"])
|
||||
|
||||
|
||||
@override_settings(**OAUTH_SETTINGS)
|
||||
class OAuthStartTestCase(APITestCase):
|
||||
def test_start_redirects_to_google(self):
|
||||
response = self.client.get(
|
||||
reverse("oauth_start", kwargs={"provider": "google"}),
|
||||
{"intent": "login"},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||
location = response["Location"]
|
||||
self.assertIn("accounts.google.com", location)
|
||||
params = parse_qs(urlparse(location).query)
|
||||
self.assertEqual(params["client_id"], ["google-client-id"])
|
||||
self.assertIn("state", params)
|
||||
self.assertTrue(
|
||||
params["redirect_uri"][0].endswith("/api/auth/oauth/google/callback/")
|
||||
)
|
||||
|
||||
def test_start_unknown_provider_404(self):
|
||||
response = self.client.get(
|
||||
reverse("oauth_start", kwargs={"provider": "apple"})
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@override_settings(ENABLE_ACCOUNT_REGISTRATION=False)
|
||||
def test_signup_intent_blocked_when_registration_disabled(self):
|
||||
response = self.client.get(
|
||||
reverse("oauth_start", kwargs={"provider": "google"}),
|
||||
{"intent": "signup"},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
@override_settings(**OAUTH_SETTINGS)
|
||||
class OAuthCallbackTestCase(APITestCase):
|
||||
def _callback(self, provider="google", intent="login", code="auth-code"):
|
||||
state = dump_oauth_state(provider=provider, intent=intent)
|
||||
return self.client.get(
|
||||
reverse("oauth_callback", kwargs={"provider": provider}),
|
||||
{"code": code, "state": state},
|
||||
)
|
||||
|
||||
def _assert_jwt_redirect(self, response, *, created: bool):
|
||||
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||
location = response["Location"]
|
||||
self.assertTrue(location.startswith("http://frontend.test/auth/callback/?"))
|
||||
params = parse_qs(urlparse(location).query)
|
||||
self.assertIn("access", params)
|
||||
self.assertIn("refresh", params)
|
||||
self.assertEqual(params["created"], ["1" if created else "0"])
|
||||
access = AccessToken(params["access"][0])
|
||||
return access, params
|
||||
|
||||
@patch("chat_backend.views_oauth.exchange_code_for_profile")
|
||||
def test_callback_creates_user_and_returns_jwt(self, mock_exchange):
|
||||
mock_exchange.return_value = _google_profile()
|
||||
|
||||
response = self._callback(intent="signup")
|
||||
access, params = self._assert_jwt_redirect(response, created=True)
|
||||
self.assertEqual(params["needs_checkout"], ["1"])
|
||||
|
||||
user = CustomUser.objects.get(email="sso.user@example.com")
|
||||
self.assertEqual(int(access["user_id"]), user.id)
|
||||
self.assertFalse(user.has_usable_password())
|
||||
self.assertTrue(user.is_company_manager)
|
||||
self.assertEqual(user.company.name, "sso.user@example.com's workspace")
|
||||
|
||||
identity = OAuthIdentity.objects.get(provider="google", subject="google-sub-1")
|
||||
self.assertEqual(identity.user_id, user.id)
|
||||
self.assertEqual(identity.refresh_token, "refresh-token")
|
||||
mock_exchange.assert_called_once()
|
||||
|
||||
@patch("chat_backend.views_oauth.exchange_code_for_profile")
|
||||
def test_callback_logs_in_existing_identity(self, mock_exchange):
|
||||
user = make_user(email="sso.user@example.com", password="pass12345")
|
||||
OAuthIdentity.objects.create(
|
||||
user=user,
|
||||
provider=OAuthIdentity.Provider.GOOGLE,
|
||||
subject="google-sub-1",
|
||||
email=user.email,
|
||||
)
|
||||
mock_exchange.return_value = _google_profile()
|
||||
|
||||
response = self._callback(intent="login")
|
||||
access, params = self._assert_jwt_redirect(response, created=False)
|
||||
self.assertEqual(params["needs_checkout"], ["0"])
|
||||
self.assertEqual(int(access["user_id"]), user.id)
|
||||
self.assertEqual(CustomUser.objects.filter(email="sso.user@example.com").count(), 1)
|
||||
|
||||
@patch("chat_backend.views_oauth.exchange_code_for_profile")
|
||||
def test_callback_links_existing_email_account(self, mock_exchange):
|
||||
user = make_user(email="sso.user@example.com", password="pass12345")
|
||||
mock_exchange.return_value = _google_profile()
|
||||
|
||||
response = self._callback(intent="login")
|
||||
access, _params = self._assert_jwt_redirect(response, created=False)
|
||||
self.assertEqual(int(access["user_id"]), user.id)
|
||||
|
||||
identity = OAuthIdentity.objects.get(provider="google", subject="google-sub-1")
|
||||
self.assertEqual(identity.user_id, user.id)
|
||||
self.assertEqual(CustomUser.objects.count(), 1)
|
||||
|
||||
@patch("chat_backend.views_oauth.exchange_code_for_profile")
|
||||
def test_callback_rejects_unverified_email(self, mock_exchange):
|
||||
mock_exchange.return_value = _google_profile(email_verified=False)
|
||||
|
||||
response = self._callback(intent="signup")
|
||||
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||
params = parse_qs(urlparse(response["Location"]).query)
|
||||
self.assertEqual(params["error"], ["email_unverified"])
|
||||
self.assertEqual(CustomUser.objects.count(), 0)
|
||||
self.assertEqual(OAuthIdentity.objects.count(), 0)
|
||||
|
||||
@patch("chat_backend.views_oauth.exchange_code_for_profile")
|
||||
def test_callback_rejects_missing_email(self, mock_exchange):
|
||||
mock_exchange.return_value = _google_profile(email="", email_verified=True)
|
||||
|
||||
response = self._callback(intent="signup")
|
||||
params = parse_qs(urlparse(response["Location"]).query)
|
||||
self.assertEqual(params["error"], ["email_missing"])
|
||||
|
||||
@patch("chat_backend.views_oauth.exchange_code_for_profile")
|
||||
@override_settings(ENABLE_ACCOUNT_REGISTRATION=False)
|
||||
def test_callback_login_without_account_when_registration_off(self, mock_exchange):
|
||||
mock_exchange.return_value = _google_profile()
|
||||
|
||||
response = self._callback(intent="login")
|
||||
params = parse_qs(urlparse(response["Location"]).query)
|
||||
self.assertEqual(params["error"], ["account_not_found"])
|
||||
self.assertEqual(CustomUser.objects.count(), 0)
|
||||
|
||||
@patch("chat_backend.views_oauth.exchange_code_for_profile")
|
||||
def test_link_conflict_when_provider_already_linked_to_other_subject(
|
||||
self, mock_exchange
|
||||
):
|
||||
user = make_user(email="sso.user@example.com", password="pass12345")
|
||||
OAuthIdentity.objects.create(
|
||||
user=user,
|
||||
provider=OAuthIdentity.Provider.GOOGLE,
|
||||
subject="other-google-sub",
|
||||
email=user.email,
|
||||
)
|
||||
mock_exchange.return_value = _google_profile(subject="google-sub-1")
|
||||
|
||||
response = self._callback(intent="login")
|
||||
params = parse_qs(urlparse(response["Location"]).query)
|
||||
self.assertEqual(params["error"], ["link_conflict"])
|
||||
|
||||
def test_provider_access_denied(self):
|
||||
response = self.client.get(
|
||||
reverse("oauth_callback", kwargs={"provider": "google"}),
|
||||
{"error": "access_denied", "error_description": "User cancelled"},
|
||||
)
|
||||
params = parse_qs(urlparse(response["Location"]).query)
|
||||
self.assertEqual(params["error"], ["access_denied"])
|
||||
|
||||
@patch("chat_backend.views_oauth.exchange_code_for_profile")
|
||||
def test_microsoft_callback_creates_user(self, mock_exchange):
|
||||
mock_exchange.return_value = ProviderProfile(
|
||||
provider=OAuthIdentity.Provider.MICROSOFT,
|
||||
subject="ms-oid-1",
|
||||
email="ms.user@example.com",
|
||||
email_verified=True,
|
||||
first_name="Ms",
|
||||
last_name="User",
|
||||
access_token="ms-access",
|
||||
refresh_token="ms-refresh",
|
||||
expires_in=3600,
|
||||
scopes="openid email profile offline_access",
|
||||
raw={},
|
||||
)
|
||||
response = self._callback(provider="microsoft", intent="signup")
|
||||
access, _params = self._assert_jwt_redirect(response, created=True)
|
||||
user = CustomUser.objects.get(email="ms.user@example.com")
|
||||
self.assertEqual(int(access["user_id"]), user.id)
|
||||
self.assertTrue(
|
||||
OAuthIdentity.objects.filter(
|
||||
provider="microsoft", subject="ms-oid-1", user=user
|
||||
).exists()
|
||||
)
|
||||
@@ -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,6 +311,7 @@ class UserInviteTestCase(APITestCase):
|
||||
def test_manager_invites_new_user_and_email_is_sent(self):
|
||||
self.client.force_authenticate(user=self.manager)
|
||||
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
response = self.client.post(
|
||||
self.url, {"email": "newhire@example.com"}, format="json"
|
||||
)
|
||||
@@ -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,6 +375,7 @@ class FeedbackViewTestCase(APITestCase):
|
||||
self.url = reverse("feedbacks")
|
||||
|
||||
def test_post_creates_feedback_and_notifies(self):
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{"title": "Broken button", "text": "It does nothing"},
|
||||
@@ -279,6 +387,9 @@ class FeedbackViewTestCase(APITestCase):
|
||||
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")
|
||||
|
||||
@@ -15,17 +15,16 @@ from .views import (
|
||||
ConversationDetailView,
|
||||
CompanyUsersView,
|
||||
SetUserPassword,
|
||||
ResetUserPassword,
|
||||
ConversationPreferences,
|
||||
UserPromptAnalytics,
|
||||
UserConversationAnalytics,
|
||||
CompanyUsageAnalytics,
|
||||
AdminAnalytics,
|
||||
reset_password,
|
||||
DocumentWorkspaceView,
|
||||
DocumentUploadView,
|
||||
DocumentDetailView,
|
||||
)
|
||||
from .views_oauth import OAuthCallbackView, OAuthStartView
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
|
||||
@@ -34,8 +33,20 @@ urlpatterns = [
|
||||
path("token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh"),
|
||||
path("user/create/", CustomUserCreate.as_view(), name="create_user"),
|
||||
path("public/settings/", PublicSettingsView.as_view(), name="public_settings"),
|
||||
path(
|
||||
"auth/oauth/<str:provider>/start/",
|
||||
OAuthStartView.as_view(),
|
||||
name="oauth_start",
|
||||
),
|
||||
path(
|
||||
"auth/oauth/<str:provider>/callback/",
|
||||
OAuthCallbackView.as_view(),
|
||||
name="oauth_callback",
|
||||
),
|
||||
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/<slug:slug>/", SetUserPassword.as_view(), name="set_password"
|
||||
),
|
||||
|
||||
+87
-118
@@ -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,)
|
||||
@@ -100,9 +107,12 @@ class PublicSettingsView(APIView):
|
||||
authentication_classes = ()
|
||||
|
||||
def get(self, request):
|
||||
from .views_oauth import oauth_public_flags
|
||||
|
||||
return Response(
|
||||
{
|
||||
"enable_account_registration": settings.ENABLE_ACCOUNT_REGISTRATION,
|
||||
"oauth": oauth_public_flags(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -138,66 +148,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 +178,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
|
||||
try:
|
||||
captcha_response = requests.post(
|
||||
"https://www.google.com/recaptcha/api/siteverify",
|
||||
data=payload,
|
||||
timeout=10,
|
||||
)
|
||||
result = response.json()
|
||||
if result.get("success") and result.get("score") >= 0.5:
|
||||
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)
|
||||
|
||||
# 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()
|
||||
|
||||
# send the email
|
||||
send_password_reset_email(user.slug, email)
|
||||
else:
|
||||
logger.error("Captcha secret failed")
|
||||
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 +249,37 @@ class SetUserPassword(APIView):
|
||||
authentication_classes = ()
|
||||
|
||||
def get(self, request, 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)
|
||||
|
||||
def post(self, request, slug, format="json"):
|
||||
try:
|
||||
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()
|
||||
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 +314,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)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""OAuth SSO start + callback views (#24)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from .models import OAuthIdentity
|
||||
from .oauth import (
|
||||
OAuthError,
|
||||
build_authorization_url,
|
||||
configured_providers,
|
||||
dump_oauth_state,
|
||||
exchange_code_for_profile,
|
||||
load_oauth_state,
|
||||
provider_configured,
|
||||
resolve_user_from_profile,
|
||||
upsert_identity,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _callback_redirect_uri(request, provider: str) -> str:
|
||||
"""Absolute backend callback URL registered with the IdP."""
|
||||
path = reverse("oauth_callback", kwargs={"provider": provider})
|
||||
base = (settings.OAUTH_CALLBACK_BASE_URL or "").rstrip("/")
|
||||
if base:
|
||||
return f"{base}{path}"
|
||||
return request.build_absolute_uri(path)
|
||||
|
||||
|
||||
def _frontend_callback_url(**params: str) -> str:
|
||||
base = settings.FRONTEND_BASE_URL.rstrip("/")
|
||||
query = urlencode({k: v for k, v in params.items() if v is not None and v != ""})
|
||||
return f"{base}/auth/callback/?{query}"
|
||||
|
||||
|
||||
def _redirect_error(code: str, message: str = "") -> HttpResponseRedirect:
|
||||
return HttpResponseRedirect(
|
||||
_frontend_callback_url(error=code, error_description=message or code)
|
||||
)
|
||||
|
||||
|
||||
class OAuthStartView(APIView):
|
||||
"""Redirect the browser to Google / Microsoft authorize URL."""
|
||||
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
authentication_classes = ()
|
||||
http_method_names = ["get"]
|
||||
|
||||
def get(self, request, provider: str):
|
||||
provider = (provider or "").lower()
|
||||
if provider not in OAuthIdentity.Provider.values:
|
||||
return Response(
|
||||
{"detail": "Unsupported OAuth provider."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
if not provider_configured(provider):
|
||||
return Response(
|
||||
{"detail": f"{provider} OAuth is not configured."},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
intent = (request.query_params.get("intent") or "login").lower()
|
||||
if intent not in {"login", "signup"}:
|
||||
return Response(
|
||||
{"detail": "intent must be 'login' or 'signup'."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if intent == "signup" and not settings.ENABLE_ACCOUNT_REGISTRATION:
|
||||
return Response(
|
||||
{"detail": "Account registration is disabled."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
state = dump_oauth_state(provider=provider, intent=intent)
|
||||
redirect_uri = _callback_redirect_uri(request, provider)
|
||||
try:
|
||||
auth_url = build_authorization_url(
|
||||
provider=provider, redirect_uri=redirect_uri, state=state
|
||||
)
|
||||
except OAuthError as exc:
|
||||
return Response({"detail": exc.message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return HttpResponseRedirect(auth_url)
|
||||
|
||||
|
||||
class OAuthCallbackView(APIView):
|
||||
"""IdP redirect target — exchange code, create/link user, send JWTs to FE."""
|
||||
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
authentication_classes = ()
|
||||
http_method_names = ["get"]
|
||||
|
||||
def get(self, request, provider: str):
|
||||
provider = (provider or "").lower()
|
||||
if provider not in OAuthIdentity.Provider.values:
|
||||
return _redirect_error("invalid_provider", "Unsupported OAuth provider.")
|
||||
|
||||
error = request.query_params.get("error")
|
||||
if error:
|
||||
description = request.query_params.get("error_description") or error
|
||||
code = "access_denied" if error == "access_denied" else "provider_error"
|
||||
return _redirect_error(code, description)
|
||||
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
if not code or not state:
|
||||
return _redirect_error("missing_code", "Missing OAuth code or state.")
|
||||
|
||||
try:
|
||||
state_data = load_oauth_state(state)
|
||||
if state_data["provider"] != provider:
|
||||
raise OAuthError("invalid_state", "OAuth provider mismatch.")
|
||||
|
||||
redirect_uri = _callback_redirect_uri(request, provider)
|
||||
profile = exchange_code_for_profile(
|
||||
provider=provider, code=code, redirect_uri=redirect_uri
|
||||
)
|
||||
user, created = resolve_user_from_profile(
|
||||
profile=profile, intent=state_data["intent"]
|
||||
)
|
||||
# Refresh stored tokens on every successful login.
|
||||
upsert_identity(user, profile)
|
||||
except OAuthError as exc:
|
||||
logger.info("OAuth callback failed (%s): %s", exc.code, exc.message)
|
||||
return _redirect_error(exc.code, exc.message)
|
||||
except Exception:
|
||||
logger.exception("Unexpected OAuth callback failure")
|
||||
return _redirect_error("server_error", "Unexpected OAuth error.")
|
||||
|
||||
refresh = RefreshToken.for_user(user)
|
||||
needs_checkout = "1" if created else "0"
|
||||
return HttpResponseRedirect(
|
||||
_frontend_callback_url(
|
||||
access=str(refresh.access_token),
|
||||
refresh=str(refresh),
|
||||
created="1" if created else "0",
|
||||
needs_checkout=needs_checkout,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def oauth_public_flags() -> dict:
|
||||
"""Feature flags for /public/settings/."""
|
||||
return configured_providers()
|
||||
@@ -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"})
|
||||
@@ -301,6 +309,19 @@ ALLOW_INTERNET_ACCESS = env_bool("ALLOW_INTERNET_ACCESS", True)
|
||||
# control-node secret (chat_backend_<env>.env) when ready for public sign-up.
|
||||
ENABLE_ACCOUNT_REGISTRATION = env_bool("ENABLE_ACCOUNT_REGISTRATION", False)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAuth SSO (Google / Microsoft) — #24
|
||||
# ---------------------------------------------------------------------------
|
||||
GOOGLE_OAUTH_CLIENT_ID = env("GOOGLE_OAUTH_CLIENT_ID", "") or ""
|
||||
GOOGLE_OAUTH_CLIENT_SECRET = env("GOOGLE_OAUTH_CLIENT_SECRET", "") or ""
|
||||
MICROSOFT_OAUTH_CLIENT_ID = env("MICROSOFT_OAUTH_CLIENT_ID", "") or ""
|
||||
MICROSOFT_OAUTH_CLIENT_SECRET = env("MICROSOFT_OAUTH_CLIENT_SECRET", "") or ""
|
||||
# Azure AD tenant: "common" (personal + work), "organizations", or a tenant ID.
|
||||
MICROSOFT_OAUTH_TENANT = env("MICROSOFT_OAUTH_TENANT", "common") or "common"
|
||||
# Public backend origin for IdP redirect URIs (e.g. https://chatbackend.aimloperations.com).
|
||||
# When empty, callback URLs are built from the incoming request.
|
||||
OAUTH_CALLBACK_BASE_URL = (env("OAUTH_CALLBACK_BASE_URL", "") or "").rstrip("/")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finance / Stripe (subscription billing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user