All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 1m48s
1187 lines
42 KiB
Python
1187 lines
42 KiB
Python
import json
|
|
import logging
|
|
import secrets
|
|
import uuid
|
|
from datetime import timedelta
|
|
|
|
import requests
|
|
from django.conf import settings
|
|
from django.contrib.auth.hashers import make_password
|
|
from django.utils import timezone
|
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
|
from rest_framework import serializers, status
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
from rest_framework_simplejwt.authentication import JWTAuthentication
|
|
|
|
from common import serializer
|
|
from common.models import Org, Profile, User
|
|
from common.permissions import is_org_admin
|
|
from common.request_meta import client_ip
|
|
from common.serializer import OrgAwareRefreshToken
|
|
from common.throttles import (
|
|
FirstRefusalThrottleMixin,
|
|
MagicLinkGlobalThrottle,
|
|
MagicLinkIPThrottle,
|
|
)
|
|
from common.utils import CURRENCY_SYMBOLS
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _org_payload(org, profile=None):
|
|
"""The org record every auth response hands a client.
|
|
|
|
Currency belongs here because a client picks it up at sign-in and holds it
|
|
for the whole session: the mobile deal and lead forms default a new
|
|
record's currency from it, and the dashboard prices its totals with it.
|
|
All three read fields that no endpoint was sending, so an org keeping its
|
|
books in euros still created dollar deals and drew a dollar dashboard.
|
|
|
|
``default_currency`` and ``timezone`` are normalised rather than passed
|
|
through, so a blank column answers with the same "USD" and "UTC" the rest of
|
|
the codebase assumes.
|
|
|
|
With ``profile`` (the caller's membership of ``org``) it also carries
|
|
``role`` and ``is_organization_admin``. The mobile client gates admin UI on
|
|
the latter, which is ``is_org_admin`` and so admits a superuser's profile;
|
|
``role`` is for display.
|
|
"""
|
|
currency = org.default_currency or "USD"
|
|
payload = {
|
|
"id": str(org.id),
|
|
"name": org.name,
|
|
"default_currency": currency,
|
|
"currency_symbol": CURRENCY_SYMBOLS.get(currency, "$"),
|
|
"default_country": org.default_country,
|
|
# The org's day, so a client can label a date the same way the server
|
|
# computed it instead of guessing from the device.
|
|
"timezone": org.timezone or "UTC",
|
|
}
|
|
if profile is not None:
|
|
payload["role"] = profile.role
|
|
payload["is_organization_admin"] = is_org_admin(profile)
|
|
return payload
|
|
|
|
|
|
def _google_email_is_verified(claims):
|
|
"""Whether Google asserts the caller controls the email in ``claims``.
|
|
|
|
Fails closed: a missing ``email_verified`` claim counts as unverified.
|
|
Google always sends it alongside the ``email`` scope, so its absence means
|
|
the payload is not the shape we think it is. Without this check, a Google
|
|
account carrying an address whose owner never proved control of it is enough
|
|
to become that address here, which matters because other parts of the
|
|
system key off the email string.
|
|
"""
|
|
verified = claims.get("email_verified")
|
|
return verified is True or str(verified).strip().lower() == "true"
|
|
|
|
|
|
def _json_object(response):
|
|
"""``response``'s body as a dict, or ``{}`` when it is not a JSON object.
|
|
|
|
Google's token endpoint answers JSON, but a proxy or an outage in between
|
|
can answer an HTML error page, and ``.json()`` on that raised straight out
|
|
of the view as a 500.
|
|
"""
|
|
try:
|
|
body = response.json()
|
|
except ValueError:
|
|
return {}
|
|
return body if isinstance(body, dict) else {}
|
|
|
|
|
|
def _disabled_account_response():
|
|
"""403 for a login attempt by a deactivated account.
|
|
|
|
Deactivation is the offboarding lever, so every login path must honour it,
|
|
otherwise a revoked user simply signs in again. Kept identical across the
|
|
Google and magic-link flows so clients can handle one shape.
|
|
"""
|
|
return Response(
|
|
{"error": "User account is disabled"},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
|
|
class GoogleOAuthCallbackView(APIView):
|
|
"""
|
|
Handle Google OAuth authorization code exchange with PKCE.
|
|
"""
|
|
|
|
permission_classes = []
|
|
authentication_classes = []
|
|
|
|
@extend_schema(
|
|
tags=["auth"],
|
|
request=inline_serializer(
|
|
name="GoogleOAuthRequest",
|
|
fields={
|
|
"code": serializers.CharField(),
|
|
"code_verifier": serializers.CharField(),
|
|
"redirect_uri": serializers.CharField(),
|
|
},
|
|
),
|
|
responses={
|
|
200: inline_serializer(
|
|
name="GoogleOAuthResponse",
|
|
fields={
|
|
"access_token": serializers.CharField(),
|
|
"refresh_token": serializers.CharField(),
|
|
"user": serializers.DictField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def post(self, request):
|
|
import base64
|
|
|
|
from django.utils import timezone
|
|
|
|
from common.audit_log import audit_log
|
|
|
|
code = request.data.get("code")
|
|
code_verifier = request.data.get("code_verifier")
|
|
redirect_uri = request.data.get("redirect_uri")
|
|
|
|
if not all([code, code_verifier, redirect_uri]):
|
|
return Response(
|
|
{"error": "Missing required parameters"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Exchange code for tokens with Google
|
|
try:
|
|
token_response = requests.post(
|
|
"https://oauth2.googleapis.com/token",
|
|
data={
|
|
"code": code,
|
|
"client_id": settings.GOOGLE_CLIENT_ID,
|
|
"client_secret": settings.GOOGLE_CLIENT_SECRET,
|
|
"redirect_uri": redirect_uri,
|
|
"grant_type": "authorization_code",
|
|
"code_verifier": code_verifier,
|
|
},
|
|
timeout=30,
|
|
)
|
|
except requests.RequestException:
|
|
audit_log.login_failure("", "google_unreachable", request)
|
|
return Response(
|
|
{"error": "Failed to communicate with Google"},
|
|
status=status.HTTP_502_BAD_GATEWAY,
|
|
)
|
|
|
|
if token_response.status_code != 200:
|
|
audit_log.login_failure("", "google_code_rejected", request)
|
|
error_data = _json_object(token_response)
|
|
return Response(
|
|
{"error": error_data.get("error_description", "Token exchange failed")},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
id_token = _json_object(token_response).get("id_token")
|
|
if not id_token:
|
|
audit_log.login_failure("", "google_invalid_id_token", request)
|
|
return Response(
|
|
{"error": "No ID token received"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Decode ID token payload (no verification needed - we just got it from Google over HTTPS)
|
|
try:
|
|
payload_part = id_token.split(".")[1]
|
|
# Add padding if needed
|
|
payload_part += "=" * (4 - len(payload_part) % 4)
|
|
payload = json.loads(base64.urlsafe_b64decode(payload_part))
|
|
email = payload.get("email")
|
|
picture = payload.get("picture", "")
|
|
google_name = (payload.get("name") or "").strip()[:255]
|
|
except (IndexError, ValueError, AttributeError):
|
|
# AttributeError: an `id_token` that is not a string, or a payload
|
|
# that is not a JSON object. JSONDecodeError is a ValueError.
|
|
audit_log.login_failure("", "google_invalid_id_token", request)
|
|
return Response(
|
|
{"error": "Invalid ID token format"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if not email:
|
|
audit_log.login_failure("", "google_no_email", request)
|
|
return Response(
|
|
{"error": "No email in token"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Reject before touching the database so an unverified address cannot
|
|
# even provision an account.
|
|
if not _google_email_is_verified(payload):
|
|
audit_log.login_failure(email, "google_email_unverified", request)
|
|
return Response(
|
|
{"error": "Google account email is not verified"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Get or create user
|
|
created = False
|
|
try:
|
|
user = User.objects.get(email=email)
|
|
except User.DoesNotExist:
|
|
user = User.objects.create(
|
|
email=email,
|
|
name=google_name,
|
|
profile_pic=picture,
|
|
password=make_password(secrets.token_urlsafe(32)),
|
|
)
|
|
created = True
|
|
|
|
if not user.is_active:
|
|
audit_log.login_failure(email, "account_disabled", request)
|
|
return _disabled_account_response()
|
|
|
|
# Backfill name from Google when the user hasn't set one yet.
|
|
if not user.name and google_name:
|
|
user.name = google_name
|
|
user.save(update_fields=["name"])
|
|
|
|
user.last_login = timezone.now()
|
|
user.save(update_fields=["last_login"])
|
|
|
|
if created:
|
|
from common.tasks import send_welcome_email
|
|
|
|
send_welcome_email.delay(str(user.id))
|
|
|
|
# Generate JWT tokens (with user info embedded)
|
|
token = OrgAwareRefreshToken.for_user_and_org(user, None)
|
|
|
|
# The same row a magic-link sign-in writes. No org yet: this token is
|
|
# org-less until the user picks one.
|
|
audit_log.login_success(user, None, request)
|
|
|
|
return Response(
|
|
{
|
|
"access_token": str(token.access_token),
|
|
"refresh_token": str(token),
|
|
"user": {"id": str(user.id), "email": user.email},
|
|
}
|
|
)
|
|
|
|
|
|
class GoogleIdTokenView(APIView):
|
|
"""
|
|
Handle Google Sign-In from mobile apps using ID token.
|
|
Mobile app sends Google ID token, backend verifies and returns JWT.
|
|
"""
|
|
|
|
permission_classes = []
|
|
authentication_classes = []
|
|
|
|
@extend_schema(
|
|
tags=["auth"],
|
|
request=inline_serializer(
|
|
name="GoogleIdTokenRequest",
|
|
fields={"idToken": serializers.CharField()},
|
|
),
|
|
responses={
|
|
200: inline_serializer(
|
|
name="GoogleIdTokenResponse",
|
|
fields={
|
|
"JWTtoken": serializers.CharField(),
|
|
"refresh_token": serializers.CharField(),
|
|
"user": serializers.DictField(),
|
|
"organizations": serializers.ListField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def post(self, request):
|
|
from django.utils import timezone
|
|
from google.auth.transport import requests as google_requests
|
|
from google.oauth2 import id_token
|
|
|
|
from common.audit_log import audit_log
|
|
|
|
id_token_str = request.data.get("idToken")
|
|
if not id_token_str:
|
|
return Response(
|
|
{"error": "Missing idToken"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Verify the ID token with Google
|
|
try:
|
|
idinfo = id_token.verify_oauth2_token(
|
|
id_token_str,
|
|
google_requests.Request(),
|
|
settings.GOOGLE_CLIENT_ID,
|
|
)
|
|
email = idinfo.get("email")
|
|
picture = idinfo.get("picture", "")
|
|
google_name = (idinfo.get("name") or "").strip()[:255]
|
|
except ValueError:
|
|
logger.warning("Google OAuth token validation failed", exc_info=True)
|
|
audit_log.login_failure("", "google_invalid_token", request)
|
|
return Response(
|
|
{"error": "Invalid token"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if not email:
|
|
audit_log.login_failure("", "google_no_email", request)
|
|
return Response(
|
|
{"error": "No email in token"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Reject before touching the database so an unverified address cannot
|
|
# even provision an account.
|
|
if not _google_email_is_verified(idinfo):
|
|
audit_log.login_failure(email, "google_email_unverified", request)
|
|
return Response(
|
|
{"error": "Google account email is not verified"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Get or create user
|
|
user, _created = User.objects.get_or_create(
|
|
email=email,
|
|
defaults={
|
|
"name": google_name,
|
|
"profile_pic": picture,
|
|
"password": make_password(secrets.token_urlsafe(32)),
|
|
},
|
|
)
|
|
|
|
if not user.is_active:
|
|
audit_log.login_failure(email, "account_disabled", request)
|
|
return _disabled_account_response()
|
|
|
|
# Backfill name from Google for existing users who don't have one.
|
|
if not user.name and google_name:
|
|
user.name = google_name
|
|
user.save(update_fields=["name"])
|
|
|
|
user.last_login = timezone.now()
|
|
user.save(update_fields=["last_login"])
|
|
|
|
# Get user's organizations. Active profiles only: `OrgSwitchView`
|
|
# requires `is_active=True`, so listing a deactivated membership here
|
|
# offered an org that answers 403 the moment it is chosen.
|
|
profiles = Profile.objects.filter(user=user, is_active=True).select_related(
|
|
"org", "user"
|
|
)
|
|
organizations = [_org_payload(p.org, profile=p) for p in profiles]
|
|
|
|
# Generate JWT token
|
|
token = OrgAwareRefreshToken.for_user_and_org(user, None)
|
|
|
|
# The same row a magic-link sign-in writes; org-less, as above.
|
|
audit_log.login_success(user, None, request)
|
|
|
|
return Response(
|
|
{
|
|
"JWTtoken": str(token.access_token),
|
|
"refresh_token": str(token),
|
|
"user": {
|
|
"id": str(user.id),
|
|
"email": user.email,
|
|
"name": email.split("@")[0],
|
|
"profileImage": user.profile_pic,
|
|
},
|
|
"organizations": organizations,
|
|
}
|
|
)
|
|
|
|
|
|
class MeView(APIView):
|
|
"""
|
|
Get current authenticated user details
|
|
"""
|
|
|
|
authentication_classes = [JWTAuthentication]
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
@extend_schema(
|
|
description="Get current authenticated user with organizations",
|
|
responses={200: serializer.UserDetailSerializer},
|
|
)
|
|
def get(self, request):
|
|
user_serializer = serializer.UserDetailSerializer(request.user)
|
|
return Response(user_serializer.data, status=status.HTTP_200_OK)
|
|
|
|
|
|
class OrgAwareTokenRefreshView(APIView):
|
|
"""
|
|
Custom token refresh that validates org membership.
|
|
|
|
When refreshing a token, this view:
|
|
1. Validates the refresh token
|
|
2. Checks that user still has access to the org in the token
|
|
3. Issues new tokens with the same org context
|
|
|
|
If membership was revoked, returns 403 and user must login again.
|
|
"""
|
|
|
|
permission_classes = []
|
|
authentication_classes = []
|
|
|
|
@extend_schema(
|
|
description="Refresh access token with org membership validation",
|
|
request=inline_serializer(
|
|
name="OrgAwareTokenRefreshRequest",
|
|
fields={"refresh": serializers.CharField(help_text="Refresh token")},
|
|
),
|
|
responses={
|
|
200: inline_serializer(
|
|
name="OrgAwareTokenRefreshResponse",
|
|
fields={
|
|
"access": serializers.CharField(),
|
|
"refresh": serializers.CharField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def post(self, request):
|
|
from django.db import transaction
|
|
from rest_framework_simplejwt.exceptions import TokenError
|
|
from rest_framework_simplejwt.tokens import RefreshToken as BaseRefreshToken
|
|
|
|
from common.audit_log import audit_log
|
|
|
|
refresh_token = request.data.get("refresh")
|
|
|
|
if not refresh_token:
|
|
return Response(
|
|
{"error": "Refresh token is required"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
# Decode and validate refresh token
|
|
token = BaseRefreshToken(refresh_token)
|
|
user_id = token["user_id"]
|
|
org_id = token.get("org_id")
|
|
|
|
# Get user
|
|
user = User.objects.get(id=user_id)
|
|
|
|
if not user.is_active:
|
|
return Response(
|
|
{"error": "User account is disabled"},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
# If token has org context, validate membership
|
|
org = None
|
|
profile = None
|
|
if org_id:
|
|
try:
|
|
profile = Profile.objects.get(
|
|
user=user, org_id=org_id, is_active=True
|
|
)
|
|
org = profile.org
|
|
except Profile.DoesNotExist:
|
|
# Membership revoked - user must login again
|
|
audit_log.token_revoked(
|
|
user, None, f"Membership revoked for org {org_id}", request
|
|
)
|
|
return Response(
|
|
{
|
|
"error": "Organization membership revoked. Please login again."
|
|
},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
# Rotate: a refresh token is single-use. Blacklisting the presented
|
|
# token means a copy stolen via XSS, a proxy, or a log cannot keep
|
|
# minting access tokens once the legitimate client has refreshed.
|
|
# Both writes share one transaction so a failure can never leave the
|
|
# caller tokenless while the old token stays usable.
|
|
# `profile` is None when there is no org context, which is exactly
|
|
# what for_user_and_org expects.
|
|
with transaction.atomic():
|
|
token.blacklist()
|
|
new_token = OrgAwareRefreshToken.for_user_and_org(user, org, profile)
|
|
|
|
audit_log.token_refresh(user, org, request)
|
|
|
|
return Response(
|
|
{"access": str(new_token.access_token), "refresh": str(new_token)},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
except TokenError:
|
|
return Response(
|
|
{"error": "Invalid or expired token"},
|
|
status=status.HTTP_401_UNAUTHORIZED,
|
|
)
|
|
except User.DoesNotExist:
|
|
return Response(
|
|
{"error": "User not found"}, status=status.HTTP_401_UNAUTHORIZED
|
|
)
|
|
|
|
|
|
class LogoutView(APIView):
|
|
"""
|
|
Sign out by blacklisting the presented refresh token.
|
|
|
|
Until this existed, signing out only cleared the client's own storage. The
|
|
refresh token stayed valid for its full fourteen days, so a copy lifted
|
|
from a shared machine, a device backup, or a proxy log kept minting access
|
|
tokens long after the user believed the session was over. Rotation and
|
|
blacklisting were already switched on; the endpoint to use them was the
|
|
piece that was missing.
|
|
|
|
UNAUTHENTICATED BY DESIGN, and this is the part worth reading twice. The
|
|
access token is often already expired when someone presses Sign Out: a
|
|
phone that sat in a pocket, a tab left open overnight. Requiring it would
|
|
fail exactly when signing out matters most, and would leave the refresh
|
|
token alive. The refresh token is itself the credential here, presenting
|
|
one proves possession, and the only thing this view will do with it is
|
|
destroy it. A caller who does not hold a valid token cannot revoke anyone
|
|
else's, because a token that is unparseable, expired, or already
|
|
blacklisted never reaches `blacklist()`.
|
|
|
|
WHAT IT DELIBERATELY DOES NOT DO. Access tokens are stateless and stay
|
|
valid until they expire, up to an hour. Killing those needs a denylist
|
|
consulted on every request, which is a different trade in a different
|
|
change. This narrows the exposure from fourteen days to at most one hour.
|
|
|
|
It also revokes one token, not every token the user holds, so signing out
|
|
on a phone leaves a desktop signed in. "Sign out everywhere" is a separate
|
|
feature and should stay one.
|
|
"""
|
|
|
|
permission_classes = []
|
|
authentication_classes = []
|
|
|
|
@extend_schema(
|
|
description=(
|
|
"Sign out. Blacklists the supplied refresh token so it can no "
|
|
"longer mint access tokens."
|
|
),
|
|
request=inline_serializer(
|
|
name="LogoutRequest",
|
|
fields={"refresh": serializers.CharField(help_text="Refresh token")},
|
|
),
|
|
responses={
|
|
200: inline_serializer(
|
|
name="LogoutResponse",
|
|
fields={"detail": serializers.CharField()},
|
|
)
|
|
},
|
|
)
|
|
def post(self, request):
|
|
from rest_framework_simplejwt.exceptions import TokenError
|
|
from rest_framework_simplejwt.tokens import RefreshToken as BaseRefreshToken
|
|
|
|
from common.audit_log import audit_log
|
|
|
|
refresh_token = request.data.get("refresh")
|
|
if not refresh_token:
|
|
return Response(
|
|
{"error": "Refresh token is required"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
token = BaseRefreshToken(refresh_token)
|
|
except TokenError:
|
|
# Expired, malformed, or already blacklisted. The end state the
|
|
# caller asked for is already true, so answering 200 keeps a client
|
|
# from getting stuck on a sign-out that in fact succeeded. Nothing
|
|
# is leaked either way: both cases mean "this token is unusable".
|
|
return Response({"detail": "Signed out."}, status=status.HTTP_200_OK)
|
|
|
|
token.blacklist()
|
|
|
|
# Best effort, and only for the log. A user row that has since been
|
|
# deleted must not turn a successful sign-out into a 500.
|
|
user = User.objects.filter(id=token.get("user_id")).first()
|
|
if user is not None:
|
|
org = Org.objects.filter(id=token.get("org_id")).first()
|
|
audit_log.logout(user, org, request)
|
|
|
|
return Response({"detail": "Signed out."}, status=status.HTTP_200_OK)
|
|
|
|
|
|
class OrgSwitchView(APIView):
|
|
"""
|
|
Switch to a different organization and get new JWT tokens.
|
|
|
|
This endpoint validates that the user has access to the target
|
|
organization and issues new tokens with the org_id claim.
|
|
"""
|
|
|
|
authentication_classes = [JWTAuthentication]
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def _retire_presented_refresh_token(self, request):
|
|
"""Blacklist the refresh token the caller is switching away from.
|
|
|
|
Optional by design: clients already in the wild do not send ``refresh``,
|
|
and failing their org switch would be worse than letting one token expire
|
|
on its own schedule. When it *is* sent, the old token stops working
|
|
immediately instead of staying valid against the previous org for the
|
|
rest of its 14-day life.
|
|
|
|
The token must belong to the authenticated caller. Without that check
|
|
this field would let anyone retire any refresh token they can observe,
|
|
turning a convenience parameter into a remote logout for other users.
|
|
"""
|
|
from rest_framework_simplejwt.exceptions import TokenError
|
|
from rest_framework_simplejwt.tokens import RefreshToken as BaseRefreshToken
|
|
|
|
presented = request.data.get("refresh")
|
|
if not presented:
|
|
return
|
|
|
|
try:
|
|
token = BaseRefreshToken(presented)
|
|
except TokenError:
|
|
# Expired, malformed, or already blacklisted: nothing left to retire,
|
|
# and the switch itself does not depend on it.
|
|
return
|
|
|
|
if str(token.get("user_id")) != str(request.user.id):
|
|
return
|
|
|
|
token.blacklist()
|
|
|
|
@extend_schema(
|
|
description="Switch to a different organization and get new JWT tokens",
|
|
request=inline_serializer(
|
|
name="OrgSwitchRequest",
|
|
fields={
|
|
"org_id": serializers.UUIDField(help_text="Target organization ID"),
|
|
"refresh": serializers.CharField(
|
|
required=False,
|
|
help_text=(
|
|
"Optional. The refresh token being replaced; it is "
|
|
"blacklisted so it cannot be reused after the switch. "
|
|
"Ignored unless it belongs to the caller."
|
|
),
|
|
),
|
|
},
|
|
),
|
|
responses={
|
|
200: inline_serializer(
|
|
name="OrgSwitchResponse",
|
|
fields={
|
|
"access_token": serializers.CharField(),
|
|
"refresh_token": serializers.CharField(),
|
|
"current_org": serializers.DictField(),
|
|
"profile": serializers.DictField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def post(self, request):
|
|
from django.db import transaction
|
|
|
|
from common.audit_log import audit_log
|
|
|
|
org_id = request.data.get("org_id")
|
|
|
|
if not org_id:
|
|
return Response(
|
|
{"error": "org_id is required"}, status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
try:
|
|
uuid.UUID(str(org_id))
|
|
except (ValueError, AttributeError):
|
|
return Response(
|
|
{"error": "org_id must be a valid UUID"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# Get current org for audit logging
|
|
from_org = getattr(request, "org", None)
|
|
|
|
# Validate user has access to the target org
|
|
try:
|
|
profile = Profile.objects.get(
|
|
user=request.user, org_id=org_id, is_active=True
|
|
)
|
|
except Profile.DoesNotExist:
|
|
audit_log.permission_denied(
|
|
request.user, from_org, "ORG_SWITCH", f"org:{org_id}", request
|
|
)
|
|
return Response(
|
|
{"error": "User does not have access to this organization"},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
# Retire the outgoing refresh token and issue its replacement together,
|
|
# so a failure cannot leave the caller without a usable token while the
|
|
# old one is already dead.
|
|
with transaction.atomic():
|
|
self._retire_presented_refresh_token(request)
|
|
token = OrgAwareRefreshToken.for_user_and_org(
|
|
request.user, profile.org, profile
|
|
)
|
|
|
|
# Audit log the org switch
|
|
audit_log.org_switch(request.user, from_org, profile.org, request)
|
|
|
|
return Response(
|
|
{
|
|
"access_token": str(token.access_token),
|
|
"refresh_token": str(token),
|
|
"current_org": _org_payload(profile.org),
|
|
"profile": {
|
|
"id": str(profile.id),
|
|
"role": profile.role,
|
|
"is_organization_admin": is_org_admin(profile),
|
|
},
|
|
},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
class MagicLinkRequestView(FirstRefusalThrottleMixin, APIView):
|
|
"""
|
|
Request a magic link for passwordless login/registration.
|
|
Returns 200 whatever the email, to prevent enumeration. A throttled caller
|
|
gets 429 before the email is read, so that says nothing about it either.
|
|
"""
|
|
|
|
permission_classes = []
|
|
authentication_classes = []
|
|
throttle_classes = [MagicLinkIPThrottle, MagicLinkGlobalThrottle]
|
|
|
|
@extend_schema(
|
|
tags=["auth"],
|
|
request=serializer.MagicLinkRequestSerializer,
|
|
responses={
|
|
200: inline_serializer(
|
|
name="MagicLinkRequestResponse",
|
|
fields={"message": serializers.CharField()},
|
|
)
|
|
},
|
|
)
|
|
def post(self, request):
|
|
from common.models import MagicLinkToken
|
|
from common.tasks import send_magic_link_email
|
|
|
|
generic_response = Response(
|
|
{"message": "If this email is valid, you will receive a sign-in link."},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
serializer_obj = serializer.MagicLinkRequestSerializer(data=request.data)
|
|
if not serializer_obj.is_valid():
|
|
return generic_response
|
|
|
|
email = serializer_obj.validated_data["email"].lower()
|
|
delivery = serializer_obj.validated_data.get("delivery", "link")
|
|
|
|
# Rate limit: max 5 tokens per email per hour
|
|
one_hour_ago = timezone.now() - timedelta(hours=1)
|
|
recent_count = MagicLinkToken.objects.filter(
|
|
email=email, created_at__gte=one_hour_ago
|
|
).count()
|
|
if recent_count >= 5:
|
|
return generic_response
|
|
|
|
# Invalidate any existing unused tokens for this email
|
|
MagicLinkToken.objects.filter(email=email, is_used=False).update(is_used=True)
|
|
|
|
# For code delivery, generate a 6-digit OTP. Plaintext stays in memory
|
|
# only long enough to ship to the Celery task; the row stores only the
|
|
# PBKDF2 hash.
|
|
raw_code = None
|
|
code_hash = ""
|
|
if delivery == "code":
|
|
raw_code = f"{secrets.randbelow(10**6):06d}"
|
|
code_hash = make_password(raw_code)
|
|
|
|
# Create new token
|
|
token_obj = MagicLinkToken.objects.create(
|
|
email=email,
|
|
token=secrets.token_hex(32),
|
|
delivery=delivery,
|
|
code_hash=code_hash,
|
|
expires_at=timezone.now() + timedelta(minutes=10),
|
|
ip_address=client_ip(request),
|
|
)
|
|
|
|
# Send email via Celery. Pass raw_code only when delivery is "code".
|
|
send_magic_link_email.delay(str(token_obj.id), raw_code=raw_code)
|
|
|
|
return Response(
|
|
{"message": "If this email is valid, you will receive a sign-in link."},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
class MagicLinkVerifyView(APIView):
|
|
"""
|
|
Verify a magic link token and return JWT tokens.
|
|
Creates a new user if the email doesn't exist.
|
|
"""
|
|
|
|
permission_classes = []
|
|
authentication_classes = []
|
|
|
|
@extend_schema(
|
|
tags=["auth"],
|
|
request=serializer.MagicLinkVerifySerializer,
|
|
responses={
|
|
200: inline_serializer(
|
|
name="MagicLinkVerifyResponse",
|
|
fields={
|
|
"access_token": serializers.CharField(),
|
|
"refresh_token": serializers.CharField(),
|
|
"user": serializers.DictField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def post(self, request):
|
|
from django.contrib.auth.hashers import make_password
|
|
|
|
from common.audit_log import audit_log
|
|
from common.models import MagicLinkToken
|
|
|
|
serializer_obj = serializer.MagicLinkVerifySerializer(data=request.data)
|
|
if not serializer_obj.is_valid():
|
|
return Response(
|
|
{"error": "Invalid request"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
token_value = serializer_obj.validated_data["token"]
|
|
|
|
# Opportunistic cleanup: delete expired tokens
|
|
MagicLinkToken.objects.filter(expires_at__lt=timezone.now()).delete()
|
|
|
|
# Atomically mark token as used (prevents race condition)
|
|
updated = MagicLinkToken.objects.filter(
|
|
token=token_value,
|
|
is_used=False,
|
|
expires_at__gt=timezone.now(),
|
|
).update(is_used=True, used_at=timezone.now())
|
|
|
|
if not updated:
|
|
# The email is the link's own, when the link exists at all (a
|
|
# replayed one, say); the token itself is never recorded.
|
|
link_email = (
|
|
MagicLinkToken.objects.filter(token=token_value)
|
|
.values_list("email", flat=True)
|
|
.first()
|
|
)
|
|
audit_log.login_failure(link_email, "magic_link_invalid", request)
|
|
return Response(
|
|
{"error": "Invalid or expired link"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
token_obj = MagicLinkToken.objects.get(token=token_value)
|
|
|
|
# Get or create user
|
|
email = token_obj.email
|
|
created = False
|
|
try:
|
|
user = User.objects.get(email=email)
|
|
except User.DoesNotExist:
|
|
user = User.objects.create(
|
|
email=email,
|
|
password=make_password(secrets.token_urlsafe(32)),
|
|
is_active=True,
|
|
)
|
|
created = True
|
|
|
|
if not user.is_active:
|
|
audit_log.login_failure(email, "account_disabled", request)
|
|
return _disabled_account_response()
|
|
|
|
if created:
|
|
from common.tasks import send_welcome_email
|
|
|
|
send_welcome_email.delay(str(user.id))
|
|
|
|
# Update last_login
|
|
user.last_login = timezone.now()
|
|
user.save(update_fields=["last_login"])
|
|
|
|
# Get user's organizations
|
|
profiles = Profile.objects.filter(user=user, is_active=True)
|
|
default_org = None
|
|
profile = None
|
|
|
|
if profiles.exists():
|
|
profile = profiles.first()
|
|
default_org = profile.org
|
|
|
|
if default_org:
|
|
token = OrgAwareRefreshToken.for_user_and_org(user, default_org, profile)
|
|
else:
|
|
token = OrgAwareRefreshToken.for_user_and_org(user, None)
|
|
|
|
audit_log.login_success(user, default_org, request)
|
|
|
|
user_serializer = serializer.UserDetailSerializer(user)
|
|
response_data = {
|
|
"access_token": str(token.access_token),
|
|
"refresh_token": str(token),
|
|
"user": user_serializer.data,
|
|
}
|
|
|
|
if default_org:
|
|
response_data["current_org"] = _org_payload(default_org)
|
|
|
|
return Response(response_data, status=status.HTTP_200_OK)
|
|
|
|
|
|
class MagicLinkVerifyCodeView(APIView):
|
|
"""
|
|
Verify a 6-digit OTP code (mobile flow) and return JWT tokens.
|
|
Creates a new user if the email doesn't exist.
|
|
"""
|
|
|
|
permission_classes = []
|
|
authentication_classes = []
|
|
|
|
MAX_ATTEMPTS = 5
|
|
|
|
@extend_schema(
|
|
tags=["auth"],
|
|
request=serializer.MagicLinkVerifyCodeSerializer,
|
|
responses={
|
|
200: inline_serializer(
|
|
name="MagicLinkVerifyCodeResponse",
|
|
fields={
|
|
"access_token": serializers.CharField(),
|
|
"refresh_token": serializers.CharField(),
|
|
"user": serializers.DictField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def post(self, request):
|
|
from django.contrib.auth.hashers import check_password
|
|
from django.db import transaction
|
|
|
|
from common.audit_log import audit_log
|
|
from common.models import MagicLinkToken
|
|
|
|
serializer_obj = serializer.MagicLinkVerifyCodeSerializer(data=request.data)
|
|
if not serializer_obj.is_valid():
|
|
return Response(
|
|
{"error": "Invalid request"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
email = serializer_obj.validated_data["email"].lower()
|
|
code = serializer_obj.validated_data["code"]
|
|
|
|
# Opportunistic cleanup of expired rows.
|
|
MagicLinkToken.objects.filter(expires_at__lt=timezone.now()).delete()
|
|
|
|
# Lock the most-recent unused code-delivery token for this email and
|
|
# check the OTP. select_for_update protects against two concurrent
|
|
# verify requests both observing the same `attempts` value.
|
|
invalid = Response(
|
|
{"error": "Invalid or expired code"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
# The failure row is written after the block commits, so an audit
|
|
# insert that fails cannot roll back the attempt counter with it.
|
|
failure = None
|
|
with transaction.atomic():
|
|
token_obj = (
|
|
MagicLinkToken.objects.select_for_update()
|
|
.filter(
|
|
email=email,
|
|
delivery="code",
|
|
is_used=False,
|
|
expires_at__gt=timezone.now(),
|
|
)
|
|
.order_by("-created_at")
|
|
.first()
|
|
)
|
|
if not token_obj:
|
|
failure = "code_not_found"
|
|
elif not check_password(code, token_obj.code_hash):
|
|
token_obj.attempts = (token_obj.attempts or 0) + 1
|
|
failure = "code_wrong"
|
|
if token_obj.attempts >= self.MAX_ATTEMPTS:
|
|
token_obj.is_used = True
|
|
token_obj.used_at = timezone.now()
|
|
failure = "code_attempts_exhausted"
|
|
token_obj.save(update_fields=["attempts", "is_used", "used_at"])
|
|
else:
|
|
token_obj.is_used = True
|
|
token_obj.used_at = timezone.now()
|
|
token_obj.save(update_fields=["is_used", "used_at"])
|
|
|
|
if failure:
|
|
audit_log.login_failure(email, failure, request)
|
|
return invalid
|
|
|
|
# Get or create user
|
|
created = False
|
|
try:
|
|
user = User.objects.get(email=email)
|
|
except User.DoesNotExist:
|
|
user = User.objects.create(
|
|
email=email,
|
|
password=make_password(secrets.token_urlsafe(32)),
|
|
is_active=True,
|
|
)
|
|
created = True
|
|
|
|
if not user.is_active:
|
|
audit_log.login_failure(email, "account_disabled", request)
|
|
return _disabled_account_response()
|
|
|
|
if created:
|
|
from common.tasks import send_welcome_email
|
|
|
|
send_welcome_email.delay(str(user.id))
|
|
|
|
user.last_login = timezone.now()
|
|
user.save(update_fields=["last_login"])
|
|
|
|
profiles = list(
|
|
Profile.objects.filter(user=user, is_active=True)
|
|
.select_related("org", "user")
|
|
.order_by("org__name")
|
|
)
|
|
# Same shape the Google flow returns, so a client can offer the same
|
|
# picker whichever way the user signed in.
|
|
organizations = [_org_payload(p.org, profile=p) for p in profiles]
|
|
|
|
# Bind the session to an org only when there is no choice to make.
|
|
# Picking `profiles.first()` out of several was an arbitrary answer to
|
|
# a question only the user can answer: it dropped a three-org admin
|
|
# into whichever org the database happened to return first, named
|
|
# nowhere on screen, with the picker suppressed because an org had
|
|
# already been chosen for them.
|
|
default_org = None
|
|
profile = None
|
|
if len(profiles) == 1:
|
|
profile = profiles[0]
|
|
default_org = profile.org
|
|
|
|
token = OrgAwareRefreshToken.for_user_and_org(user, default_org, profile)
|
|
|
|
audit_log.login_success(user, default_org, request)
|
|
|
|
user_serializer = serializer.UserDetailSerializer(user)
|
|
response_data = {
|
|
"access_token": str(token.access_token),
|
|
"refresh_token": str(token),
|
|
"user": user_serializer.data,
|
|
"organizations": organizations,
|
|
}
|
|
if default_org:
|
|
response_data["current_org"] = _org_payload(default_org)
|
|
return Response(response_data, status=status.HTTP_200_OK)
|
|
|
|
|
|
class PasswordLoginView(APIView):
|
|
"""
|
|
Authenticate user with user ID / email and password.
|
|
Returns JWT access and refresh tokens.
|
|
"""
|
|
|
|
permission_classes = []
|
|
authentication_classes = []
|
|
|
|
@extend_schema(
|
|
tags=["auth"],
|
|
request=inline_serializer(
|
|
name="PasswordLoginRequest",
|
|
fields={
|
|
"email": serializers.CharField(required=False),
|
|
"username": serializers.CharField(required=False),
|
|
"password": serializers.CharField(),
|
|
},
|
|
),
|
|
responses={
|
|
200: inline_serializer(
|
|
name="PasswordLoginResponse",
|
|
fields={
|
|
"access_token": serializers.CharField(),
|
|
"refresh_token": serializers.CharField(),
|
|
"user": serializers.DictField(),
|
|
"organizations": serializers.ListField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def post(self, request):
|
|
from common.audit_log import audit_log
|
|
|
|
identifier = (
|
|
request.data.get("email")
|
|
or request.data.get("username")
|
|
or request.data.get("user_id")
|
|
or ""
|
|
).strip()
|
|
password = request.data.get("password") or ""
|
|
|
|
if not identifier or not password:
|
|
return Response(
|
|
{"error": "User ID/Email and password are required."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
user = User.objects.filter(email__iexact=identifier).first()
|
|
if not user and "@" not in identifier:
|
|
user = User.objects.filter(email__istartswith=identifier + "@").first()
|
|
if not user:
|
|
user = User.objects.filter(name__iexact=identifier).first()
|
|
|
|
if not user or not user.check_password(password):
|
|
audit_log.login_failure(identifier, "invalid_credentials", request)
|
|
return Response(
|
|
{"error": "Invalid User ID/Email or password."},
|
|
status=status.HTTP_401_UNAUTHORIZED,
|
|
)
|
|
|
|
if not user.is_active:
|
|
audit_log.login_failure(user.email, "account_disabled", request)
|
|
return _disabled_account_response()
|
|
|
|
user.last_login = timezone.now()
|
|
user.save(update_fields=["last_login"])
|
|
|
|
profiles = list(
|
|
Profile.objects.filter(user=user, is_active=True)
|
|
.select_related("org", "user")
|
|
.order_by("org__name")
|
|
)
|
|
organizations = [_org_payload(p.org, profile=p) for p in profiles]
|
|
|
|
default_org = None
|
|
profile = None
|
|
if len(profiles) == 1:
|
|
profile = profiles[0]
|
|
default_org = profile.org
|
|
elif len(profiles) > 1:
|
|
profile = profiles[0]
|
|
default_org = profile.org
|
|
|
|
token = OrgAwareRefreshToken.for_user_and_org(user, default_org, profile)
|
|
|
|
audit_log.login_success(user, default_org, request)
|
|
|
|
user_serializer = serializer.UserDetailSerializer(user)
|
|
response_data = {
|
|
"access_token": str(token.access_token),
|
|
"refresh_token": str(token),
|
|
"user": user_serializer.data,
|
|
"organizations": organizations,
|
|
}
|
|
if default_org:
|
|
response_data["current_org"] = _org_payload(default_org, profile=profile)
|
|
|
|
return Response(response_data, status=status.HTTP_200_OK)
|
|
|