Compare commits

...
2 Commits
Author SHA1 Message Date
westfarn be6ff471ad Add Google/Microsoft SSO OAuth for register and sign-in (#24)
CI / test (pull_request) Failing after 7s
Unit Tests / test (pull_request) Failing after 6s
Introduce OAuthIdentity storage, start/callback endpoints, JWT handoff to
the SPA, and mocked IdP tests so Drive OAuth (#11) can reuse the same model.
2026-07-27 07:09:02 -05:00
westfarn 16442b336c Fix password reset flow (#1) (#27)
Unit Tests / test (push) Successful in 9s
## Summary
- Closes #1
- Replace broken `csrf_exempt` `reset_password` FBV (responses never returned; missing `requests` import) with working DRF `ResetUserPassword`
- Deduplicate reset email helper; build set-password links from `FRONTEND_BASE_URL`
- Harden `SetUserPassword`: require unusable password, min 8 chars, handle missing slug
- Accept reCAPTCHA v2 (success only) and v3 (score ≥ 0.5); avoid email enumeration (200 after valid captcha)
- Add unit tests for reset + set-password edge cases

## Test plan
- [ ] `manage.py test chat_backend.tests.test_views_users.ResetPasswordTestCase chat_backend.tests.test_views_users.SetPasswordTestCase`
- [ ] With SMTP configured: request reset for known email → receive link → set password → sign in
- [ ] Unknown email still returns 200 and sends no mail
- [ ] Failed captcha returns 400
- [ ] Pair with chat_web_app `feature/password-reset-1` PRReviewed-on: #27
2026-07-27 05:07:42 -07:00
23 changed files with 1700 additions and 442 deletions
+12
View File
@@ -28,6 +28,18 @@ CAPTCHA_SECRET_KEY=
# Self-serve sign-up (default false — set true to allow /user/create/)
ENABLE_ACCOUNT_REGISTRATION=false
# OAuth SSO — Google / Microsoft (#24). Leave blank to hide SSO buttons.
# Redirect URIs (register in each IdP console):
# {OAUTH_CALLBACK_BASE_URL}/api/auth/oauth/google/callback/
# {OAUTH_CALLBACK_BASE_URL}/api/auth/oauth/microsoft/callback/
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
MICROSOFT_OAUTH_CLIENT_ID=
MICROSOFT_OAUTH_CLIENT_SECRET=
MICROSOFT_OAUTH_TENANT=common
# Optional; defaults to request host. Example local: http://127.0.0.1:8001
OAUTH_CALLBACK_BASE_URL=http://127.0.0.1:8001
# Stripe / finance (optional local — required for checkout + webhooks)
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
+11
View File
@@ -49,6 +49,17 @@ CAPTCHA_SECRET_KEY=replace-with-captcha-secret
# public registration; set true in chat_backend_prod.env / chat_backend_beta.env.
ENABLE_ACCOUNT_REGISTRATION=false
# OAuth SSO — Google / Microsoft (#24). Never commit real secrets.
# Register redirect URIs:
# https://chatbackend.aimloperations.com/api/auth/oauth/google/callback/
# https://chatbackend.aimloperations.com/api/auth/oauth/microsoft/callback/
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
MICROSOFT_OAUTH_CLIENT_ID=
MICROSOFT_OAUTH_CLIENT_SECRET=
MICROSOFT_OAUTH_TENANT=common
OAUTH_CALLBACK_BASE_URL=https://chatbackend.aimloperations.com
# Stripe / finance
STRIPE_SECRET_KEY=replace-with-stripe-secret-key
STRIPE_PUBLISHABLE_KEY=replace-with-stripe-publishable-key
+116 -9
View File
@@ -11,10 +11,11 @@ from .models import (
PromptMetric,
DocumentWorkspace,
Document,
UserAuthEvent,
OutboundEmail,
OAuthIdentity,
)
# Register your models here.
class AnnouncmentAdmin(admin.ModelAdmin):
model = Announcement
@@ -24,6 +25,32 @@ class CompanyAdmin(admin.ModelAdmin):
model = Company
class UserAuthEventInline(admin.TabularInline):
model = UserAuthEvent
extra = 0
can_delete = False
fields = ("created", "event_type", "detail", "ip_address")
readonly_fields = ("created", "event_type", "detail", "ip_address")
ordering = ("-created",)
show_change_link = False
def has_add_permission(self, request, obj=None):
return False
class OutboundEmailInline(admin.TabularInline):
model = OutboundEmail
extra = 0
can_delete = False
fields = ("created", "kind", "status", "subject", "to_email", "sent_at")
readonly_fields = ("created", "kind", "status", "subject", "to_email", "sent_at")
ordering = ("-created",)
show_change_link = True
def has_add_permission(self, request, obj=None):
return False
class CustomUserAdmin(admin.ModelAdmin):
model = CustomUser
list_display = (
@@ -41,7 +68,63 @@ class CustomUserAdmin(admin.ModelAdmin):
"slug",
"get_set_password_url",
)
search_fields = ("fields", "username", "first_name", "last_name", "slug")
search_fields = ("email", "username", "first_name", "last_name", "slug")
readonly_fields = ("last_login", "date_joined", "get_set_password_url", "slug")
inlines = (UserAuthEventInline, OutboundEmailInline)
class UserAuthEventAdmin(admin.ModelAdmin):
model = UserAuthEvent
list_display = ("created", "user", "event_type", "detail", "ip_address")
list_filter = ("event_type",)
search_fields = ("user__email", "user__username", "detail", "ip_address")
readonly_fields = ("user", "event_type", "created", "detail", "ip_address")
ordering = ("-created",)
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
class OutboundEmailAdmin(admin.ModelAdmin):
model = OutboundEmail
list_display = (
"created",
"kind",
"status",
"subject",
"to_email",
"user",
"sent_at",
)
list_filter = ("kind", "status")
search_fields = ("to_email", "subject", "user__email", "id")
readonly_fields = (
"id",
"kind",
"status",
"to_email",
"from_email",
"subject",
"html_template",
"text_template",
"context",
"error_message",
"user",
"created",
"updated",
"sent_at",
)
ordering = ("-created",)
date_hierarchy = "created"
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
class FeedbackAdmin(admin.ModelAdmin):
@@ -59,6 +142,7 @@ class LLMModelsAdmin(admin.ModelAdmin):
class PromptInline(admin.TabularInline):
model = Prompt
class ConversationAdmin(admin.ModelAdmin):
model = Conversation
list_display = (
@@ -70,12 +154,14 @@ class ConversationAdmin(admin.ModelAdmin):
"tokens_total",
)
search_fields = ("title",)
inlines = [PromptInline,]
inlines = [
PromptInline,
]
def _token_sum(self, conversation, field):
total = PromptMetric.objects.filter(
conversation_id=conversation.id
).aggregate(total=Sum(field))["total"]
total = PromptMetric.objects.filter(conversation_id=conversation.id).aggregate(
total=Sum(field)
)["total"]
return total or 0
@admin.display(description="Tokens in")
@@ -93,7 +179,7 @@ class ConversationAdmin(admin.ModelAdmin):
class PromptAdmin(admin.ModelAdmin):
model = Prompt
list_display = ("id","message", "user_created", "get_conversation_title","created")
list_display = ("id", "message", "user_created", "get_conversation_title", "created")
search_fields = ("message",)
@@ -110,7 +196,7 @@ class PromptMetricAdmin(admin.ModelAdmin):
"has_file",
"file_type",
"get_duration",
"created"
"created",
)
list_filter = ("event", "model_name", "has_file")
@@ -136,6 +222,8 @@ class DocumentAdmin(admin.ModelAdmin):
admin.site.register(Announcement, AnnouncmentAdmin)
admin.site.register(Company, CompanyAdmin)
admin.site.register(CustomUser, CustomUserAdmin)
admin.site.register(UserAuthEvent, UserAuthEventAdmin)
admin.site.register(OutboundEmail, OutboundEmailAdmin)
admin.site.register(LLMModels, LLMModelsAdmin)
admin.site.register(Conversation, ConversationAdmin)
@@ -145,3 +233,22 @@ admin.site.register(Feedback, FeedbackAdmin)
admin.site.register(DocumentWorkspace, DocumentWorkspaceAdmin)
admin.site.register(Document, DocumentAdmin)
class OAuthIdentityAdmin(admin.ModelAdmin):
model = OAuthIdentity
list_display = (
"provider",
"email",
"subject",
"user",
"token_expires_at",
"created",
"last_modified",
)
list_filter = ("provider",)
search_fields = ("email", "subject", "user__email")
readonly_fields = ("created", "last_modified", "raw_profile")
admin.site.register(OAuthIdentity, OAuthIdentityAdmin)
+164
View File
@@ -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'],
},
),
]
+127 -1
View File
@@ -3,6 +3,7 @@ from django.contrib.auth.models import AbstractUser
from django.utils import timezone
from autoslug import AutoSlugField
from chat_backend.storage import DatabaseStorage
import uuid
# Create your models here.
@@ -74,7 +75,132 @@ class CustomUser(AbstractUser):
)
def get_set_password_url(self):
return f"https://chat.aimloperations.com/set_password?slug={self.slug}"
from django.conf import settings
base = settings.FRONTEND_BASE_URL.rstrip("/")
return f"{base}/set_password/?slug={self.slug}"
class UserAuthEvent(models.Model):
"""Audit trail for password reset / set actions, shown on user admin."""
class EventType(models.TextChoices):
PASSWORD_RESET_REQUESTED = (
"password_reset_requested",
"Password reset requested",
)
PASSWORD_SET = ("password_set", "Password set")
INVITE_SENT = ("invite_sent", "Invite sent")
user = models.ForeignKey(
CustomUser,
on_delete=models.CASCADE,
related_name="auth_events",
)
event_type = models.CharField(max_length=64, choices=EventType.choices)
created = models.DateTimeField(default=timezone.now, db_index=True)
detail = models.CharField(max_length=512, blank=True, default="")
ip_address = models.GenericIPAddressField(null=True, blank=True)
class Meta:
ordering = ["-created"]
def __str__(self):
return f"{self.get_event_type_display()} @ {self.created.isoformat()}"
@classmethod
def log(cls, user, event_type, *, detail="", ip_address=None):
return cls.objects.create(
user=user,
event_type=event_type,
detail=detail or "",
ip_address=ip_address,
)
class OutboundEmail(models.Model):
"""Record of emails queued/sent by the app (visible in admin)."""
class Kind(models.TextChoices):
PASSWORD_RESET = "password_reset", "Password reset"
INVITE = "invite", "Invite"
FEEDBACK = "feedback", "Feedback"
class Status(models.TextChoices):
QUEUED = "queued", "Queued"
SENDING = "sending", "Sending"
SENT = "sent", "Sent to SMTP"
FAILED = "failed", "Failed"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
kind = models.CharField(max_length=32, choices=Kind.choices)
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.QUEUED, db_index=True
)
to_email = models.EmailField()
from_email = models.EmailField()
subject = models.CharField(max_length=255)
html_template = models.CharField(max_length=255)
text_template = models.CharField(max_length=255)
context = models.JSONField(default=dict, blank=True)
error_message = models.TextField(blank=True, default="")
user = models.ForeignKey(
CustomUser,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="outbound_emails",
)
created = models.DateTimeField(default=timezone.now, db_index=True)
updated = models.DateTimeField(auto_now=True)
sent_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created"]
def __str__(self):
return f"{self.subject}{self.to_email} ({self.status})"
class OAuthIdentity(TimeInfoBase):
"""Linked IdP identity + tokens (SSO now; Drive OAuth reuse later — #11)."""
class Provider(models.TextChoices):
GOOGLE = "google", "Google"
MICROSOFT = "microsoft", "Microsoft"
user = models.ForeignKey(
CustomUser,
on_delete=models.CASCADE,
related_name="oauth_identities",
)
provider = models.CharField(max_length=32, choices=Provider.choices)
subject = models.CharField(
max_length=255,
help_text="OIDC subject (sub) from the identity provider",
)
email = models.EmailField(blank=True, default="")
access_token = models.TextField(blank=True, default="")
refresh_token = models.TextField(blank=True, default="")
token_expires_at = models.DateTimeField(null=True, blank=True)
scopes = models.TextField(blank=True, default="")
raw_profile = models.JSONField(default=dict, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["provider", "subject"],
name="uniq_oauth_provider_subject",
),
models.UniqueConstraint(
fields=["provider", "user"],
name="uniq_oauth_provider_user",
),
]
verbose_name_plural = "OAuth identities"
def __str__(self):
return f"{self.provider}:{self.subject}{self.user_id}"
FEEDBACK_CHOICE = (
+383
View File
@@ -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;">&copy; {% now "Y" %} {{ company_name }}. All rights reserved.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -1,105 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Feedback Submission</title>
<style>
/* Basic reset for email clients */
body, table, td, a {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
background-color: #f4f4f4;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border: 1px solid #dddddd;
}
.header {
background-color: #007BFF;
color: #ffffff;
padding: 20px;
text-align: center;
}
.content {
padding: 20px;
color: #333333;
}
.footer {
background-color: #f4f4f4;
color: #777777;
text-align: center;
padding: 10px;
font-size: 12px;
}
.feedback-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.feedback-text {
font-size: 14px;
line-height: 1.5;
}
</style>
</head>
<body>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td>
<!-- Email Container -->
<div class="email-container">
<!-- Header -->
<div class="header">
<h1>New Feedback Submission</h1>
</div>
{% extends "emails/base_email.html" %}
<!-- Content -->
<div class="content">
<p>Hello,</p>
<p>A new feedback item has been submitted. Here are the details:</p>
{% block title %}New {{ site_name }} feedback{% endblock %}
<!-- Feedback Title -->
<div class="feedback-title">
Title: <strong>{{ title }}</strong>
{% block content %}
<h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">New feedback</h1>
<p style="margin:0 0 16px 0;">A new feedback item was submitted in {{ site_name }}.</p>
<div style="margin:0 0 12px 0;padding:16px;border-radius:10px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);">
<div style="font-size:12px;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px;">Title</div>
<div style="font-size:16px;font-weight:600;color:#ffffff;">{{ title }}</div>
</div>
<!-- Feedback Text -->
<div class="feedback-text">
<strong>Feedback:</strong><br>
{{ feedback_text }}
<div style="margin:0 0 16px 0;padding:16px;border-radius:10px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);">
<div style="font-size:12px;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px;">Feedback</div>
<div style="font-size:14px;color:rgba(255,255,255,0.85);white-space:pre-wrap;">{{ feedback_text }}</div>
</div>
<p>Thank you for your attention.</p>
</div>
<!-- Footer -->
<div class="footer">
<p>This is an automated message. Please do not reply to this email.</p>
<p>&copy; 2025 AI ML Operations, LLC. All rights reserved.</p>
</div>
</div>
</td>
</tr>
</table>
</body>
</html>
{% endblock %}
@@ -1,3 +1,8 @@
New feedback for Chat by AI ML Operations, LLC
New {{ site_name }} feedback
"New Feedback. {{ title }}. {{ feedback_text }}"
Title: {{ title }}
Feedback:
{{ feedback_text }}
— {{ site_name }} by {{ company_name }}
@@ -1,97 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invitation to Chat by AI ML Operations, LLC</title>
<style>
/* Basic reset for email clients */
body, table, td, a {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
background-color: #f4f4f4;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border: 1px solid #dddddd;
}
.header {
background-color: #007BFF;
color: #ffffff;
padding: 20px;
text-align: center;
}
.content {
padding: 20px;
color: #333333;
}
.footer {
background-color: #f4f4f4;
color: #777777;
text-align: center;
padding: 10px;
font-size: 12px;
}
.feedback-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.feedback-text {
font-size: 14px;
line-height: 1.5;
}
</style>
</head>
<body>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" align="center">
{% extends "emails/base_email.html" %}
{% block title %}Welcome to {{ site_name }}{% endblock %}
{% block content %}
<h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">You're invited</h1>
<p style="margin:0 0 16px 0;">Hello,</p>
<p style="margin:0 0 16px 0;">
You have been invited to {{ site_name }} by {{ company_name }}.
Set a password to activate your account and start chatting.
</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:24px 0;">
<tr>
<td>
<!-- Email Container -->
<div class="email-container">
<!-- Header -->
<div class="header">
<h1>Welcome to AI ML Operations, LLC Chat Services</h1>
</div>
<!-- Content -->
<div class="content">
<p>Hello,</p>
<p>You have been invited to use Chat by AI ML Operations, LLC.</p>
<p>Please click <a href="{{ url }}">link</a> to set your password.</p>
<p>Once you have set your password go <a href="https://chat.aimloperations.com">here</a> to get started.</p>
<p>Thank you.</p>
</div>
<!-- Footer -->
<div class="footer">
<p>This is an automated message. Please do not reply to this email.</p>
<p>&copy; 2025 AI ML Operations, LLC. All rights reserved.</p>
</div>
</div>
<td align="center" style="border-radius:8px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);">
<a href="{{ url }}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">
Set your password
</a>
</td>
</tr>
</table>
</body>
</html>
<p style="margin:0 0 12px 0;font-size:13px;color:rgba(255,255,255,0.55);word-break:break-all;">
Or copy this link:<br>
<a href="{{ url }}" style="color:#a78bfa;text-decoration:none;">{{ url }}</a>
</p>
<p style="margin:0;">
Once ready,
<a href="{{ site_url }}" style="color:#a78bfa;text-decoration:none;">open {{ site_name }}</a>.
</p>
{% endblock %}
@@ -1,3 +1,10 @@
Welcome to AI ML Operations, LLC Chat Services
You're invited to {{ site_name }}
"Welcome to chat.aimloperations.com. Please use {{ url }} to set your password"
You have been invited to {{ site_name }} by {{ company_name }}.
Set a password to activate your account:
{{ url }}
Then open {{ site_url }}
— {{ site_name }} by {{ company_name }}
@@ -1,97 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Password for Chat by AI ML Operations, LLC</title>
<style>
/* Basic reset for email clients */
body, table, td, a {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
background-color: #f4f4f4;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border: 1px solid #dddddd;
}
.header {
background-color: #007BFF;
color: #ffffff;
padding: 20px;
text-align: center;
}
.content {
padding: 20px;
color: #333333;
}
.footer {
background-color: #f4f4f4;
color: #777777;
text-align: center;
padding: 10px;
font-size: 12px;
}
.feedback-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.feedback-text {
font-size: 14px;
line-height: 1.5;
}
</style>
</head>
<body>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" align="center">
{% extends "emails/base_email.html" %}
{% block title %}Reset your {{ site_name }} password{% endblock %}
{% block content %}
<h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">Reset your password</h1>
<p style="margin:0 0 16px 0;">Hello,</p>
<p style="margin:0 0 16px 0;">
We received a request to reset the password for your {{ site_name }} account.
If you did not make this request, you can ignore this email or contact
<a href="mailto:{{ support_email }}" style="color:#a78bfa;text-decoration:none;">{{ support_email }}</a>.
</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:24px 0;">
<tr>
<td>
<!-- Email Container -->
<div class="email-container">
<!-- Header -->
<div class="header">
<h1>Password Reset for AI ML Operations, LLC Chat Services</h1>
</div>
<!-- Content -->
<div class="content">
<p>Hello,</p>
<p>There has been a request for a password reset. If you didn't requets this, please email ryan@aimloperations.com</p>
<p>Please click <a href="{{ url }}">link</a> to set your password.</p>
<p>Once you have set your password go <a href="https://chat.aimloperations.com">here</a> to get started.</p>
<p>Thank you.</p>
</div>
<!-- Footer -->
<div class="footer">
<p>This is an automated message. Please do not reply to this email.</p>
<p>&copy; 2023-2025 AI ML Operations, LLC. All rights reserved.</p>
</div>
</div>
<td align="center" style="border-radius:8px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);">
<a href="{{ url }}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">
Set a new password
</a>
</td>
</tr>
</table>
</body>
</html>
<p style="margin:0 0 12px 0;font-size:13px;color:rgba(255,255,255,0.55);word-break:break-all;">
Or copy this link:<br>
<a href="{{ url }}" style="color:#a78bfa;text-decoration:none;">{{ url }}</a>
</p>
<p style="margin:0;">
After you set your password,
<a href="{{ site_url }}" style="color:#a78bfa;text-decoration:none;">sign in to {{ site_name }}</a>.
</p>
{% endblock %}
@@ -1,3 +1,11 @@
Password Reset for AI ML Operations, LLC Chat Services
Reset your {{ site_name }} password
"Password reset for chat.aimloperations.com. Please use {{ url }} to set your password"
We received a request to reset the password for your {{ site_name }} account.
If you did not make this request, ignore this email or contact {{ support_email }}.
Set a new password:
{{ url }}
Then sign in at {{ site_url }}
— {{ site_name }} by {{ company_name }}
+1 -1
View File
@@ -72,7 +72,7 @@ class CompanyAndUserTestCase(TestCase):
user = make_user(email="person@example.com")
self.assertEqual(
user.get_set_password_url(),
f"https://chat.aimloperations.com/set_password?slug={user.slug}",
f"http://localhost:3000/set_password/?slug={user.slug}",
)
def test_user_defaults(self):
+241
View File
@@ -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()
)
+112 -1
View File
@@ -1,10 +1,17 @@
from django.core import mail
from django.urls import reverse
from unittest import mock
from rest_framework import status
from rest_framework.test import APITestCase
from rest_framework_simplejwt.tokens import RefreshToken
from chat_backend.models import Announcement, CustomUser, Feedback
from chat_backend.models import (
Announcement,
CustomUser,
Feedback,
OutboundEmail,
UserAuthEvent,
)
from .factories import make_company, make_user
@@ -179,6 +186,8 @@ class SetPasswordTestCase(APITestCase):
self.assertEqual(self.client.get(url).status_code, status.HTTP_200_OK)
def test_post_sets_password(self):
self.user.set_unusable_password()
self.user.save()
url = reverse("set_password", kwargs={"slug": self.user.slug})
response = self.client.post(url, {"password": "brandnewpass"}, format="json")
@@ -186,6 +195,97 @@ class SetPasswordTestCase(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.user.refresh_from_db()
self.assertTrue(self.user.check_password("brandnewpass"))
event = UserAuthEvent.objects.get(user=self.user)
self.assertEqual(event.event_type, UserAuthEvent.EventType.PASSWORD_SET)
def test_post_rejects_user_that_already_has_a_password(self):
url = reverse("set_password", kwargs={"slug": self.user.slug})
response = self.client.post(url, {"password": "brandnewpass"}, format="json")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_rejects_short_password(self):
self.user.set_unusable_password()
self.user.save()
url = reverse("set_password", kwargs={"slug": self.user.slug})
response = self.client.post(url, {"password": "short"}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
class ResetPasswordTestCase(APITestCase):
def setUp(self):
self.user = make_user(email="person@example.com", password="testpass123")
self.url = reverse("reset_password")
def _mock_captcha(self, success=True, score=None):
result = {"success": success}
if score is not None:
result["score"] = score
return mock.patch(
"chat_backend.views.requests.post",
return_value=mock.Mock(json=mock.Mock(return_value=result)),
)
def test_reset_invalidates_password_and_sends_email(self):
with self._mock_captcha(success=True):
with self.captureOnCommitCallbacks(execute=True):
response = self.client.post(
self.url,
{"email": self.user.email, "recaptchaToken": "token"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.user.refresh_from_db()
self.assertFalse(self.user.has_usable_password())
self.assertEqual(len(mail.outbox), 1)
self.assertIn(self.user.slug, mail.outbox[0].body)
self.assertIn("Hesychia", mail.outbox[0].subject)
self.assertEqual(mail.outbox[0].to, [self.user.email])
event = UserAuthEvent.objects.get(user=self.user)
self.assertEqual(
event.event_type, UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED
)
outbound = OutboundEmail.objects.get(user=self.user)
self.assertEqual(outbound.kind, OutboundEmail.Kind.PASSWORD_RESET)
self.assertEqual(outbound.status, OutboundEmail.Status.SENT)
self.assertEqual(outbound.to_email, self.user.email)
self.assertIsNotNone(outbound.sent_at)
def test_reset_unknown_email_still_returns_ok(self):
with self._mock_captcha(success=True):
response = self.client.post(
self.url,
{"email": "missing@example.com", "recaptchaToken": "token"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(mail.outbox), 0)
self.user.refresh_from_db()
self.assertTrue(self.user.has_usable_password())
def test_reset_rejects_failed_captcha(self):
with self._mock_captcha(success=False):
response = self.client.post(
self.url,
{"email": self.user.email, "recaptchaToken": "bad"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(len(mail.outbox), 0)
def test_reset_rejects_missing_email(self):
with self._mock_captcha(success=True):
response = self.client.post(
self.url, {"recaptchaToken": "token"}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
class AcknowledgeTermsOfServiceTestCase(APITestCase):
@@ -211,6 +311,7 @@ class UserInviteTestCase(APITestCase):
def test_manager_invites_new_user_and_email_is_sent(self):
self.client.force_authenticate(user=self.manager)
with self.captureOnCommitCallbacks(execute=True):
response = self.client.post(
self.url, {"email": "newhire@example.com"}, format="json"
)
@@ -221,7 +322,13 @@ class UserInviteTestCase(APITestCase):
self.assertEqual(invited.username, "newhire@example.com")
self.assertEqual(len(mail.outbox), 1)
self.assertIn(invited.slug, mail.outbox[0].body)
self.assertIn("Hesychia", mail.outbox[0].subject)
self.assertEqual(mail.outbox[0].to, ["newhire@example.com"])
event = UserAuthEvent.objects.get(user=invited)
self.assertEqual(event.event_type, UserAuthEvent.EventType.INVITE_SENT)
outbound = OutboundEmail.objects.get(user=invited)
self.assertEqual(outbound.kind, OutboundEmail.Kind.INVITE)
self.assertEqual(outbound.status, OutboundEmail.Status.SENT)
def test_non_manager_cannot_invite(self):
member = make_user(email="member@example.com", company=self.company)
@@ -268,6 +375,7 @@ class FeedbackViewTestCase(APITestCase):
self.url = reverse("feedbacks")
def test_post_creates_feedback_and_notifies(self):
with self.captureOnCommitCallbacks(execute=True):
response = self.client.post(
self.url,
{"title": "Broken button", "text": "It does nothing"},
@@ -279,6 +387,9 @@ class FeedbackViewTestCase(APITestCase):
self.assertEqual(feedback.user, self.user)
self.assertEqual(len(mail.outbox), 1)
self.assertIn("Broken button", mail.outbox[0].body)
outbound = OutboundEmail.objects.get(user=self.user)
self.assertEqual(outbound.kind, OutboundEmail.Kind.FEEDBACK)
self.assertEqual(outbound.status, OutboundEmail.Status.SENT)
def test_post_without_text_is_rejected(self):
response = self.client.post(self.url, {"title": "no body"}, format="json")
+14 -3
View File
@@ -15,17 +15,16 @@ from .views import (
ConversationDetailView,
CompanyUsersView,
SetUserPassword,
ResetUserPassword,
ConversationPreferences,
UserPromptAnalytics,
UserConversationAnalytics,
CompanyUsageAnalytics,
AdminAnalytics,
reset_password,
DocumentWorkspaceView,
DocumentUploadView,
DocumentDetailView,
)
from .views_oauth import OAuthCallbackView, OAuthStartView
from rest_framework.routers import DefaultRouter
@@ -34,8 +33,20 @@ urlpatterns = [
path("token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh"),
path("user/create/", CustomUserCreate.as_view(), name="create_user"),
path("public/settings/", PublicSettingsView.as_view(), name="public_settings"),
path(
"auth/oauth/<str:provider>/start/",
OAuthStartView.as_view(),
name="oauth_start",
),
path(
"auth/oauth/<str:provider>/callback/",
OAuthCallbackView.as_view(),
name="oauth_callback",
),
path("user/invite/", CustomUserInvite.as_view(), name="invite_user"),
path("user/reset_password/", reset_password, name="reset_password"),
path(
"user/reset_password/", ResetUserPassword.as_view(), name="reset_password"
),
path(
"user/set_password/<slug:slug>/", SetUserPassword.as_view(), name="set_password"
),
+87 -118
View File
@@ -28,6 +28,7 @@ from .models import (
PromptMetric,
DocumentWorkspace,
Document,
UserAuthEvent,
)
from django.views.decorators.cache import never_cache
from django.http import JsonResponse
@@ -47,12 +48,6 @@ import pandas as pd
import io
from chat_backend.services.assistant_identity import ASSISTANT_SYSTEM_PROMPT
# For email support
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.template.loader import get_template
from django.template import Context
from django.utils import timezone
from django.core.files import File
from django.core.files.base import ContentFile
@@ -62,9 +57,14 @@ import pytz
from langchain_ollama import OllamaEmbeddings
from dateutil.relativedelta import relativedelta
from django.views.decorators.csrf import csrf_exempt
import requests
from .utils import last_day_of_month
from .email_tasks import (
send_feedback_email,
send_invite_email,
send_password_reset_email,
)
from .services.llm_service import AsyncLLMService
from .services.rag_services import AsyncRAGService
from .services.title_generator import title_generator
@@ -87,6 +87,13 @@ CHANNEL_NAME: str = "llm_messages"
MODEL_NAME: str = ollama_model()
def _client_ip(request):
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
if forwarded:
return forwarded.split(",")[0].strip()
return request.META.get("REMOTE_ADDR")
# Create your views here.
class CustomObtainTokenView(TokenObtainPairView):
permission_classes = (permissions.AllowAny,)
@@ -100,9 +107,12 @@ class PublicSettingsView(APIView):
authentication_classes = ()
def get(self, request):
from .views_oauth import oauth_public_flags
return Response(
{
"enable_account_registration": settings.ENABLE_ACCOUNT_REGISTRATION,
"oauth": oauth_public_flags(),
}
)
@@ -138,66 +148,6 @@ class CustomUserCreate(APIView):
)
def send_invite_email(slug, email_to_invite):
logger.info("Sending invite email")
logger.info(f"url : https://chat.aimloperations.com/set_password?slug={slug}")
url = f"https://chat.aimloperations.com/set_password?slug={slug}"
subject = "Welcome to AI ML Operations, LLC Chat Services"
from_email = "ryan@aimloperations.com"
to = email_to_invite
d = {"url": url}
html_content = get_template(r"emails/invite_email.html").render(d)
text_content = get_template(r"emails/invite_email.txt").render(d)
msg = EmailMultiAlternatives(subject, text_content, from_email, [to])
msg.attach_alternative(html_content, "text/html")
msg.send(fail_silently=True)
def send_password_reset_email(slug, email_to_invite):
logger.info("Sending reset email")
logger.info(f"url : https://www.chat.aimloperations.com/set_password?slug={slug}")
url = f"https://www.chat.aimloperations.com/set_password?slug={slug}"
subject = "Password reset for AI ML Operations, LLC Chat Services"
from_email = "ryan@aimloperations.com"
to = email_to_invite
d = {"url": url}
html_content = get_template(r"emails/reset_email.html").render(d)
text_content = get_template(r"emails/reset_email.txt").render(d)
msg = EmailMultiAlternatives(subject, text_content, from_email, [to])
msg.attach_alternative(html_content, "text/html")
msg.send(fail_silently=True)
def send_feedback_email(feedback_obj):
logger.info("Sending feedback email")
subject = "New Feedback for Chat by AI ML Operations, LLC"
from_email = "ryan@aimloperations.com"
to = "ryan@aimloperations.com"
d = {"title": feedback_obj.title, "feedback_text": feedback_obj.text}
html_content = get_template(r"emails/feedback_email.html").render(d)
text_content = get_template(r"emails/feedback_email.txt").render(d)
msg = EmailMultiAlternatives(subject, text_content, from_email, [to])
msg.attach_alternative(html_content, "text/html")
msg.send(fail_silently=True)
def send_password_reset_email(slug, email_to_invite):
logger.info("Sending Password reset email")
url = f"https://www.chat.aimloperations.com/set_password?slug={slug}"
subject = "Password reset for Chat by AI ML Operations, LLC"
from_email = "ryan@aimloperations.com"
to = email_to_invite
d = {"url": url}
html_content = get_template(r"emails/reset_email.html").render(d)
text_content = get_template(r"emails/reset_email.txt").render(d)
msg = EmailMultiAlternatives(subject, text_content, from_email, [to])
msg.attach_alternative(html_content, "text/html")
msg.send(fail_silently=True)
class CustomUserInvite(APIView):
http_method_names = ["post"]
@@ -228,72 +178,68 @@ class CustomUserInvite(APIView):
company=request.user.company,
)
# send an email
send_invite_email(user.slug, email_to_invite)
send_invite_email(user.slug, email_to_invite, user=user)
UserAuthEvent.log(
user,
UserAuthEvent.EventType.INVITE_SENT,
detail=f"Invited by {request.user.email}",
ip_address=_client_ip(request),
)
return Response(status=status.HTTP_201_CREATED)
@csrf_exempt
def reset_password(request):
if request.method == "POST":
data = json.loads(request.body)
token = data.get("recaptchaToken")
payload = {
"secret": settings.CAPTCHA_SECRET_KEY,
"response": token,
}
response = requests.post(
"https://www.google.com/recaptcha/api/siteverify", data=payload
)
result = response.json()
if result.get("success") and result.get("score") >= 0.5:
email = data.get("email")
user = CustomUser.objects.filter(email=email).first()
if user:
user.set_unusable_password()
user.save()
# send the email
send_password_reset_email(user.slug, email)
JsonResponse(status=200)
JsonResponse(status=400)
class ResetUserPassword(APIView):
http_method_names = [
"post",
]
"""Request a password-reset email. Invalidates the current password when sent."""
http_method_names = ["post"]
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
def post(self, request, format="json"):
"""
Send an email with a set password link to the set password page
Also disable the account
"""
logger.info(f"Password reset for requests. {request.data}")
logger.info("Password reset requested")
email = request.data.get("email")
token = request.data.get("recaptchaToken")
if not email:
return Response(status=status.HTTP_400_BAD_REQUEST)
payload = {
"secret": settings.CAPTCHA_SECRET_KEY,
"response": recaptchaToken,
"response": token,
}
response = requests.post(
"https://www.google.com/recaptcha/api/siteverify", data=payload
try:
captcha_response = requests.post(
"https://www.google.com/recaptcha/api/siteverify",
data=payload,
timeout=10,
)
result = response.json()
if result.get("success") and result.get("score") >= 0.5:
result = captcha_response.json()
except requests.RequestException as exc:
logger.error("Captcha verification request failed: %s", exc)
return Response(status=status.HTTP_400_BAD_REQUEST)
# v2 invisible returns success only; v3 also returns a score.
if not result.get("success"):
logger.error("Captcha verification failed: %s", result)
return Response(status=status.HTTP_400_BAD_REQUEST)
score = result.get("score")
if score is not None and score < 0.5:
logger.error("Captcha score too low: %s", score)
return Response(status=status.HTTP_400_BAD_REQUEST)
user = CustomUser.objects.filter(email=email).first()
if user:
user.set_unusable_password()
user.save()
# send the email
send_password_reset_email(user.slug, email)
else:
logger.error("Captcha secret failed")
user.save(update_fields=["password"])
send_password_reset_email(user.slug, email, user=user)
UserAuthEvent.log(
user,
UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED,
detail="Password reset email queued",
ip_address=_client_ip(request),
)
# Always 200 after valid captcha to avoid email enumeration.
return Response(status=status.HTTP_200_OK)
@@ -303,16 +249,37 @@ class SetUserPassword(APIView):
authentication_classes = ()
def get(self, request, slug):
try:
user = CustomUser.objects.get(slug=slug)
except CustomUser.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
if user.has_usable_password():
return Response(status=status.HTTP_401_UNAUTHORIZED)
else:
return Response(status=status.HTTP_200_OK)
def post(self, request, slug, format="json"):
try:
user = CustomUser.objects.get(slug=slug)
user.set_password(request.data["password"])
except CustomUser.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
if user.has_usable_password():
return Response(status=status.HTTP_401_UNAUTHORIZED)
password = request.data.get("password")
if not password or len(password) < 8:
return Response(
{"password": "Password must be at least 8 characters."},
status=status.HTTP_400_BAD_REQUEST,
)
user.set_password(password)
user.save()
UserAuthEvent.log(
user,
UserAuthEvent.EventType.PASSWORD_SET,
detail="Password set via email link",
ip_address=_client_ip(request),
)
return Response(status=status.HTTP_200_OK)
@@ -347,7 +314,9 @@ class FeedbackView(APIView):
feedback_obj.user = request.user
feedback_obj.save()
send_feedback_email(feedback_obj)
send_feedback_email(
feedback_obj.title, feedback_obj.text, user=request.user
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
else:
logger.error(serializer.errors)
+155
View File
@@ -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()
+21
View File
@@ -285,6 +285,14 @@ EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "") or ""
EMAIL_PORT = int(env("EMAIL_PORT", "2525") or "2525")
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True)
# Django 6 Tasks: ImmediateBackend runs in-process (no worker yet). Swap BACKEND
# to a durable queue + worker when SMTP should leave the request thread.
TASKS = {
"default": {
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
}
}
CAPTCHA_SECRET_KEY = env("CAPTCHA_SECRET_KEY", "") or ""
USE_TLS_PROXY = env_bool("USE_TLS_PROXY", DJANGO_ENV in {"prod", "beta"})
@@ -301,6 +309,19 @@ ALLOW_INTERNET_ACCESS = env_bool("ALLOW_INTERNET_ACCESS", True)
# control-node secret (chat_backend_<env>.env) when ready for public sign-up.
ENABLE_ACCOUNT_REGISTRATION = env_bool("ENABLE_ACCOUNT_REGISTRATION", False)
# ---------------------------------------------------------------------------
# OAuth SSO (Google / Microsoft) — #24
# ---------------------------------------------------------------------------
GOOGLE_OAUTH_CLIENT_ID = env("GOOGLE_OAUTH_CLIENT_ID", "") or ""
GOOGLE_OAUTH_CLIENT_SECRET = env("GOOGLE_OAUTH_CLIENT_SECRET", "") or ""
MICROSOFT_OAUTH_CLIENT_ID = env("MICROSOFT_OAUTH_CLIENT_ID", "") or ""
MICROSOFT_OAUTH_CLIENT_SECRET = env("MICROSOFT_OAUTH_CLIENT_SECRET", "") or ""
# Azure AD tenant: "common" (personal + work), "organizations", or a tenant ID.
MICROSOFT_OAUTH_TENANT = env("MICROSOFT_OAUTH_TENANT", "common") or "common"
# Public backend origin for IdP redirect URIs (e.g. https://chatbackend.aimloperations.com).
# When empty, callback URLs are built from the incoming request.
OAUTH_CALLBACK_BASE_URL = (env("OAUTH_CALLBACK_BASE_URL", "") or "").rstrip("/")
# ---------------------------------------------------------------------------
# Finance / Stripe (subscription billing)
# ---------------------------------------------------------------------------