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/)
|
# Self-serve sign-up (default false — set true to allow /user/create/)
|
||||||
ENABLE_ACCOUNT_REGISTRATION=false
|
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 / finance (optional local — required for checkout + webhooks)
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
STRIPE_PUBLISHABLE_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.
|
# public registration; set true in chat_backend_prod.env / chat_backend_beta.env.
|
||||||
ENABLE_ACCOUNT_REGISTRATION=false
|
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 / finance
|
||||||
STRIPE_SECRET_KEY=replace-with-stripe-secret-key
|
STRIPE_SECRET_KEY=replace-with-stripe-secret-key
|
||||||
STRIPE_PUBLISHABLE_KEY=replace-with-stripe-publishable-key
|
STRIPE_PUBLISHABLE_KEY=replace-with-stripe-publishable-key
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ from .models import (
|
|||||||
PromptMetric,
|
PromptMetric,
|
||||||
DocumentWorkspace,
|
DocumentWorkspace,
|
||||||
Document,
|
Document,
|
||||||
|
UserAuthEvent,
|
||||||
|
OutboundEmail,
|
||||||
|
OAuthIdentity,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
|
|
||||||
|
|
||||||
class AnnouncmentAdmin(admin.ModelAdmin):
|
class AnnouncmentAdmin(admin.ModelAdmin):
|
||||||
model = Announcement
|
model = Announcement
|
||||||
@@ -24,6 +25,32 @@ class CompanyAdmin(admin.ModelAdmin):
|
|||||||
model = Company
|
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):
|
class CustomUserAdmin(admin.ModelAdmin):
|
||||||
model = CustomUser
|
model = CustomUser
|
||||||
list_display = (
|
list_display = (
|
||||||
@@ -41,7 +68,63 @@ class CustomUserAdmin(admin.ModelAdmin):
|
|||||||
"slug",
|
"slug",
|
||||||
"get_set_password_url",
|
"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):
|
class FeedbackAdmin(admin.ModelAdmin):
|
||||||
@@ -59,6 +142,7 @@ class LLMModelsAdmin(admin.ModelAdmin):
|
|||||||
class PromptInline(admin.TabularInline):
|
class PromptInline(admin.TabularInline):
|
||||||
model = Prompt
|
model = Prompt
|
||||||
|
|
||||||
|
|
||||||
class ConversationAdmin(admin.ModelAdmin):
|
class ConversationAdmin(admin.ModelAdmin):
|
||||||
model = Conversation
|
model = Conversation
|
||||||
list_display = (
|
list_display = (
|
||||||
@@ -70,12 +154,14 @@ class ConversationAdmin(admin.ModelAdmin):
|
|||||||
"tokens_total",
|
"tokens_total",
|
||||||
)
|
)
|
||||||
search_fields = ("title",)
|
search_fields = ("title",)
|
||||||
inlines = [PromptInline,]
|
inlines = [
|
||||||
|
PromptInline,
|
||||||
|
]
|
||||||
|
|
||||||
def _token_sum(self, conversation, field):
|
def _token_sum(self, conversation, field):
|
||||||
total = PromptMetric.objects.filter(
|
total = PromptMetric.objects.filter(conversation_id=conversation.id).aggregate(
|
||||||
conversation_id=conversation.id
|
total=Sum(field)
|
||||||
).aggregate(total=Sum(field))["total"]
|
)["total"]
|
||||||
return total or 0
|
return total or 0
|
||||||
|
|
||||||
@admin.display(description="Tokens in")
|
@admin.display(description="Tokens in")
|
||||||
@@ -93,7 +179,7 @@ class ConversationAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
class PromptAdmin(admin.ModelAdmin):
|
class PromptAdmin(admin.ModelAdmin):
|
||||||
model = Prompt
|
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",)
|
search_fields = ("message",)
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +196,7 @@ class PromptMetricAdmin(admin.ModelAdmin):
|
|||||||
"has_file",
|
"has_file",
|
||||||
"file_type",
|
"file_type",
|
||||||
"get_duration",
|
"get_duration",
|
||||||
"created"
|
"created",
|
||||||
)
|
)
|
||||||
list_filter = ("event", "model_name", "has_file")
|
list_filter = ("event", "model_name", "has_file")
|
||||||
|
|
||||||
@@ -136,6 +222,8 @@ class DocumentAdmin(admin.ModelAdmin):
|
|||||||
admin.site.register(Announcement, AnnouncmentAdmin)
|
admin.site.register(Announcement, AnnouncmentAdmin)
|
||||||
admin.site.register(Company, CompanyAdmin)
|
admin.site.register(Company, CompanyAdmin)
|
||||||
admin.site.register(CustomUser, CustomUserAdmin)
|
admin.site.register(CustomUser, CustomUserAdmin)
|
||||||
|
admin.site.register(UserAuthEvent, UserAuthEventAdmin)
|
||||||
|
admin.site.register(OutboundEmail, OutboundEmailAdmin)
|
||||||
|
|
||||||
admin.site.register(LLMModels, LLMModelsAdmin)
|
admin.site.register(LLMModels, LLMModelsAdmin)
|
||||||
admin.site.register(Conversation, ConversationAdmin)
|
admin.site.register(Conversation, ConversationAdmin)
|
||||||
@@ -145,3 +233,22 @@ admin.site.register(Feedback, FeedbackAdmin)
|
|||||||
|
|
||||||
admin.site.register(DocumentWorkspace, DocumentWorkspaceAdmin)
|
admin.site.register(DocumentWorkspace, DocumentWorkspaceAdmin)
|
||||||
admin.site.register(Document, DocumentAdmin)
|
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 django.utils import timezone
|
||||||
from autoslug import AutoSlugField
|
from autoslug import AutoSlugField
|
||||||
from chat_backend.storage import DatabaseStorage
|
from chat_backend.storage import DatabaseStorage
|
||||||
|
import uuid
|
||||||
|
|
||||||
# Create your models here.
|
# Create your models here.
|
||||||
|
|
||||||
@@ -74,7 +75,132 @@ class CustomUser(AbstractUser):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_set_password_url(self):
|
def get_set_password_url(self):
|
||||||
return f"https://chat.aimloperations.com/set_password?slug={self.slug}"
|
from django.conf import settings
|
||||||
|
|
||||||
|
base = settings.FRONTEND_BASE_URL.rstrip("/")
|
||||||
|
return f"{base}/set_password/?slug={self.slug}"
|
||||||
|
|
||||||
|
|
||||||
|
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 = (
|
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>
|
{% extends "emails/base_email.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>
|
|
||||||
|
|
||||||
<!-- Content -->
|
{% block title %}New {{ site_name }} feedback{% endblock %}
|
||||||
<div class="content">
|
|
||||||
<p>Hello,</p>
|
|
||||||
<p>A new feedback item has been submitted. Here are the details:</p>
|
|
||||||
|
|
||||||
<!-- Feedback Title -->
|
{% block content %}
|
||||||
<div class="feedback-title">
|
<h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">New feedback</h1>
|
||||||
Title: <strong>{{ title }}</strong>
|
<p style="margin:0 0 16px 0;">A new feedback item was submitted in {{ site_name }}.</p>
|
||||||
</div>
|
<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>
|
||||||
<!-- Feedback Text -->
|
<div style="font-size:16px;font-weight:600;color:#ffffff;">{{ title }}</div>
|
||||||
<div class="feedback-text">
|
</div>
|
||||||
<strong>Feedback:</strong><br>
|
<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);">
|
||||||
{{ feedback_text }}
|
<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>
|
<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>
|
{% endblock %}
|
||||||
</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>
|
|
||||||
|
|||||||
@@ -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>
|
{% extends "emails/base_email.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
{% block title %}Welcome to {{ site_name }}{% endblock %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{% block content %}
|
||||||
<title>Invitation to Chat by AI ML Operations, LLC</title>
|
<h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">You're invited</h1>
|
||||||
<style>
|
<p style="margin:0 0 16px 0;">Hello,</p>
|
||||||
/* Basic reset for email clients */
|
<p style="margin:0 0 16px 0;">
|
||||||
body, table, td, a {
|
You have been invited to {{ site_name }} by {{ company_name }}.
|
||||||
-webkit-text-size-adjust: 100%;
|
Set a password to activate your account and start chatting.
|
||||||
-ms-text-size-adjust: 100%;
|
</p>
|
||||||
}
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:24px 0;">
|
||||||
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>
|
<tr>
|
||||||
<td>
|
<td align="center" style="border-radius:8px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);">
|
||||||
<!-- Email Container -->
|
<a href="{{ url }}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">
|
||||||
<div class="email-container">
|
Set your password
|
||||||
<!-- Header -->
|
</a>
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
<p style="margin:0 0 12px 0;font-size:13px;color:rgba(255,255,255,0.55);word-break:break-all;">
|
||||||
</html>
|
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>
|
{% extends "emails/base_email.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
{% block title %}Reset your {{ site_name }} password{% endblock %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{% block content %}
|
||||||
<title>Reset Password for Chat by AI ML Operations, LLC</title>
|
<h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">Reset your password</h1>
|
||||||
<style>
|
<p style="margin:0 0 16px 0;">Hello,</p>
|
||||||
/* Basic reset for email clients */
|
<p style="margin:0 0 16px 0;">
|
||||||
body, table, td, a {
|
We received a request to reset the password for your {{ site_name }} account.
|
||||||
-webkit-text-size-adjust: 100%;
|
If you did not make this request, you can ignore this email or contact
|
||||||
-ms-text-size-adjust: 100%;
|
<a href="mailto:{{ support_email }}" style="color:#a78bfa;text-decoration:none;">{{ support_email }}</a>.
|
||||||
}
|
</p>
|
||||||
table, td {
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:24px 0;">
|
||||||
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>
|
<tr>
|
||||||
<td>
|
<td align="center" style="border-radius:8px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);">
|
||||||
<!-- Email Container -->
|
<a href="{{ url }}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">
|
||||||
<div class="email-container">
|
Set a new password
|
||||||
<!-- Header -->
|
</a>
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
<p style="margin:0 0 12px 0;font-size:13px;color:rgba(255,255,255,0.55);word-break:break-all;">
|
||||||
</html>
|
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")
|
user = make_user(email="person@example.com")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
user.get_set_password_url(),
|
user.get_set_password_url(),
|
||||||
f"https://chat.aimloperations.com/set_password?slug={user.slug}",
|
f"http://localhost:3000/set_password/?slug={user.slug}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_user_defaults(self):
|
def test_user_defaults(self):
|
||||||
|
|||||||
@@ -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.core import mail
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from unittest import mock
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
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
|
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)
|
self.assertEqual(self.client.get(url).status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_post_sets_password(self):
|
def test_post_sets_password(self):
|
||||||
|
self.user.set_unusable_password()
|
||||||
|
self.user.save()
|
||||||
url = reverse("set_password", kwargs={"slug": self.user.slug})
|
url = reverse("set_password", kwargs={"slug": self.user.slug})
|
||||||
|
|
||||||
response = self.client.post(url, {"password": "brandnewpass"}, format="json")
|
response = self.client.post(url, {"password": "brandnewpass"}, format="json")
|
||||||
@@ -186,6 +195,97 @@ class SetPasswordTestCase(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
self.assertTrue(self.user.check_password("brandnewpass"))
|
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):
|
class AcknowledgeTermsOfServiceTestCase(APITestCase):
|
||||||
@@ -211,9 +311,10 @@ class UserInviteTestCase(APITestCase):
|
|||||||
def test_manager_invites_new_user_and_email_is_sent(self):
|
def test_manager_invites_new_user_and_email_is_sent(self):
|
||||||
self.client.force_authenticate(user=self.manager)
|
self.client.force_authenticate(user=self.manager)
|
||||||
|
|
||||||
response = self.client.post(
|
with self.captureOnCommitCallbacks(execute=True):
|
||||||
self.url, {"email": "newhire@example.com"}, format="json"
|
response = self.client.post(
|
||||||
)
|
self.url, {"email": "newhire@example.com"}, format="json"
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
invited = CustomUser.objects.get(email="newhire@example.com")
|
invited = CustomUser.objects.get(email="newhire@example.com")
|
||||||
@@ -221,7 +322,13 @@ class UserInviteTestCase(APITestCase):
|
|||||||
self.assertEqual(invited.username, "newhire@example.com")
|
self.assertEqual(invited.username, "newhire@example.com")
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
self.assertIn(invited.slug, mail.outbox[0].body)
|
self.assertIn(invited.slug, mail.outbox[0].body)
|
||||||
|
self.assertIn("Hesychia", mail.outbox[0].subject)
|
||||||
self.assertEqual(mail.outbox[0].to, ["newhire@example.com"])
|
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):
|
def test_non_manager_cannot_invite(self):
|
||||||
member = make_user(email="member@example.com", company=self.company)
|
member = make_user(email="member@example.com", company=self.company)
|
||||||
@@ -268,17 +375,21 @@ class FeedbackViewTestCase(APITestCase):
|
|||||||
self.url = reverse("feedbacks")
|
self.url = reverse("feedbacks")
|
||||||
|
|
||||||
def test_post_creates_feedback_and_notifies(self):
|
def test_post_creates_feedback_and_notifies(self):
|
||||||
response = self.client.post(
|
with self.captureOnCommitCallbacks(execute=True):
|
||||||
self.url,
|
response = self.client.post(
|
||||||
{"title": "Broken button", "text": "It does nothing"},
|
self.url,
|
||||||
format="json",
|
{"title": "Broken button", "text": "It does nothing"},
|
||||||
)
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
feedback = Feedback.objects.get()
|
feedback = Feedback.objects.get()
|
||||||
self.assertEqual(feedback.user, self.user)
|
self.assertEqual(feedback.user, self.user)
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
self.assertIn("Broken button", mail.outbox[0].body)
|
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):
|
def test_post_without_text_is_rejected(self):
|
||||||
response = self.client.post(self.url, {"title": "no body"}, format="json")
|
response = self.client.post(self.url, {"title": "no body"}, format="json")
|
||||||
|
|||||||
@@ -15,17 +15,16 @@ from .views import (
|
|||||||
ConversationDetailView,
|
ConversationDetailView,
|
||||||
CompanyUsersView,
|
CompanyUsersView,
|
||||||
SetUserPassword,
|
SetUserPassword,
|
||||||
ResetUserPassword,
|
|
||||||
ConversationPreferences,
|
ConversationPreferences,
|
||||||
UserPromptAnalytics,
|
UserPromptAnalytics,
|
||||||
UserConversationAnalytics,
|
UserConversationAnalytics,
|
||||||
CompanyUsageAnalytics,
|
CompanyUsageAnalytics,
|
||||||
AdminAnalytics,
|
AdminAnalytics,
|
||||||
reset_password,
|
|
||||||
DocumentWorkspaceView,
|
DocumentWorkspaceView,
|
||||||
DocumentUploadView,
|
DocumentUploadView,
|
||||||
DocumentDetailView,
|
DocumentDetailView,
|
||||||
)
|
)
|
||||||
|
from .views_oauth import OAuthCallbackView, OAuthStartView
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
|
||||||
@@ -34,8 +33,20 @@ urlpatterns = [
|
|||||||
path("token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh"),
|
path("token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh"),
|
||||||
path("user/create/", CustomUserCreate.as_view(), name="create_user"),
|
path("user/create/", CustomUserCreate.as_view(), name="create_user"),
|
||||||
path("public/settings/", PublicSettingsView.as_view(), name="public_settings"),
|
path("public/settings/", PublicSettingsView.as_view(), name="public_settings"),
|
||||||
|
path(
|
||||||
|
"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/invite/", CustomUserInvite.as_view(), name="invite_user"),
|
||||||
path("user/reset_password/", reset_password, name="reset_password"),
|
path(
|
||||||
|
"user/reset_password/", ResetUserPassword.as_view(), name="reset_password"
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"user/set_password/<slug:slug>/", SetUserPassword.as_view(), name="set_password"
|
"user/set_password/<slug:slug>/", SetUserPassword.as_view(), name="set_password"
|
||||||
),
|
),
|
||||||
|
|||||||
+93
-124
@@ -28,6 +28,7 @@ from .models import (
|
|||||||
PromptMetric,
|
PromptMetric,
|
||||||
DocumentWorkspace,
|
DocumentWorkspace,
|
||||||
Document,
|
Document,
|
||||||
|
UserAuthEvent,
|
||||||
)
|
)
|
||||||
from django.views.decorators.cache import never_cache
|
from django.views.decorators.cache import never_cache
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
@@ -47,12 +48,6 @@ import pandas as pd
|
|||||||
import io
|
import io
|
||||||
from chat_backend.services.assistant_identity import ASSISTANT_SYSTEM_PROMPT
|
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.utils import timezone
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
@@ -62,9 +57,14 @@ import pytz
|
|||||||
from langchain_ollama import OllamaEmbeddings
|
from langchain_ollama import OllamaEmbeddings
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
import requests
|
||||||
|
|
||||||
from .utils import last_day_of_month
|
from .utils import last_day_of_month
|
||||||
|
from .email_tasks import (
|
||||||
|
send_feedback_email,
|
||||||
|
send_invite_email,
|
||||||
|
send_password_reset_email,
|
||||||
|
)
|
||||||
from .services.llm_service import AsyncLLMService
|
from .services.llm_service import AsyncLLMService
|
||||||
from .services.rag_services import AsyncRAGService
|
from .services.rag_services import AsyncRAGService
|
||||||
from .services.title_generator import title_generator
|
from .services.title_generator import title_generator
|
||||||
@@ -87,6 +87,13 @@ CHANNEL_NAME: str = "llm_messages"
|
|||||||
MODEL_NAME: str = ollama_model()
|
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.
|
# Create your views here.
|
||||||
class CustomObtainTokenView(TokenObtainPairView):
|
class CustomObtainTokenView(TokenObtainPairView):
|
||||||
permission_classes = (permissions.AllowAny,)
|
permission_classes = (permissions.AllowAny,)
|
||||||
@@ -100,9 +107,12 @@ class PublicSettingsView(APIView):
|
|||||||
authentication_classes = ()
|
authentication_classes = ()
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
from .views_oauth import oauth_public_flags
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"enable_account_registration": settings.ENABLE_ACCOUNT_REGISTRATION,
|
"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):
|
class CustomUserInvite(APIView):
|
||||||
http_method_names = ["post"]
|
http_method_names = ["post"]
|
||||||
|
|
||||||
@@ -228,72 +178,68 @@ class CustomUserInvite(APIView):
|
|||||||
company=request.user.company,
|
company=request.user.company,
|
||||||
)
|
)
|
||||||
|
|
||||||
# send an email
|
send_invite_email(user.slug, email_to_invite, user=user)
|
||||||
send_invite_email(user.slug, email_to_invite)
|
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)
|
return Response(status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
def reset_password(request):
|
|
||||||
if request.method == "POST":
|
|
||||||
data = json.loads(request.body)
|
|
||||||
token = data.get("recaptchaToken")
|
|
||||||
payload = {
|
|
||||||
"secret": settings.CAPTCHA_SECRET_KEY,
|
|
||||||
"response": token,
|
|
||||||
}
|
|
||||||
response = requests.post(
|
|
||||||
"https://www.google.com/recaptcha/api/siteverify", data=payload
|
|
||||||
)
|
|
||||||
result = response.json()
|
|
||||||
if result.get("success") and result.get("score") >= 0.5:
|
|
||||||
email = data.get("email")
|
|
||||||
user = CustomUser.objects.filter(email=email).first()
|
|
||||||
if user:
|
|
||||||
user.set_unusable_password()
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
# send the email
|
|
||||||
send_password_reset_email(user.slug, email)
|
|
||||||
JsonResponse(status=200)
|
|
||||||
|
|
||||||
JsonResponse(status=400)
|
|
||||||
|
|
||||||
|
|
||||||
class ResetUserPassword(APIView):
|
class ResetUserPassword(APIView):
|
||||||
http_method_names = [
|
"""Request a password-reset email. Invalidates the current password when sent."""
|
||||||
"post",
|
|
||||||
]
|
http_method_names = ["post"]
|
||||||
permission_classes = (permissions.AllowAny,)
|
permission_classes = (permissions.AllowAny,)
|
||||||
authentication_classes = ()
|
authentication_classes = ()
|
||||||
|
|
||||||
def post(self, request, format="json"):
|
def post(self, request, format="json"):
|
||||||
"""
|
logger.info("Password reset requested")
|
||||||
Send an email with a set password link to the set password page
|
email = request.data.get("email")
|
||||||
Also disable the account
|
|
||||||
"""
|
|
||||||
logger.info(f"Password reset for requests. {request.data}")
|
|
||||||
token = request.data.get("recaptchaToken")
|
token = request.data.get("recaptchaToken")
|
||||||
|
if not email:
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"secret": settings.CAPTCHA_SECRET_KEY,
|
"secret": settings.CAPTCHA_SECRET_KEY,
|
||||||
"response": recaptchaToken,
|
"response": token,
|
||||||
}
|
}
|
||||||
response = requests.post(
|
try:
|
||||||
"https://www.google.com/recaptcha/api/siteverify", data=payload
|
captcha_response = requests.post(
|
||||||
)
|
"https://www.google.com/recaptcha/api/siteverify",
|
||||||
result = response.json()
|
data=payload,
|
||||||
if result.get("success") and result.get("score") >= 0.5:
|
timeout=10,
|
||||||
user = CustomUser.objects.filter(email=email).first()
|
)
|
||||||
if user:
|
result = captcha_response.json()
|
||||||
user.set_unusable_password()
|
except requests.RequestException as exc:
|
||||||
user.save()
|
logger.error("Captcha verification request failed: %s", exc)
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# send the email
|
# v2 invisible returns success only; v3 also returns a score.
|
||||||
send_password_reset_email(user.slug, email)
|
if not result.get("success"):
|
||||||
else:
|
logger.error("Captcha verification failed: %s", result)
|
||||||
logger.error("Captcha secret failed")
|
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)
|
return Response(status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
@@ -303,16 +249,37 @@ class SetUserPassword(APIView):
|
|||||||
authentication_classes = ()
|
authentication_classes = ()
|
||||||
|
|
||||||
def get(self, request, slug):
|
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():
|
if user.has_usable_password():
|
||||||
return Response(status=status.HTTP_401_UNAUTHORIZED)
|
return Response(status=status.HTTP_401_UNAUTHORIZED)
|
||||||
else:
|
return Response(status=status.HTTP_200_OK)
|
||||||
return Response(status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
def post(self, request, slug, format="json"):
|
def post(self, request, slug, format="json"):
|
||||||
user = CustomUser.objects.get(slug=slug)
|
try:
|
||||||
user.set_password(request.data["password"])
|
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()
|
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)
|
return Response(status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
@@ -347,7 +314,9 @@ class FeedbackView(APIView):
|
|||||||
feedback_obj.user = request.user
|
feedback_obj.user = request.user
|
||||||
|
|
||||||
feedback_obj.save()
|
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)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
else:
|
else:
|
||||||
logger.error(serializer.errors)
|
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_PORT = int(env("EMAIL_PORT", "2525") or "2525")
|
||||||
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True)
|
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 ""
|
CAPTCHA_SECRET_KEY = env("CAPTCHA_SECRET_KEY", "") or ""
|
||||||
|
|
||||||
USE_TLS_PROXY = env_bool("USE_TLS_PROXY", DJANGO_ENV in {"prod", "beta"})
|
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.
|
# control-node secret (chat_backend_<env>.env) when ready for public sign-up.
|
||||||
ENABLE_ACCOUNT_REGISTRATION = env_bool("ENABLE_ACCOUNT_REGISTRATION", False)
|
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)
|
# Finance / Stripe (subscription billing)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user