8 Commits

Author SHA1 Message Date
mohd eb88f23d28 chore: document auth fixes 2026-03-14 00:48:05 +03:00
mohd 0b76356169 fix: deprecate passwords, use phone auth source of truth 2026-03-14 00:47:31 +03:00
mohd c391a9b8e5 chore: update auth progress 2026-03-14 00:32:57 +03:00
mohd 5ece1036cd feat: DB constraints for phone auth 2026-03-14 00:31:20 +03:00
mohd 4026b94c3a feat: phone auth tests and fixes 2026-03-13 23:48:40 +03:00
mohd 38e5ece96f chore: auth gaps docs 2026-03-13 23:45:36 +03:00
mohd 5db211dda9 chore: less brittle tests 2026-03-13 23:26:09 +03:00
mohd c0846fe096 test: added auth contract test 2026-03-13 20:36:47 +03:00
17 changed files with 438 additions and 25 deletions
+1
View File
@@ -0,0 +1 @@
venv
+1 -1
View File
@@ -4,7 +4,7 @@
- Authentica OTP integration is implemented; Moyasar capture/refund are TODOs. - Authentica OTP integration is implemented; Moyasar capture/refund are TODOs.
- External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk. - External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk.
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries. - Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries.
- Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion. - Phone-first auth is in place with `USERNAME_FIELD = "phone_number"`, but endpoint/admin/domain alignment is still incomplete and needs hardening.
## Near-Term Focus ## Near-Term Focus
- finalize otp testing - finalize otp testing
@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-03-13 20:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0003_preferred_language"),
]
operations = [
migrations.AlterField(
model_name="user",
name="phone_number",
field=models.CharField(max_length=20, unique=True),
),
migrations.AddConstraint(
model_name="user",
constraint=models.CheckConstraint(
condition=models.Q(("phone_number__regex", r"^\+[1-9][0-9]{7,14}$")),
name="accounts_user_phone_e164_format",
),
),
]
+12 -3
View File
@@ -4,6 +4,7 @@ from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
from django.db import models from django.db import models
from django.db.models import Q
from django.utils import timezone from django.utils import timezone
@@ -17,8 +18,8 @@ class UserRole(models.TextChoices):
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
def create_user(self, email=None, password=None, **extra_fields): def create_user(self, email=None, password=None, **extra_fields):
phone_number = extra_fields.get("phone_number") phone_number = extra_fields.get("phone_number")
if not email and not phone_number: if not phone_number:
raise ValueError("Email or phone number is required") raise ValueError("Phone number is required")
if email: if email:
email = self.normalize_email(email) email = self.normalize_email(email)
user = self.model(email=email, **extra_fields) user = self.model(email=email, **extra_fields)
@@ -42,7 +43,7 @@ class UserManager(BaseUserManager):
class User(AbstractBaseUser, PermissionsMixin): class User(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(unique=True, null=True, blank=True) email = models.EmailField(unique=True, null=True, blank=True)
phone_number = models.CharField(max_length=20, unique=True, null=True, blank=True) phone_number = models.CharField(max_length=20, unique=True)
role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER) role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER)
first_name = models.CharField(max_length=150, blank=True) first_name = models.CharField(max_length=150, blank=True)
last_name = models.CharField(max_length=150, blank=True) last_name = models.CharField(max_length=150, blank=True)
@@ -62,6 +63,14 @@ class User(AbstractBaseUser, PermissionsMixin):
USERNAME_FIELD = "phone_number" USERNAME_FIELD = "phone_number"
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
class Meta:
constraints = [
models.CheckConstraint(
name="accounts_user_phone_e164_format",
condition=Q(phone_number__regex=r"^\+[1-9][0-9]{7,14}$"),
),
]
def __str__(self): def __str__(self):
return self.email or self.phone_number or str(self.id) return self.email or self.phone_number or str(self.id)
+1
View File
@@ -25,6 +25,7 @@ class UserSerializer(serializers.ModelSerializer):
class RegisterSerializer(serializers.ModelSerializer): class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=8) password = serializers.CharField(write_only=True, min_length=8)
phone_number = serializers.CharField(max_length=20, required=True)
class Meta: class Meta:
model = User model = User
@@ -47,9 +47,11 @@ def test_otp_max_attempts_blocks_verification():
otp.refresh_from_db() otp.refresh_from_db()
assert otp.attempt_count == otp.max_attempts assert otp.attempt_count == otp.max_attempts
# Once the max is reached, even a correct code must remain blocked.
assert verify_otp(otp, "123456") is False assert verify_otp(otp, "123456") is False
otp.refresh_from_db() otp.refresh_from_db()
assert otp.attempt_count == otp.max_attempts + 1 # Do not lock this test to a specific increment policy after lockout.
assert otp.attempt_count >= otp.max_attempts
assert otp.verified_at is None assert otp.verified_at is None
@@ -47,3 +47,32 @@ def test_phone_auth_creates_user_and_issues_tokens(client):
user = User.objects.filter(phone_number="+966512345678").first() user = User.objects.filter(phone_number="+966512345678").first()
assert user is not None assert user is not None
assert user.is_phone_verified is True assert user.is_phone_verified is True
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_refresh_endpoint_still_works(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
request_response = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
request_id = request_response.json()["request_id"]
verify_response = client.post(
reverse("phone_auth_verify"),
{"request_id": request_id, "code": "123456"},
content_type="application/json",
)
assert verify_response.status_code == 200
refresh = verify_response.json()["refresh"]
refresh_response = client.post(
reverse("token_refresh"),
{"refresh": refresh},
content_type="application/json",
)
assert refresh_response.status_code == 200
assert "access" in refresh_response.json()
@@ -0,0 +1,169 @@
from unittest.mock import patch
import pytest
from django.test import override_settings
from django.urls import reverse
from apps.accounts.models import OtpPurpose, PhoneOTP, User
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_request_creates_customer_for_new_phone(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
response = client.post(
reverse("phone_auth_request"),
{
"phone_number": "0512345678",
"channel": "sms",
"first_name": "Sara",
"last_name": "Ali",
"email": "sara@example.com",
},
content_type="application/json",
)
assert response.status_code == 201
data = response.json()
assert "request_id" in data
assert "expires_at" in data
user = User.objects.get(phone_number="+966512345678")
assert user.role == "customer"
assert user.is_phone_verified is False
otp = PhoneOTP.objects.get(id=data["request_id"])
assert otp.phone_number == "+966512345678"
assert otp.channel == "sms"
assert otp.purpose == OtpPurpose.AUTH
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_request_existing_phone_no_duplicate_user(client):
User.objects.create_user(
phone_number="+966512345678",
email="existing@example.com",
first_name="Existing",
)
before_count = User.objects.filter(phone_number="+966512345678").count()
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
response = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert response.status_code == 201
assert User.objects.filter(phone_number="+966512345678").count() == before_count
assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 1
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_request_rejects_email_already_used(client):
User.objects.create_user(
phone_number="+966500000001",
email="taken@example.com",
)
before_otp_count = PhoneOTP.objects.count()
response = client.post(
reverse("phone_auth_request"),
{
"phone_number": "0512345678",
"channel": "sms",
"email": "taken@example.com",
},
content_type="application/json",
)
assert response.status_code == 400
assert "detail" in response.json()
assert User.objects.filter(phone_number="+966512345678").count() == 0
assert PhoneOTP.objects.count() == before_otp_count
@pytest.mark.django_db
def test_phone_auth_request_invalid_phone_localized_en(client):
response = client.post(
reverse("phone_auth_request"),
{"phone_number": "123", "channel": "sms"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="en",
)
assert response.status_code == 400
assert response.json()["phone_number"][0] == "Phone number must be in E.164 format or a valid Saudi mobile"
@pytest.mark.django_db
def test_phone_auth_request_invalid_phone_localized_ar(client):
response = client.post(
reverse("phone_auth_request"),
{"phone_number": "123", "channel": "sms"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="ar-sa",
)
assert response.status_code == 400
assert response.json()["phone_number"][0] == "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
@pytest.mark.django_db
@override_settings(
OTP_PROVIDER="console",
OTP_MAX_PER_WINDOW=5,
OTP_WINDOW_MINUTES=15,
OTP_RESEND_COOLDOWN_SECONDS=60,
)
def test_phone_auth_request_cooldown_returns_retry_after(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
first = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert first.status_code == 201
second = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert second.status_code == 429
data = second.json()
assert "detail" in data
assert data["retry_after_seconds"] > 0
@pytest.mark.django_db
@override_settings(
OTP_PROVIDER="console",
OTP_MAX_PER_WINDOW=1,
OTP_WINDOW_MINUTES=15,
OTP_RESEND_COOLDOWN_SECONDS=0,
)
def test_phone_auth_request_rate_limit_returns_retry_after(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
first = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert first.status_code == 201
second = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert second.status_code == 429
data = second.json()
assert "detail" in data
assert data["retry_after_seconds"] > 0
@@ -0,0 +1,107 @@
from unittest.mock import patch
import pytest
from django.db import IntegrityError
from django.urls import reverse
from apps.accounts.models import OtpPurpose, PhoneOTP, User
@pytest.mark.django_db
def test_create_user_rejects_email_only_identity():
# Phone-first invariant: do not allow creating users without phone_number.
with pytest.raises(ValueError):
User.objects.create_user(email="email-only@example.com")
@pytest.mark.django_db
def test_register_requires_phone_number(client):
# Public registration must keep phone as required identifier.
response = client.post(
reverse("register"),
{"email": "new@example.com", "password": "StrongPass123"},
content_type="application/json",
)
assert response.status_code == 400
assert "phone_number" in response.json()
@pytest.mark.django_db
def test_phone_auth_verify_rejects_verify_purpose_otp(client):
user = User.objects.create_user(phone_number="+966512345678")
otp = PhoneOTP.objects.create(
phone_number=user.phone_number,
channel="sms",
purpose=OtpPurpose.VERIFY,
provider="console",
code_hash="not-used",
expires_at=PhoneOTP.expiry_at(),
)
# Purpose boundary: /phone/verify must only accept auth OTP requests.
with patch("apps.accounts.views.verify_otp", return_value=True):
response = client.post(
reverse("phone_auth_verify"),
{"request_id": str(otp.id), "code": "123456"},
content_type="application/json",
)
assert response.status_code == 400
@pytest.mark.django_db
def test_otp_verify_rejects_auth_purpose_otp(client):
otp = PhoneOTP.objects.create(
phone_number="+966512345678",
channel="sms",
purpose=OtpPurpose.AUTH,
provider="console",
code_hash="not-used",
expires_at=PhoneOTP.expiry_at(),
)
# Purpose boundary: /otp/verify must only accept verify OTP requests.
with patch("apps.accounts.views.verify_otp", return_value=True):
response = client.post(
reverse("otp_verify"),
{"request_id": str(otp.id), "code": "123456"},
content_type="application/json",
)
assert response.status_code == 400
@pytest.mark.django_db
def test_db_rejects_null_phone_number():
# DB invariant: phone_number is mandatory.
with pytest.raises(IntegrityError):
User.objects.create(email="null-phone@example.com")
@pytest.mark.django_db
def test_db_rejects_non_e164_phone_number():
# DB invariant: store normalized E.164 only.
with pytest.raises(IntegrityError):
User.objects.create_user(phone_number="0512345678")
@pytest.mark.django_db
def test_db_rejects_duplicate_phone_number():
User.objects.create_user(phone_number="+966512345678")
with pytest.raises(IntegrityError):
User.objects.create_user(phone_number="+966512345678")
@pytest.mark.django_db
def test_password_token_endpoint_is_disabled(client):
User.objects.create_user(phone_number="+966512345678", password="StrongPass123")
response = client.post(
reverse("token_obtain_pair"),
{"phone_number": "+966512345678", "password": "StrongPass123"},
content_type="application/json",
)
assert response.status_code == 410
assert "detail" in response.json()
+3 -2
View File
@@ -1,5 +1,5 @@
from django.urls import path from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from rest_framework_simplejwt.views import TokenRefreshView
from apps.accounts.views import ( from apps.accounts.views import (
MeView, MeView,
@@ -7,6 +7,7 @@ from apps.accounts.views import (
OTPVerifyView, OTPVerifyView,
PhoneAuthRequestView, PhoneAuthRequestView,
PhoneAuthVerifyView, PhoneAuthVerifyView,
PasswordTokenObtainDeprecatedView,
RegisterView, RegisterView,
SocialLoginPlaceholderView, SocialLoginPlaceholderView,
) )
@@ -14,7 +15,7 @@ from apps.accounts.views import (
urlpatterns = [ urlpatterns = [
path("register/", RegisterView.as_view(), name="register"), path("register/", RegisterView.as_view(), name="register"),
path("me/", MeView.as_view(), name="me"), path("me/", MeView.as_view(), name="me"),
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("token/", PasswordTokenObtainDeprecatedView.as_view(), name="token_obtain_pair"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
path("otp/request/", OTPRequestView.as_view(), name="otp_request"), path("otp/request/", OTPRequestView.as_view(), name="otp_request"),
path("otp/verify/", OTPVerifyView.as_view(), name="otp_verify"), path("otp/verify/", OTPVerifyView.as_view(), name="otp_verify"),
+22 -3
View File
@@ -1,5 +1,4 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404
from rest_framework import generics, permissions, status from rest_framework import generics, permissions, status
from rest_framework.response import Response from rest_framework.response import Response
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@@ -70,7 +69,10 @@ class OTPVerifyView(APIView):
serializer = OTPVerifySerializer(data=request.data) serializer = OTPVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
data = serializer.validated_data data = serializer.validated_data
otp = get_object_or_404(PhoneOTP, id=data["request_id"]) # Purpose isolation: verification endpoint accepts only verify-purpose OTPs.
otp = PhoneOTP.objects.filter(id=data["request_id"], purpose=OtpPurpose.VERIFY).first()
if not otp:
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
if not verify_otp(otp, data["code"]): if not verify_otp(otp, data["code"]):
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
@@ -133,7 +135,10 @@ class PhoneAuthVerifyView(APIView):
serializer = PhoneAuthVerifySerializer(data=request.data) serializer = PhoneAuthVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
data = serializer.validated_data data = serializer.validated_data
otp = get_object_or_404(PhoneOTP, id=data["request_id"]) # Purpose isolation: login endpoint accepts only auth-purpose OTPs.
otp = PhoneOTP.objects.filter(id=data["request_id"], purpose=OtpPurpose.AUTH).first()
if not otp:
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
if not verify_otp(otp, data["code"]): if not verify_otp(otp, data["code"]):
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
@@ -164,3 +169,17 @@ class SocialLoginPlaceholderView(APIView):
{"detail": _("Social login not configured yet. Add OAuth provider config.")}, {"detail": _("Social login not configured yet. Add OAuth provider config.")},
status=status.HTTP_501_NOT_IMPLEMENTED, status=status.HTTP_501_NOT_IMPLEMENTED,
) )
class PasswordTokenObtainDeprecatedView(APIView):
permission_classes = [permissions.AllowAny]
def post(self, request):
return Response(
{
"detail": _(
"Password login is deprecated. Use /api/auth/phone/request/ then /api/auth/phone/verify/."
)
},
status=status.HTTP_410_GONE,
)
@@ -12,9 +12,23 @@ from apps.salons.models import Salon, Service, StaffAvailability, StaffProfile
@pytest.fixture @pytest.fixture
def base_entities(): def base_entities():
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER) owner = User.objects.create_user(
customer = User.objects.create_user(email="customer@example.com", password="pass") email="owner@example.com",
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF) password="pass",
role=UserRole.MANAGER,
phone_number="+966500000001",
)
customer = User.objects.create_user(
email="customer@example.com",
password="pass",
phone_number="+966500000002",
)
staff_user = User.objects.create_user(
email="staff@example.com",
password="pass",
role=UserRole.STAFF,
phone_number="+966500000003",
)
salon = Salon.objects.create( salon = Salon.objects.create(
owner=owner, owner=owner,
@@ -17,18 +17,18 @@ def booking_payload():
email="owner@example.com", email="owner@example.com",
password="pass", password="pass",
role=UserRole.MANAGER, role=UserRole.MANAGER,
phone_number="0500000001", phone_number="+966500000001",
) )
customer = User.objects.create_user( customer = User.objects.create_user(
email="customer@example.com", email="customer@example.com",
password="pass", password="pass",
phone_number="0500000002", phone_number="+966500000002",
) )
staff_user = User.objects.create_user( staff_user = User.objects.create_user(
email="staff@example.com", email="staff@example.com",
password="pass", password="pass",
role=UserRole.STAFF, role=UserRole.STAFF,
phone_number="0500000003", phone_number="+966500000003",
) )
salon = Salon.objects.create( salon = Salon.objects.create(
@@ -15,9 +15,23 @@ from apps.salons.models import Salon, Service, StaffProfile
@pytest.fixture @pytest.fixture
def booking_entities(): def booking_entities():
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER) owner = User.objects.create_user(
customer = User.objects.create_user(email="customer@example.com", password="pass") email="owner@example.com",
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF) password="pass",
role=UserRole.MANAGER,
phone_number="+966500000011",
)
customer = User.objects.create_user(
email="customer@example.com",
password="pass",
phone_number="+966500000012",
)
staff_user = User.objects.create_user(
email="staff@example.com",
password="pass",
role=UserRole.STAFF,
phone_number="+966500000013",
)
salon = Salon.objects.create( salon = Salon.objects.create(
owner=owner, owner=owner,
+11
View File
@@ -6,6 +6,17 @@ This document describes the requirements for an execution plan ("ExecPlan"), a d
The current execution plan is `docs/execplans/booking-notifications.md`. It focuses on booking lifecycle notifications (confirmation/cancellation) as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below. The current execution plan is `docs/execplans/booking-notifications.md`. It focuses on booking lifecycle notifications (confirmation/cancellation) as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below.
## Queued Next Review Focus
After the active booking notifications plan, the next reliability review track is phone-first authentication hardening. Keep these points visible when planning the next ExecPlan update:
- Enforce phone as first-class identifier at model/DB boundaries (normalized E.164, nullability/uniqueness policy).
- Consolidate auth contract (phone OTP vs password endpoints) and document intended public login surface.
- Enforce OTP purpose boundaries (`auth` vs `verify`) in verification flows.
- Align Django admin and cross-app display/audit behavior for phone-only users.
- Define OAuth linking policy and conflict handling (phone/email collisions, account merge rules).
- Add/expand tests for phone-first invariants and abuse controls (IP/device throttling strategy review).
## How to use ExecPlans and PLANS.md ## How to use ExecPlans and PLANS.md
When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research. When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research.
+11
View File
@@ -8,6 +8,17 @@ This file tracks known gaps and risks to address in future iterations.
- Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP). - Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP).
- Social login is a placeholder. - Social login is a placeholder.
- `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users. - `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users.
- Password token obtain endpoint (`/api/auth/token/`) is deprecated (`410 Gone`); phone OTP flow is the login source of truth.
- OTP purpose isolation is enforced at verification endpoint boundaries (`/otp/verify` accepts only `verify`, `/phone/verify` accepts only `auth`).
- Django admin user configuration remains email-centric (ordering/add form defaults), increasing operational friction for phone-only accounts.
- Multiple serializers/model `__str__` paths in non-auth apps still fallback to `user.email`; phone-only users may get poor display/audit clarity.
## Next Auth Review Points
- DB-level guardrails for `accounts_user.phone_number` are now enforced (`NOT NULL`, `UNIQUE`, E.164 check constraint).
- Decide user lifecycle for phone auth (create user before OTP verify vs provisional/pre-user state).
- Expand abuse prevention beyond per-phone cooldown (IP throttling, device fingerprint, risk signals).
- Define OAuth account-linking policy (phone/email conflicts, merge rules, trust source).
- Add explicit tests for remaining phone-first invariants (verified-phone guards and any legacy-path regressions).
## Booking Integrity ## Booking Integrity
- Availability checks and overlap prevention are now enforced for staff bookings. - Availability checks and overlap prevention are now enforced for staff bookings.
+5 -5
View File
@@ -58,18 +58,18 @@ Expected: server starts at `http://127.0.0.1:8000/`.
### 4) Obtain a JWT access token ### 4) Obtain a JWT access token
Password token login at `/api/auth/token/` is deprecated for phone-first auth. For this runbook, mint a local JWT in Django shell.
The demo customer is: The demo customer is:
- `customer@example.com` - `customer@example.com`
- `Customer123!` - `Customer123!`
Fetch the access token: Generate an access token:
curl -s -X POST http://127.0.0.1:8000/api/auth/token/ \ python3 manage.py shell -c "from django.contrib.auth import get_user_model; from rest_framework_simplejwt.tokens import RefreshToken; u=get_user_model().objects.get(email='customer@example.com'); print(str(RefreshToken.for_user(u).access_token))"
-H "Content-Type: application/json" \
-d '{"email":"customer@example.com","password":"Customer123!"}'
Expected: JSON containing `access` and `refresh` tokens. Expected: a JWT string printed to stdout. Use it as `<ACCESS>`.
### 5) Create a payment ### 5) Create a payment