register and sign in pages
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
|
||||
from .models import OtpCode, RegistrationTicket, User
|
||||
from .sms import send_registration_approved, send_registration_rejected
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
ordering = ["-date_joined"]
|
||||
list_display = [
|
||||
"mobile",
|
||||
"full_name",
|
||||
"status",
|
||||
"idnumber",
|
||||
"island",
|
||||
"auth_method",
|
||||
"date_joined",
|
||||
]
|
||||
list_filter = ["status", "auth_method", "atoll", "is_staff", "is_active"]
|
||||
search_fields = ["mobile", "full_name", "email", "idnumber"]
|
||||
readonly_fields = [
|
||||
"date_joined",
|
||||
"updated_at",
|
||||
"last_login",
|
||||
"terms_accepted_at",
|
||||
"policy_accepted_at",
|
||||
"reviewed_at",
|
||||
"reviewed_by",
|
||||
]
|
||||
autocomplete_fields = ["atoll", "island"]
|
||||
actions = ["approve_registrations", "reject_registrations"]
|
||||
fieldsets = [
|
||||
(None, {"fields": ["mobile", "password"]}),
|
||||
(
|
||||
"Applicant",
|
||||
{
|
||||
"fields": [
|
||||
"full_name",
|
||||
"email",
|
||||
"idnumber",
|
||||
"date_of_birth",
|
||||
"atoll",
|
||||
"island",
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
"Registration review",
|
||||
{
|
||||
"fields": [
|
||||
"status",
|
||||
"rejection_reason",
|
||||
"terms_accepted_at",
|
||||
"policy_accepted_at",
|
||||
"reviewed_at",
|
||||
"reviewed_by",
|
||||
]
|
||||
},
|
||||
),
|
||||
("Portal", {"fields": ["auth_method", "mobile_verified"]}),
|
||||
(
|
||||
"Permissions",
|
||||
{
|
||||
"fields": [
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
]
|
||||
},
|
||||
),
|
||||
("Dates", {"fields": ["last_login", "date_joined", "updated_at"]}),
|
||||
]
|
||||
add_fieldsets = [
|
||||
(
|
||||
None,
|
||||
{
|
||||
"classes": ["wide"],
|
||||
"fields": [
|
||||
"mobile",
|
||||
"full_name",
|
||||
"auth_method",
|
||||
"password1",
|
||||
"password2",
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@admin.action(description="Approve selected registrations")
|
||||
def approve_registrations(self, request, queryset):
|
||||
pending = queryset.exclude(status=User.Status.APPROVED)
|
||||
for user in pending:
|
||||
user.approve(reviewer=request.user)
|
||||
send_registration_approved(user.mobile)
|
||||
self.message_user(
|
||||
request, f"Approved {pending.count()} registration(s).", messages.SUCCESS
|
||||
)
|
||||
|
||||
@admin.action(description="Reject selected registrations")
|
||||
def reject_registrations(self, request, queryset):
|
||||
pending = queryset.exclude(status=User.Status.REJECTED)
|
||||
for user in pending:
|
||||
user.reject(reviewer=request.user)
|
||||
send_registration_rejected(user.mobile, user.rejection_reason)
|
||||
self.message_user(
|
||||
request, f"Rejected {pending.count()} registration(s).", messages.WARNING
|
||||
)
|
||||
|
||||
|
||||
@admin.register(OtpCode)
|
||||
class OtpCodeAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"mobile",
|
||||
"purpose",
|
||||
"attempts",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"consumed_at",
|
||||
]
|
||||
list_filter = ["purpose"]
|
||||
search_fields = ["mobile"]
|
||||
readonly_fields = [
|
||||
"user",
|
||||
"mobile",
|
||||
"purpose",
|
||||
"code_hash",
|
||||
"attempts",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"consumed_at",
|
||||
]
|
||||
|
||||
|
||||
@admin.register(RegistrationTicket)
|
||||
class RegistrationTicketAdmin(admin.ModelAdmin):
|
||||
list_display = ["mobile", "created_at", "expires_at", "consumed_at"]
|
||||
search_fields = ["mobile"]
|
||||
readonly_fields = ["key", "mobile", "created_at", "expires_at", "consumed_at"]
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "users"
|
||||
@@ -0,0 +1,38 @@
|
||||
from django.contrib.auth.models import BaseUserManager
|
||||
|
||||
from .mobile import normalize_mobile
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
"""Users are identified by mobile number, not username."""
|
||||
|
||||
use_in_migrations = True
|
||||
|
||||
def _create_user(self, mobile, password=None, **extra_fields):
|
||||
if not mobile:
|
||||
raise ValueError("A mobile number is required.")
|
||||
user = self.model(mobile=normalize_mobile(mobile), **extra_fields)
|
||||
if password:
|
||||
user.set_password(password)
|
||||
else:
|
||||
user.set_unusable_password()
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_user(self, mobile, password=None, **extra_fields):
|
||||
extra_fields.setdefault("is_staff", False)
|
||||
extra_fields.setdefault("is_superuser", False)
|
||||
return self._create_user(mobile, password, **extra_fields)
|
||||
|
||||
def create_superuser(self, mobile, password=None, **extra_fields):
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
extra_fields.setdefault("is_active", True)
|
||||
extra_fields.setdefault("status", self.model.Status.APPROVED)
|
||||
extra_fields.setdefault("auth_method", self.model.AuthMethod.PASSWORD)
|
||||
if not extra_fields["is_staff"] or not extra_fields["is_superuser"]:
|
||||
raise ValueError("Superusers must have is_staff and is_superuser set.")
|
||||
return self._create_user(mobile, password, **extra_fields)
|
||||
|
||||
def get_by_natural_key(self, username):
|
||||
return self.get(mobile=normalize_mobile(username))
|
||||
@@ -0,0 +1,89 @@
|
||||
# Generated by Django 5.2.7 on 2026-09-21 19:05
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import users.managers
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('locations', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RegistrationTicket',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(db_index=True, max_length=64, unique=True)),
|
||||
('mobile', models.CharField(db_index=True, max_length=16)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('consumed_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('mobile', models.CharField(db_index=True, max_length=16, unique=True)),
|
||||
('full_name', models.CharField(blank=True, max_length=255)),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True)),
|
||||
('document_type', models.CharField(choices=[('id_card', 'ID card'), ('passport', 'Passport'), ('work_permit', 'Work permit')], default='id_card', max_length=16)),
|
||||
('document_number', models.CharField(blank=True, db_index=True, max_length=32)),
|
||||
('date_of_birth', models.DateField(blank=True, null=True)),
|
||||
('auth_method', models.CharField(choices=[('otp', 'SMS one-time code'), ('password', 'Password')], default='otp', max_length=16)),
|
||||
('mobile_verified', models.BooleanField(default=False)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending approval'), ('approved', 'Approved'), ('rejected', 'Rejected')], db_index=True, default='pending', max_length=16)),
|
||||
('terms_accepted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('policy_accepted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('reviewed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('rejection_reason', models.TextField(blank=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('is_staff', models.BooleanField(default=False)),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('atoll', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='locations.atoll')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('island', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='locations.island')),
|
||||
('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_users', to=settings.AUTH_USER_MODEL)),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-date_joined'],
|
||||
},
|
||||
managers=[
|
||||
('objects', users.managers.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OtpCode',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('mobile', models.CharField(db_index=True, max_length=16)),
|
||||
('purpose', models.CharField(choices=[('login', 'Login'), ('registration', 'Registration')], default='login', max_length=16)),
|
||||
('code_hash', models.CharField(max_length=128)),
|
||||
('attempts', models.PositiveSmallIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('consumed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='otp_codes', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['mobile', 'purpose', '-created_at'], name='users_otpco_mobile_0ef94c_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("users", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="user", name="document_type"),
|
||||
migrations.RenameField(
|
||||
model_name="user", old_name="document_number", new_name="idnumber"
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Maldives mobile number normalisation.
|
||||
|
||||
Everything past the serializer layer deals in E.164 (`+960XXXXXXX`) so a
|
||||
number is stored and looked up exactly one way.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
COUNTRY_CODE = "960"
|
||||
LOCAL_LENGTH = 7
|
||||
# Maldives mobile prefixes are 7xx and 9xx.
|
||||
LOCAL_RE = re.compile(r"^[79]\d{6}$")
|
||||
|
||||
|
||||
def normalize_mobile(value: str) -> str:
|
||||
"""Return `value` as +960XXXXXXX, or raise ValidationError."""
|
||||
digits = re.sub(r"[\s()-]", "", str(value or "")).lstrip("+")
|
||||
|
||||
if digits.startswith("00" + COUNTRY_CODE):
|
||||
digits = digits[len("00" + COUNTRY_CODE) :]
|
||||
elif digits.startswith(COUNTRY_CODE) and len(digits) > LOCAL_LENGTH:
|
||||
digits = digits[len(COUNTRY_CODE) :]
|
||||
|
||||
if not LOCAL_RE.match(digits):
|
||||
raise ValidationError("Enter a valid Maldives mobile number.")
|
||||
|
||||
return f"+{COUNTRY_CODE}{digits}"
|
||||
@@ -0,0 +1,279 @@
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password, make_password
|
||||
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from .managers import UserManager
|
||||
from .mobile import normalize_mobile
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
"""Portal account. The mobile number is the login identifier.
|
||||
|
||||
A self-registered account starts at `status = PENDING` and only becomes
|
||||
usable once an admin approves it - see `approve()` / `reject()`.
|
||||
"""
|
||||
|
||||
class AuthMethod(models.TextChoices):
|
||||
OTP = "otp", "SMS one-time code"
|
||||
PASSWORD = "password", "Password"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending approval"
|
||||
APPROVED = "approved", "Approved"
|
||||
REJECTED = "rejected", "Rejected"
|
||||
|
||||
mobile = models.CharField(max_length=16, unique=True, db_index=True)
|
||||
full_name = models.CharField(max_length=255, blank=True)
|
||||
email = models.EmailField(blank=True, null=True, unique=True)
|
||||
|
||||
# ID card, passport or work permit number.
|
||||
idnumber = models.CharField(max_length=32, blank=True, db_index=True)
|
||||
date_of_birth = models.DateField(null=True, blank=True)
|
||||
|
||||
# Address
|
||||
atoll = models.ForeignKey(
|
||||
"locations.Atoll",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="users",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
island = models.ForeignKey(
|
||||
"locations.Island",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="users",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
# Which second step /auth/start/ asks the SPA to render.
|
||||
auth_method = models.CharField(
|
||||
max_length=16, choices=AuthMethod.choices, default=AuthMethod.OTP
|
||||
)
|
||||
mobile_verified = models.BooleanField(default=False)
|
||||
|
||||
# Registration review
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.PENDING, db_index=True
|
||||
)
|
||||
terms_accepted_at = models.DateTimeField(null=True, blank=True)
|
||||
policy_accepted_at = models.DateTimeField(null=True, blank=True)
|
||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
reviewed_by = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="reviewed_users",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
rejection_reason = models.TextField(blank=True)
|
||||
|
||||
is_active = models.BooleanField(default=True)
|
||||
is_staff = models.BooleanField(default=False)
|
||||
|
||||
date_joined = models.DateTimeField(default=timezone.now)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "mobile"
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
class Meta:
|
||||
ordering = ["-date_joined"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.full_name or 'Unnamed'} ({self.mobile})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.mobile = normalize_mobile(self.mobile)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_admin_user(self) -> bool:
|
||||
"""Admin surface gate: staff or superuser."""
|
||||
return self.is_staff or self.is_superuser
|
||||
|
||||
@property
|
||||
def is_approved(self) -> bool:
|
||||
return self.status == self.Status.APPROVED
|
||||
|
||||
def can_use_password_login(self) -> bool:
|
||||
return (
|
||||
self.auth_method == self.AuthMethod.PASSWORD and self.has_usable_password()
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_auth_method(self) -> str:
|
||||
"""`auth_method`, falling back to OTP if no password is actually set."""
|
||||
if self.can_use_password_login():
|
||||
return self.AuthMethod.PASSWORD
|
||||
return self.AuthMethod.OTP
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
parts = [part for part in [self.island_id and self.island.name, self.atoll_id and self.atoll.name] if part]
|
||||
return ", ".join(parts)
|
||||
|
||||
def approve(self, reviewer=None) -> None:
|
||||
self.status = self.Status.APPROVED
|
||||
self.rejection_reason = ""
|
||||
self.reviewed_at = timezone.now()
|
||||
self.reviewed_by = reviewer
|
||||
self.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"rejection_reason",
|
||||
"reviewed_at",
|
||||
"reviewed_by",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
def reject(self, reviewer=None, reason: str = "") -> None:
|
||||
self.status = self.Status.REJECTED
|
||||
self.rejection_reason = reason
|
||||
self.reviewed_at = timezone.now()
|
||||
self.reviewed_by = reviewer
|
||||
self.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"rejection_reason",
|
||||
"reviewed_at",
|
||||
"reviewed_by",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _generate_code() -> str:
|
||||
return f"{secrets.randbelow(1_000_000):06d}"
|
||||
|
||||
|
||||
class OtpCodeQuerySet(models.QuerySet):
|
||||
def active(self):
|
||||
return self.filter(consumed_at__isnull=True, expires_at__gt=timezone.now())
|
||||
|
||||
|
||||
class OtpCode(models.Model):
|
||||
"""A single-use SMS code. Only the hash of the code is stored."""
|
||||
|
||||
class Purpose(models.TextChoices):
|
||||
LOGIN = "login", "Login"
|
||||
REGISTRATION = "registration", "Registration"
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="otp_codes",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
mobile = models.CharField(max_length=16, db_index=True)
|
||||
purpose = models.CharField(
|
||||
max_length=16, choices=Purpose.choices, default=Purpose.LOGIN
|
||||
)
|
||||
code_hash = models.CharField(max_length=128)
|
||||
attempts = models.PositiveSmallIntegerField(default=0)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
expires_at = models.DateTimeField()
|
||||
consumed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
objects = OtpCodeQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [models.Index(fields=["mobile", "purpose", "-created_at"])]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.purpose} code for {self.mobile}"
|
||||
|
||||
@classmethod
|
||||
def issue(cls, mobile: str, purpose: str, user=None) -> tuple["OtpCode", str]:
|
||||
"""Invalidate any outstanding codes and return (row, plaintext code)."""
|
||||
cls.objects.filter(
|
||||
mobile=mobile, purpose=purpose, consumed_at__isnull=True
|
||||
).update(consumed_at=timezone.now())
|
||||
|
||||
code = _generate_code()
|
||||
otp = cls.objects.create(
|
||||
user=user,
|
||||
mobile=mobile,
|
||||
purpose=purpose,
|
||||
code_hash=make_password(code),
|
||||
expires_at=timezone.now() + timedelta(seconds=settings.OTP_TTL_SECONDS),
|
||||
)
|
||||
return otp, code
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return self.expires_at <= timezone.now()
|
||||
|
||||
@property
|
||||
def is_exhausted(self) -> bool:
|
||||
return self.attempts >= settings.OTP_MAX_ATTEMPTS
|
||||
|
||||
@property
|
||||
def resend_available_at(self):
|
||||
return self.created_at + timedelta(
|
||||
seconds=settings.OTP_RESEND_COOLDOWN_SECONDS
|
||||
)
|
||||
|
||||
def verify(self, code: str) -> bool:
|
||||
"""Check `code`, counting the attempt. Consumes the row on success."""
|
||||
self.attempts += 1
|
||||
if check_password(str(code), self.code_hash):
|
||||
self.consumed_at = timezone.now()
|
||||
self.save(update_fields=["attempts", "consumed_at"])
|
||||
return True
|
||||
self.save(update_fields=["attempts"])
|
||||
return False
|
||||
|
||||
|
||||
class RegistrationTicketQuerySet(models.QuerySet):
|
||||
def active(self):
|
||||
return self.filter(consumed_at__isnull=True, expires_at__gt=timezone.now())
|
||||
|
||||
|
||||
class RegistrationTicket(models.Model):
|
||||
"""Proof that a mobile number was verified by SMS, redeemable once.
|
||||
|
||||
Issued when a registration code is confirmed and required by the
|
||||
registration submit, so the form can't be posted for a number the caller
|
||||
never proved they control.
|
||||
"""
|
||||
|
||||
key = models.CharField(max_length=64, unique=True, db_index=True)
|
||||
mobile = models.CharField(max_length=16, db_index=True)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
expires_at = models.DateTimeField()
|
||||
consumed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
objects = RegistrationTicketQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"registration ticket for {self.mobile}"
|
||||
|
||||
@classmethod
|
||||
def issue(cls, mobile: str) -> "RegistrationTicket":
|
||||
cls.objects.filter(mobile=mobile, consumed_at__isnull=True).update(
|
||||
consumed_at=timezone.now()
|
||||
)
|
||||
return cls.objects.create(
|
||||
key=secrets.token_urlsafe(32),
|
||||
mobile=mobile,
|
||||
expires_at=timezone.now()
|
||||
+ timedelta(seconds=settings.REGISTRATION_TICKET_TTL_SECONDS),
|
||||
)
|
||||
|
||||
def consume(self) -> None:
|
||||
self.consumed_at = timezone.now()
|
||||
self.save(update_fields=["consumed_at"])
|
||||
@@ -0,0 +1,170 @@
|
||||
from datetime import date
|
||||
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from rest_framework import serializers
|
||||
|
||||
from locations.models import Atoll, Island
|
||||
|
||||
from .mobile import normalize_mobile
|
||||
from .models import RegistrationTicket, User
|
||||
|
||||
MAX_AGE_YEARS = 120
|
||||
|
||||
|
||||
class MobileField(serializers.CharField):
|
||||
"""Accepts 7712345 / 9607712345 / +960 771 2345 and stores +9607712345."""
|
||||
|
||||
def to_internal_value(self, data):
|
||||
value = super().to_internal_value(data)
|
||||
try:
|
||||
return normalize_mobile(value)
|
||||
except DjangoValidationError as exc:
|
||||
raise serializers.ValidationError(exc.messages) from exc
|
||||
|
||||
|
||||
class AuthStartSerializer(serializers.Serializer):
|
||||
mobile = MobileField()
|
||||
|
||||
|
||||
class PasswordLoginSerializer(serializers.Serializer):
|
||||
mobile = MobileField()
|
||||
password = serializers.CharField(trim_whitespace=False, write_only=True)
|
||||
|
||||
|
||||
class OtpVerifySerializer(serializers.Serializer):
|
||||
mobile = MobileField()
|
||||
code = serializers.RegexField(r"^\d{6}$", write_only=True)
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
is_admin = serializers.BooleanField(source="is_admin_user", read_only=True)
|
||||
has_password = serializers.SerializerMethodField()
|
||||
atoll_name = serializers.CharField(source="atoll.name", default=None, read_only=True)
|
||||
island_name = serializers.CharField(
|
||||
source="island.name", default=None, read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"mobile",
|
||||
"full_name",
|
||||
"email",
|
||||
"idnumber",
|
||||
"date_of_birth",
|
||||
"atoll",
|
||||
"atoll_name",
|
||||
"island",
|
||||
"island_name",
|
||||
"auth_method",
|
||||
"status",
|
||||
"rejection_reason",
|
||||
"mobile_verified",
|
||||
"is_admin",
|
||||
"has_password",
|
||||
"date_joined",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_has_password(self, obj) -> bool:
|
||||
return obj.has_usable_password()
|
||||
|
||||
|
||||
class RegistrationSerializer(serializers.Serializer):
|
||||
"""The registration form.
|
||||
|
||||
The mobile number is not accepted from the client: it comes from the
|
||||
`registration_token`, which is only issued after that number confirmed an
|
||||
SMS code. So the number on the account is always one the applicant proved
|
||||
they control, and the form's prefilled field can't be tampered with.
|
||||
"""
|
||||
|
||||
registration_token = serializers.CharField(write_only=True)
|
||||
full_name = serializers.CharField(max_length=255)
|
||||
idnumber = serializers.CharField(max_length=32)
|
||||
date_of_birth = serializers.DateField()
|
||||
atoll = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Atoll.objects.filter(is_active=True)
|
||||
)
|
||||
island = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Island.objects.filter(is_active=True)
|
||||
)
|
||||
terms_accepted = serializers.BooleanField()
|
||||
policy_accepted = serializers.BooleanField()
|
||||
|
||||
def validate_registration_token(self, value):
|
||||
ticket = RegistrationTicket.objects.active().filter(key=value).first()
|
||||
if ticket is None:
|
||||
raise serializers.ValidationError(
|
||||
"Your number needs to be verified again."
|
||||
)
|
||||
return ticket
|
||||
|
||||
def validate_full_name(self, value):
|
||||
name = " ".join(value.split())
|
||||
if len(name) < 3:
|
||||
raise serializers.ValidationError("Enter your full name.")
|
||||
return name
|
||||
|
||||
def validate_idnumber(self, value):
|
||||
return value.strip().upper()
|
||||
|
||||
def validate_date_of_birth(self, value):
|
||||
today = date.today()
|
||||
if value > today:
|
||||
raise serializers.ValidationError("Date of birth can't be in the future.")
|
||||
if value.year < today.year - MAX_AGE_YEARS:
|
||||
raise serializers.ValidationError("Enter a valid date of birth.")
|
||||
return value
|
||||
|
||||
def validate_terms_accepted(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError(
|
||||
"You must agree to the terms and conditions."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_policy_accepted(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError(
|
||||
"You must confirm you understand the privacy policy."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
island = attrs["island"]
|
||||
if island.atoll_id != attrs["atoll"].pk:
|
||||
raise serializers.ValidationError(
|
||||
{"island": "That island isn't in the selected atoll."}
|
||||
)
|
||||
|
||||
ticket = attrs["registration_token"]
|
||||
if User.objects.filter(mobile=ticket.mobile).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"mobile": "An account already exists for this number."}
|
||||
)
|
||||
return attrs
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
ticket = validated_data["registration_token"]
|
||||
now = timezone.now()
|
||||
|
||||
user = User.objects.create_user(
|
||||
mobile=ticket.mobile,
|
||||
full_name=validated_data["full_name"],
|
||||
idnumber=validated_data["idnumber"],
|
||||
date_of_birth=validated_data["date_of_birth"],
|
||||
atoll=validated_data["atoll"],
|
||||
island=validated_data["island"],
|
||||
auth_method=User.AuthMethod.OTP,
|
||||
status=User.Status.PENDING,
|
||||
mobile_verified=True,
|
||||
terms_accepted_at=now,
|
||||
policy_accepted_at=now,
|
||||
)
|
||||
ticket.consume()
|
||||
return user
|
||||
@@ -0,0 +1,73 @@
|
||||
"""SMS delivery via the SAR Link SMS gateway.
|
||||
|
||||
POST {SMS_API_URL}
|
||||
X-API-Key: {SMS_API_KEY}
|
||||
{"to": "+9607712345", "text": "..."}
|
||||
|
||||
With `SMS_API_URL`/`SMS_API_KEY` unset (dev) nothing is sent and the message is
|
||||
logged instead, so the OTP flows stay usable without the gateway.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
def send_sms(mobile: str, text: str) -> bool:
|
||||
if not settings.SMS_API_URL or not settings.SMS_API_KEY:
|
||||
logger.warning("SMS not configured; would send to %s: %s", mobile, text)
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.SMS_API_URL,
|
||||
json={"to": mobile, "text": text},
|
||||
headers={
|
||||
"X-API-Key": settings.SMS_API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException:
|
||||
logger.exception("Failed to send SMS to %s", mobile)
|
||||
return False
|
||||
|
||||
logger.info("Sent SMS to %s", mobile)
|
||||
return True
|
||||
|
||||
|
||||
def send_otp(mobile: str, code: str, purpose: str) -> bool:
|
||||
minutes = max(1, settings.OTP_TTL_SECONDS // 60)
|
||||
what = "registration" if purpose == "registration" else "login"
|
||||
return send_sms(
|
||||
mobile,
|
||||
f"{code} is your SAR Link {what} code. It expires in {minutes} minutes.",
|
||||
)
|
||||
|
||||
|
||||
def send_registration_submitted(mobile: str) -> bool:
|
||||
return send_sms(
|
||||
mobile,
|
||||
"Thanks for registering with SAR Link. Your application is being "
|
||||
"reviewed and we'll text you once it's approved.",
|
||||
)
|
||||
|
||||
|
||||
def send_registration_approved(mobile: str) -> bool:
|
||||
return send_sms(
|
||||
mobile,
|
||||
f"Your SAR Link registration is approved. Sign in at {settings.FRONTEND_URL}",
|
||||
)
|
||||
|
||||
|
||||
def send_registration_rejected(mobile: str, reason: str = "") -> bool:
|
||||
tail = f" Reason: {reason}" if reason else ""
|
||||
return send_sms(
|
||||
mobile, f"Your SAR Link registration could not be approved.{tail}"
|
||||
)
|
||||
@@ -0,0 +1,185 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from users.models import OtpCode, User
|
||||
|
||||
|
||||
class AuthFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.otp_user = User.objects.create_user(mobile="7712345", full_name="Otp User")
|
||||
self.password_user = User.objects.create_user(
|
||||
mobile="7798765",
|
||||
full_name="Password User",
|
||||
password="correct-horse-battery",
|
||||
auth_method=User.AuthMethod.PASSWORD,
|
||||
)
|
||||
|
||||
# --- step 1 ---------------------------------------------------------
|
||||
def test_start_reports_password_method(self):
|
||||
response = self.client.post(reverse("auth-start"), {"mobile": "7798765"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["next"], "password")
|
||||
self.assertFalse(OtpCode.objects.exists())
|
||||
|
||||
def test_start_sends_a_code_for_otp_accounts(self):
|
||||
response = self.client.post(reverse("auth-start"), {"mobile": "771 2345"})
|
||||
self.assertEqual(response.data["next"], "otp")
|
||||
self.assertEqual(response.data["mobile"], "+9607712345")
|
||||
self.assertEqual(OtpCode.objects.filter(user=self.otp_user).count(), 1)
|
||||
|
||||
def test_start_does_not_say_whether_a_number_has_an_account(self):
|
||||
known = self.client.post(reverse("auth-start"), {"mobile": "7712345"})
|
||||
unknown = self.client.post(reverse("auth-start"), {"mobile": "7700000"})
|
||||
|
||||
self.assertEqual(unknown.status_code, known.status_code)
|
||||
self.assertEqual(set(unknown.data), set(known.data))
|
||||
self.assertEqual(unknown.data["next"], "otp")
|
||||
|
||||
def test_start_never_returns_a_masked_number(self):
|
||||
for mobile in ["7798765", "7712345", "7700000"]:
|
||||
with self.subTest(mobile=mobile):
|
||||
response = self.client.post(reverse("auth-start"), {"mobile": mobile})
|
||||
self.assertNotIn("mobile_masked", response.data)
|
||||
|
||||
def test_start_falls_back_to_a_code_when_no_password_is_set(self):
|
||||
self.otp_user.auth_method = User.AuthMethod.PASSWORD
|
||||
self.otp_user.save(update_fields=["auth_method"])
|
||||
response = self.client.post(reverse("auth-start"), {"mobile": "7712345"})
|
||||
self.assertEqual(response.data["next"], "otp")
|
||||
|
||||
def test_start_rejects_a_malformed_number(self):
|
||||
response = self.client.post(reverse("auth-start"), {"mobile": "123"})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["code"], "invalid")
|
||||
self.assertIn("mobile", response.data["errors"])
|
||||
|
||||
# --- step 2: password ----------------------------------------------
|
||||
def test_password_login_returns_a_token(self):
|
||||
response = self.client.post(
|
||||
reverse("auth-login-password"),
|
||||
{"mobile": "7798765", "password": "correct-horse-battery"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["next"], "dashboard")
|
||||
self.assertIn("token", response.data)
|
||||
self.assertEqual(response.data["user"]["mobile"], "+9607798765")
|
||||
|
||||
def test_password_login_rejects_a_wrong_password(self):
|
||||
response = self.client.post(
|
||||
reverse("auth-login-password"),
|
||||
{"mobile": "7798765", "password": "nope"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["code"], "invalid_credentials")
|
||||
|
||||
# --- step 2: code ---------------------------------------------------
|
||||
def _issue_code(self, user=None):
|
||||
target = user or self.otp_user
|
||||
return OtpCode.issue(
|
||||
mobile=target.mobile, purpose=OtpCode.Purpose.LOGIN, user=target
|
||||
)
|
||||
|
||||
def test_verifying_a_code_signs_the_account_in(self):
|
||||
_, code = self._issue_code()
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7712345", "code": code}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["next"], "dashboard")
|
||||
self.assertIn("token", response.data)
|
||||
self.otp_user.refresh_from_db()
|
||||
self.assertTrue(self.otp_user.mobile_verified)
|
||||
|
||||
def test_a_code_works_only_once(self):
|
||||
_, code = self._issue_code()
|
||||
self.client.post(reverse("auth-verify"), {"mobile": "7712345", "code": code})
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7712345", "code": code}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["code"], "code_expired")
|
||||
|
||||
def test_wrong_code_counts_attempts(self):
|
||||
self._issue_code()
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7712345", "code": "000000"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["code"], "invalid_code")
|
||||
self.assertEqual(response.data["attempts_left"], 4)
|
||||
|
||||
def test_code_is_exhausted_after_max_attempts(self):
|
||||
self._issue_code()
|
||||
for _ in range(5):
|
||||
self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7712345", "code": "000000"}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7712345", "code": "000000"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 429)
|
||||
self.assertEqual(response.data["code"], "code_exhausted")
|
||||
|
||||
def test_issuing_a_new_code_invalidates_the_previous_one(self):
|
||||
_, first = self._issue_code()
|
||||
_, second = self._issue_code()
|
||||
self.assertEqual(OtpCode.objects.active().count(), 1)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7712345", "code": first}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7712345", "code": second}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_a_disabled_account_is_told_only_after_verifying(self):
|
||||
self.otp_user.is_active = False
|
||||
self.otp_user.save(update_fields=["is_active"])
|
||||
|
||||
start = self.client.post(reverse("auth-start"), {"mobile": "7712345"})
|
||||
self.assertEqual(start.data["next"], "otp")
|
||||
|
||||
_, code = self._issue_code()
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7712345", "code": code}
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.data["code"], "account_disabled")
|
||||
|
||||
# --- resend ----------------------------------------------------------
|
||||
def test_resend_is_rate_limited_by_the_cooldown(self):
|
||||
self.client.post(reverse("auth-start"), {"mobile": "7712345"})
|
||||
response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7712345"})
|
||||
self.assertEqual(response.status_code, 429)
|
||||
self.assertEqual(response.data["code"], "resend_cooldown")
|
||||
|
||||
def test_resend_works_for_a_number_without_an_account(self):
|
||||
response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7700000"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["next"], "otp")
|
||||
self.assertTrue(OtpCode.objects.filter(mobile="+9607700000").exists())
|
||||
|
||||
def test_resend_does_not_reveal_that_an_account_uses_a_password(self):
|
||||
response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7798765"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["next"], "otp")
|
||||
self.assertFalse(OtpCode.objects.exists())
|
||||
|
||||
# --- session --------------------------------------------------------
|
||||
def test_me_requires_a_token(self):
|
||||
self.assertEqual(self.client.get(reverse("auth-me")).status_code, 401)
|
||||
|
||||
def test_me_returns_the_signed_in_account(self):
|
||||
login = self.client.post(
|
||||
reverse("auth-login-password"),
|
||||
{"mobile": "7798765", "password": "correct-horse-battery"},
|
||||
)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {login.data['token']}")
|
||||
response = self.client.get(reverse("auth-me"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["mobile"], "+9607798765")
|
||||
self.assertFalse(response.data["is_admin"])
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from users.mobile import normalize_mobile
|
||||
|
||||
|
||||
class NormalizeMobileTests(SimpleTestCase):
|
||||
def test_accepts_every_way_a_number_is_typed(self):
|
||||
for value in ["7712345", "+9607712345", "9607712345", "960 771 2345", "009607712345", "771-2345"]:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(normalize_mobile(value), "+9607712345")
|
||||
|
||||
def test_rejects_invalid_numbers(self):
|
||||
for value in ["", "123", "1712345", "77123456", "abcdefg", None]:
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValidationError):
|
||||
normalize_mobile(value)
|
||||
@@ -0,0 +1,250 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from locations.models import Atoll, Island
|
||||
from users.models import OtpCode, RegistrationTicket, User
|
||||
|
||||
|
||||
class RegistrationFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.atoll = Atoll.objects.get(name="Faafu")
|
||||
self.island = Island.objects.get(name="Dharanboodhoo")
|
||||
self.other_atoll = Atoll.objects.create(name="Kaafu", code="K")
|
||||
self.other_island = Island.objects.create(atoll=self.other_atoll, name="Male")
|
||||
|
||||
# --- verifying the number -------------------------------------------
|
||||
def test_unknown_number_gets_a_code_without_being_told_anything(self):
|
||||
response = self.client.post(reverse("auth-start"), {"mobile": "7700000"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["next"], "otp")
|
||||
self.assertEqual(response.data["mobile"], "+9607700000")
|
||||
|
||||
otp = OtpCode.objects.get(mobile="+9607700000")
|
||||
self.assertEqual(otp.purpose, OtpCode.Purpose.REGISTRATION)
|
||||
self.assertIsNone(otp.user)
|
||||
|
||||
def test_verifying_an_unknown_number_hands_back_a_ticket(self):
|
||||
code = self._send_registration_code("7700000")
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7700000", "code": code}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["next"], "register")
|
||||
self.assertTrue(response.data["registration_token"])
|
||||
self.assertEqual(response.data["mobile"], "+9607700000")
|
||||
self.assertNotIn("token", response.data)
|
||||
|
||||
def test_verify_rejects_a_wrong_code(self):
|
||||
self._send_registration_code("7700000")
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7700000", "code": "000000"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["code"], "invalid_code")
|
||||
self.assertFalse(RegistrationTicket.objects.exists())
|
||||
|
||||
def test_a_code_signs_in_an_account_created_in_the_meantime(self):
|
||||
code = self._send_registration_code("7700000")
|
||||
User.objects.create_user(mobile="7700000")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7700000", "code": code}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["next"], "dashboard")
|
||||
self.assertFalse(RegistrationTicket.objects.exists())
|
||||
|
||||
# --- the form -------------------------------------------------------
|
||||
def test_registration_creates_a_pending_account(self):
|
||||
token = self._verified_token("7700000")
|
||||
response = self.client.post(reverse("auth-register"), self._form(token))
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.data["status"], "pending")
|
||||
# No token: registering does not sign anyone in.
|
||||
self.assertNotIn("token", response.data)
|
||||
|
||||
user = User.objects.get(mobile="+9607700000")
|
||||
self.assertEqual(user.status, User.Status.PENDING)
|
||||
self.assertEqual(user.full_name, "Ahmed Ali")
|
||||
self.assertEqual(user.idnumber, "WP123456")
|
||||
self.assertEqual(user.date_of_birth, date(1995, 4, 17))
|
||||
self.assertEqual(user.island, self.island)
|
||||
self.assertTrue(user.mobile_verified)
|
||||
self.assertFalse(user.has_usable_password())
|
||||
self.assertIsNotNone(user.terms_accepted_at)
|
||||
self.assertIsNotNone(user.policy_accepted_at)
|
||||
|
||||
def test_the_number_comes_from_the_token_not_the_form(self):
|
||||
token = self._verified_token("7700000")
|
||||
payload = self._form(token) | {"mobile": "7711111"}
|
||||
self.client.post(reverse("auth-register"), payload)
|
||||
|
||||
self.assertTrue(User.objects.filter(mobile="+9607700000").exists())
|
||||
self.assertFalse(User.objects.filter(mobile="+9607711111").exists())
|
||||
|
||||
def test_a_token_works_only_once(self):
|
||||
token = self._verified_token("7700000")
|
||||
self.client.post(reverse("auth-register"), self._form(token))
|
||||
User.objects.all().delete()
|
||||
|
||||
response = self.client.post(reverse("auth-register"), self._form(token))
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("registration_token", response.data["errors"])
|
||||
|
||||
def test_an_expired_token_is_refused(self):
|
||||
token = self._verified_token("7700000")
|
||||
RegistrationTicket.objects.filter(key=token).update(
|
||||
expires_at=timezone.now() - timedelta(minutes=1)
|
||||
)
|
||||
response = self.client.post(reverse("auth-register"), self._form(token))
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("registration_token", response.data["errors"])
|
||||
|
||||
def test_registration_requires_a_token(self):
|
||||
payload = self._form("nope")
|
||||
response = self.client.post(reverse("auth-register"), payload)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("registration_token", response.data["errors"])
|
||||
|
||||
def test_both_agreements_are_required(self):
|
||||
for field in ["terms_accepted", "policy_accepted"]:
|
||||
with self.subTest(field=field):
|
||||
token = self._verified_token("7700000")
|
||||
payload = self._form(token) | {field: False}
|
||||
response = self.client.post(reverse("auth-register"), payload)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn(field, response.data["errors"])
|
||||
self.assertFalse(User.objects.exists())
|
||||
|
||||
def test_island_must_belong_to_the_selected_atoll(self):
|
||||
token = self._verified_token("7700000")
|
||||
payload = self._form(token) | {"island": self.other_island.pk}
|
||||
response = self.client.post(reverse("auth-register"), payload)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("island", response.data["errors"])
|
||||
|
||||
def test_required_fields(self):
|
||||
token = self._verified_token("7700000")
|
||||
response = self.client.post(
|
||||
reverse("auth-register"), {"registration_token": token}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
for field in [
|
||||
"full_name",
|
||||
"idnumber",
|
||||
"date_of_birth",
|
||||
"atoll",
|
||||
"island",
|
||||
]:
|
||||
self.assertIn(field, response.data["errors"])
|
||||
|
||||
def test_future_date_of_birth_is_refused(self):
|
||||
token = self._verified_token("7700000")
|
||||
payload = self._form(token) | {
|
||||
"date_of_birth": (date.today() + timedelta(days=1)).isoformat()
|
||||
}
|
||||
response = self.client.post(reverse("auth-register"), payload)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("date_of_birth", response.data["errors"])
|
||||
|
||||
def test_cannot_register_a_number_that_already_has_an_account(self):
|
||||
token = self._verified_token("7700000")
|
||||
User.objects.create_user(mobile="7700000")
|
||||
response = self.client.post(reverse("auth-register"), self._form(token))
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("mobile", response.data["errors"])
|
||||
|
||||
# --- after submitting ------------------------------------------------
|
||||
def test_a_pending_applicant_can_sign_in_and_see_their_status(self):
|
||||
token = self._verified_token("7700000")
|
||||
self.client.post(reverse("auth-register"), self._form(token))
|
||||
|
||||
start = self.client.post(reverse("auth-start"), {"mobile": "7700000"})
|
||||
self.assertEqual(start.data["next"], "otp")
|
||||
|
||||
otp = OtpCode.objects.active().filter(mobile="+9607700000").first()
|
||||
self.assertIsNotNone(otp)
|
||||
self.assertEqual(otp.purpose, OtpCode.Purpose.LOGIN)
|
||||
|
||||
_, code = OtpCode.issue(
|
||||
mobile="+9607700000",
|
||||
purpose=OtpCode.Purpose.LOGIN,
|
||||
user=User.objects.get(mobile="+9607700000"),
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": "7700000", "code": code}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["next"], "dashboard")
|
||||
self.assertEqual(response.data["user"]["status"], "pending")
|
||||
|
||||
def test_approval_flips_the_status_and_records_the_reviewer(self):
|
||||
admin = User.objects.create_superuser(mobile="7711111", password="x")
|
||||
token = self._verified_token("7700000")
|
||||
self.client.post(reverse("auth-register"), self._form(token))
|
||||
|
||||
user = User.objects.get(mobile="+9607700000")
|
||||
user.approve(reviewer=admin)
|
||||
|
||||
user.refresh_from_db()
|
||||
self.assertEqual(user.status, User.Status.APPROVED)
|
||||
self.assertTrue(user.is_approved)
|
||||
self.assertEqual(user.reviewed_by, admin)
|
||||
self.assertIsNotNone(user.reviewed_at)
|
||||
|
||||
def test_rejection_records_the_reason(self):
|
||||
token = self._verified_token("7700000")
|
||||
self.client.post(reverse("auth-register"), self._form(token))
|
||||
|
||||
user = User.objects.get(mobile="+9607700000")
|
||||
user.reject(reason="Document unreadable")
|
||||
|
||||
user.refresh_from_db()
|
||||
self.assertEqual(user.status, User.Status.REJECTED)
|
||||
self.assertEqual(user.rejection_reason, "Document unreadable")
|
||||
|
||||
# --- helpers ---------------------------------------------------------
|
||||
def _send_registration_code(self, mobile: str) -> str:
|
||||
otp, code = OtpCode.issue(
|
||||
mobile=f"+960{mobile}", purpose=OtpCode.Purpose.REGISTRATION
|
||||
)
|
||||
self.assertIsNotNone(otp)
|
||||
return code
|
||||
|
||||
def _verified_token(self, mobile: str) -> str:
|
||||
code = self._send_registration_code(mobile)
|
||||
response = self.client.post(
|
||||
reverse("auth-verify"), {"mobile": mobile, "code": code}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
return response.data["registration_token"]
|
||||
|
||||
def _form(self, token: str) -> dict:
|
||||
return {
|
||||
"registration_token": token,
|
||||
"full_name": "Ahmed Ali",
|
||||
"idnumber": "wp123456",
|
||||
"date_of_birth": "1995-04-17",
|
||||
"atoll": self.atoll.pk,
|
||||
"island": self.island.pk,
|
||||
"terms_accepted": True,
|
||||
"policy_accepted": True,
|
||||
}
|
||||
|
||||
|
||||
class LocationsApiTests(TestCase):
|
||||
def test_atolls_are_public_and_include_islands(self):
|
||||
response = APIClient().get(reverse("atoll-list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
names = {atoll["name"]: atoll for atoll in response.data}
|
||||
self.assertIn("Faafu", names)
|
||||
self.assertEqual(
|
||||
[island["name"] for island in names["Faafu"]["islands"]],
|
||||
["Dharanboodhoo"],
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.urls import path
|
||||
from knox import views as knox_views
|
||||
|
||||
from .views import (
|
||||
AuthStartView,
|
||||
MeView,
|
||||
OtpResendView,
|
||||
PasswordLoginView,
|
||||
RegisterView,
|
||||
VerifyCodeView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("start/", AuthStartView.as_view(), name="auth-start"),
|
||||
path("login/password/", PasswordLoginView.as_view(), name="auth-login-password"),
|
||||
path("verify/", VerifyCodeView.as_view(), name="auth-verify"),
|
||||
path("otp/resend/", OtpResendView.as_view(), name="auth-otp-resend"),
|
||||
path("register/", RegisterView.as_view(), name="auth-register"),
|
||||
path("me/", MeView.as_view(), name="auth-me"),
|
||||
path("logout/", knox_views.LogoutView.as_view(), name="auth-logout"),
|
||||
path("logout-all/", knox_views.LogoutAllView.as_view(), name="auth-logout-all"),
|
||||
]
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Authentication and registration.
|
||||
|
||||
1. POST /api/auth/start/ {mobile}
|
||||
-> {"next": "password"} the account signs in with a password
|
||||
-> {"next": "otp", ...} a code was sent by SMS
|
||||
|
||||
2. POST /api/auth/login/password/ {mobile, password}
|
||||
-> {"next": "dashboard", "token", "expiry", "user"}
|
||||
|
||||
POST /api/auth/verify/ {mobile, code}
|
||||
-> {"next": "dashboard", "token", "expiry", "user"}
|
||||
-> {"next": "register", "registration_token", "mobile", "expires_at"}
|
||||
|
||||
3. POST /api/auth/register/ {registration_token, ...form}
|
||||
-> 201 {"status": "pending", ...}
|
||||
|
||||
Whether a number has an account is only answered once its owner has confirmed
|
||||
a code, so `start` looks the same for every valid number.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
from django.utils import timezone
|
||||
from knox.views import LoginView as KnoxLoginView
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .models import OtpCode, RegistrationTicket, User
|
||||
from .serializers import (
|
||||
AuthStartSerializer,
|
||||
OtpVerifySerializer,
|
||||
PasswordLoginSerializer,
|
||||
RegistrationSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
from .sms import send_otp, send_registration_submitted
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CODE_LENGTH = 6
|
||||
|
||||
|
||||
def _issue_code(mobile: str, user: User | None) -> OtpCode:
|
||||
purpose = OtpCode.Purpose.LOGIN if user else OtpCode.Purpose.REGISTRATION
|
||||
otp, code = OtpCode.issue(mobile=mobile, purpose=purpose, user=user)
|
||||
send_otp(mobile, code, purpose)
|
||||
return otp
|
||||
|
||||
|
||||
def _code_payload(mobile: str, otp: OtpCode | None = None) -> dict:
|
||||
payload = {"next": "otp", "mobile": mobile, "code_length": CODE_LENGTH}
|
||||
if otp is not None:
|
||||
payload["expires_at"] = otp.expires_at
|
||||
payload["resend_available_at"] = otp.resend_available_at
|
||||
return payload
|
||||
|
||||
|
||||
class AuthStartView(APIView):
|
||||
"""Step 1: password box, or a code sent by SMS."""
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = [AllowAny]
|
||||
throttle_scope = "auth_start"
|
||||
|
||||
def post(self, request):
|
||||
serializer = AuthStartSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
mobile = serializer.validated_data["mobile"]
|
||||
|
||||
user = User.objects.filter(mobile=mobile).first()
|
||||
if user is not None and user.effective_auth_method == User.AuthMethod.PASSWORD:
|
||||
return Response({"next": "password", "mobile": mobile})
|
||||
|
||||
return Response(_code_payload(mobile, _issue_code(mobile, user)))
|
||||
|
||||
|
||||
class OtpResendView(APIView):
|
||||
"""Send a fresh code, honouring the cooldown."""
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = [AllowAny]
|
||||
throttle_scope = "otp_request"
|
||||
|
||||
def post(self, request):
|
||||
serializer = AuthStartSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
mobile = serializer.validated_data["mobile"]
|
||||
|
||||
user = User.objects.filter(mobile=mobile).first()
|
||||
if user is not None and user.effective_auth_method == User.AuthMethod.PASSWORD:
|
||||
return Response(_code_payload(mobile))
|
||||
|
||||
latest = OtpCode.objects.active().filter(mobile=mobile).first()
|
||||
if latest and latest.resend_available_at > timezone.now():
|
||||
return Response(
|
||||
{
|
||||
"detail": "A code was just sent. Try again shortly.",
|
||||
"code": "resend_cooldown",
|
||||
"resend_available_at": latest.resend_available_at,
|
||||
},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
|
||||
return Response(_code_payload(mobile, _issue_code(mobile, user)))
|
||||
|
||||
|
||||
class BaseLoginView(KnoxLoginView):
|
||||
"""Issues a knox token, with the account serialised alongside it."""
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def issue_token(self, request, user):
|
||||
request.user = user
|
||||
return super().post(request, format=None)
|
||||
|
||||
def get_post_response_data(self, request, token, instance):
|
||||
data = super().get_post_response_data(request, token, instance)
|
||||
data["next"] = "dashboard"
|
||||
data["user"] = UserSerializer(request.user).data
|
||||
return data
|
||||
|
||||
|
||||
class PasswordLoginView(BaseLoginView):
|
||||
"""Step 2, password."""
|
||||
|
||||
throttle_scope = "auth_login"
|
||||
|
||||
def post(self, request, format=None):
|
||||
serializer = PasswordLoginSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
user = authenticate(
|
||||
request,
|
||||
username=serializer.validated_data["mobile"],
|
||||
password=serializer.validated_data["password"],
|
||||
)
|
||||
if user is None:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Incorrect mobile number or password.",
|
||||
"code": "invalid_credentials",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return self.issue_token(request, user)
|
||||
|
||||
|
||||
class VerifyCodeView(BaseLoginView):
|
||||
"""Step 2, SMS code.
|
||||
|
||||
A confirmed code either signs the account in or, when the number has no
|
||||
account, hands back the ticket the registration form needs.
|
||||
"""
|
||||
|
||||
throttle_scope = "auth_login"
|
||||
|
||||
def post(self, request, format=None):
|
||||
serializer = OtpVerifySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
mobile = serializer.validated_data["mobile"]
|
||||
|
||||
otp = (
|
||||
OtpCode.objects.active()
|
||||
.filter(mobile=mobile)
|
||||
.select_related("user")
|
||||
.first()
|
||||
)
|
||||
if otp is None:
|
||||
return Response(
|
||||
{
|
||||
"detail": "That code has expired. Request a new one.",
|
||||
"code": "code_expired",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if otp.is_exhausted:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Too many incorrect attempts. Request a new code.",
|
||||
"code": "code_exhausted",
|
||||
},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
|
||||
if not otp.verify(serializer.validated_data["code"]):
|
||||
return Response(
|
||||
{
|
||||
"detail": "That code is not correct.",
|
||||
"code": "invalid_code",
|
||||
"attempts_left": max(0, settings.OTP_MAX_ATTEMPTS - otp.attempts),
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
user = otp.user or User.objects.filter(mobile=mobile).first()
|
||||
if user is None:
|
||||
ticket = RegistrationTicket.issue(mobile)
|
||||
return Response(
|
||||
{
|
||||
"next": "register",
|
||||
"registration_token": ticket.key,
|
||||
"mobile": mobile,
|
||||
"expires_at": ticket.expires_at,
|
||||
}
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
return Response(
|
||||
{"detail": "This account is disabled.", "code": "account_disabled"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if not user.mobile_verified:
|
||||
user.mobile_verified = True
|
||||
user.save(update_fields=["mobile_verified", "updated_at"])
|
||||
|
||||
return self.issue_token(request, user)
|
||||
|
||||
|
||||
class RegisterView(APIView):
|
||||
"""Submit the registration form. Creates a pending account - no token."""
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = [AllowAny]
|
||||
throttle_scope = "register"
|
||||
|
||||
def post(self, request):
|
||||
serializer = RegistrationSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = serializer.save()
|
||||
|
||||
send_registration_submitted(user.mobile)
|
||||
logger.info("Registration submitted for %s (user %s)", user.mobile, user.pk)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"status": user.status,
|
||||
"mobile": user.mobile,
|
||||
"full_name": user.full_name,
|
||||
"detail": (
|
||||
"Your registration is pending approval. We'll text you "
|
||||
"once it has been reviewed."
|
||||
),
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class MeView(APIView):
|
||||
"""The signed-in account, for the SPA to hydrate its session."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
return Response(UserSerializer(request.user).data)
|
||||
Reference in New Issue
Block a user