├── tests ├── __init__.py ├── test_app │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0004_remove_user_username.py │ │ ├── 0013_remove_user_created_by.py │ │ ├── 0008_rename_invited_by_user_created_by.py │ │ ├── 0011_rename_invited_by_user_created_by.py │ │ ├── 0009_rename_created_by_user_invited_by.py │ │ ├── 0010_user_is_2fa_enabled.py │ │ ├── 0005_alter_user_managers.py │ │ ├── 0012_alter_user_managers_remove_user_is_2fa_enabled.py │ │ ├── 0002_alter_user_email_alter_user_phone_number.py │ │ ├── 0007_user_invited_by.py │ │ ├── 0003_alter_user_email_alter_user_phone_number.py │ │ ├── 0006_user_username.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── admin.py │ ├── models.py │ └── tests.py ├── urls.py └── settings.py ├── df_auth ├── __init__.py ├── drf │ ├── __init__.py │ ├── urls.py │ ├── viewsets.py │ └── serializers.py ├── migrations │ ├── __init__.py │ ├── 0003_alter_userregistration_is_registering.py │ ├── 0001_initial.py │ └── 0002_alter_user2fa_user_userregistration.py ├── templatetags │ ├── __init__.py │ └── auth_magic_link.py ├── apps.py ├── strategy.py ├── permissions.py ├── models.py ├── utils.py ├── settings.py ├── managers.py ├── admin.py ├── remote_config.py ├── exceptions.py ├── defaults.py └── backends.py ├── docs ├── TODO.md ├── index.rst └── requirements.md ├── setup.py ├── .github ├── dependabot.yml └── workflows │ ├── test.yml │ └── release.yml ├── manage.py ├── .pre-commit-config.yaml ├── LICENSE ├── .gitignore ├── pyproject.toml ├── README.md └── CLAUDE.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /df_auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /df_auth/drf/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /df_auth/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /df_auth/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_app/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/TODO.md: -------------------------------------------------------------------------------- 1 | - test suite 2 | - configuration app 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tests/test_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestAppConfig(AppConfig): 5 | name = "tests.test_app" 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Update Github actions in workflows 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | urlpatterns = [ 5 | path("admin/", admin.site.urls), 6 | path("api/", include("df_api_drf.urls")), 7 | ] 8 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======================================= 2 | Djangoflow REST Authentication with JWT 3 | ======================================= 4 | 5 | Opinionated Django REST auth endpoints for JWT authentication and social accounts. 6 | -------------------------------------------------------------------------------- /df_auth/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class DFAuthConfig(AppConfig): 6 | name = "df_auth" 7 | verbose_name = _("DjangoFlow Auth") 8 | 9 | class DFMeta: 10 | api_path = "auth/" 11 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """used for testing only""" 4 | 5 | import os 6 | import sys 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 10 | 11 | from django.core.management import execute_from_command_line 12 | 13 | execute_from_command_line(sys.argv) 14 | -------------------------------------------------------------------------------- /tests/test_app/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from tests.test_app.models import User 4 | 5 | 6 | @admin.register(User) 7 | class UserAdmin(admin.ModelAdmin): 8 | search_fields = ( 9 | "username", 10 | "id", 11 | ) 12 | list_display = ( 13 | "username", 14 | "email", 15 | "phone_number", 16 | ) 17 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0004_remove_user_username.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.6 on 2023-02-17 04:06 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("test_app", "0003_alter_user_email_alter_user_phone_number"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="user", 14 | name="username", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0013_remove_user_created_by.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-10-04 08:28 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("test_app", "0012_alter_user_managers_remove_user_is_2fa_enabled"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="user", 14 | name="created_by", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0008_rename_invited_by_user_created_by.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-08-24 10:27 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("test_app", "0007_user_invited_by"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameField( 13 | model_name="user", 14 | old_name="invited_by", 15 | new_name="created_by", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0011_rename_invited_by_user_created_by.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-09-11 05:45 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("test_app", "0010_user_is_2fa_enabled"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameField( 13 | model_name="user", 14 | old_name="invited_by", 15 | new_name="created_by", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /df_auth/strategy.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from rest_framework.request import Request 4 | from social_django.storage import BaseDjangoStorage 5 | from social_django.strategy import DjangoStrategy 6 | 7 | 8 | class DRFStrategy(DjangoStrategy): 9 | def __init__(self, storage: BaseDjangoStorage, request: Request) -> None: 10 | self.request = request 11 | super().__init__(storage, request) 12 | 13 | def request_data(self, merge: bool = True) -> Any: 14 | return self.request.data 15 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0009_rename_created_by_user_invited_by.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-08-24 10:30 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("test_app", "0008_rename_invited_by_user_created_by"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameField( 13 | model_name="user", 14 | old_name="created_by", 15 | new_name="invited_by", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0010_user_is_2fa_enabled.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-09-05 05:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("test_app", "0009_rename_created_by_user_invited_by"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="user", 14 | name="is_2fa_enabled", 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /tests/test_app/models.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from django.contrib.auth.models import AbstractUser 4 | from django.db import models 5 | 6 | from df_auth.managers import UserManager 7 | 8 | 9 | class User(AbstractUser): 10 | objects = UserManager() # type: ignore 11 | USERNAME_FIELD = "username" 12 | REQUIRED_FIELDS: List[str] = [] 13 | email = models.EmailField(max_length=255, unique=True, null=True, blank=True) # type: ignore 14 | phone_number = models.CharField(max_length=32, unique=True, null=True, blank=True) 15 | -------------------------------------------------------------------------------- /df_auth/migrations/0003_alter_userregistration_is_registering.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-10-05 12:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("df_auth", "0002_alter_user2fa_user_userregistration"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="userregistration", 14 | name="is_registering", 15 | field=models.BooleanField(default=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0005_alter_user_managers.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.6 on 2023-07-14 09:56 2 | 3 | from django.db import migrations 4 | import tests.test_app.models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("test_app", "0004_remove_user_username"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelManagers( 14 | name="user", 15 | managers=[ 16 | ("objects", tests.test_app.models.UserManager()), 17 | ], 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0012_alter_user_managers_remove_user_is_2fa_enabled.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-09-19 03:23 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("test_app", "0011_rename_invited_by_user_created_by"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelManagers( 13 | name="user", 14 | managers=[], 15 | ), 16 | migrations.RemoveField( 17 | model_name="user", 18 | name="is_2fa_enabled", 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /df_auth/permissions.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from rest_framework.permissions import IsAuthenticated 4 | 5 | from df_auth.settings import api_settings 6 | 7 | 8 | class IsUnauthenticated(IsAuthenticated): 9 | def has_permission(self, *args: Any, **kwargs: Any) -> bool: 10 | return not super().has_permission(*args, **kwargs) 11 | 12 | 13 | class IsUserCreateAllowed(IsAuthenticated): 14 | def has_permission(self, *args: Any, **kwargs: Any) -> bool: 15 | if super().has_permission(*args, **kwargs): 16 | return api_settings.INVITE_ALLOWED 17 | else: 18 | return api_settings.SIGNUP_ALLOWED 19 | -------------------------------------------------------------------------------- /df_auth/templatetags/auth_magic_link.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from typing import Dict 3 | 4 | from django import template 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.simple_tag(takes_context=True) 10 | def auth_magic_link(context: Dict) -> str: 11 | """ 12 | :return: context("base_url") + base64.urlsafe_b64encode(context["username")/context("token")) 13 | """ 14 | token = base64.urlsafe_b64encode( 15 | bytes(f"{context['username']}/{context['token']}", "utf-8") 16 | ).decode("utf-8") 17 | url = f"{context['base_url']}{token}" 18 | if context.get("redirect_path"): 19 | url += f"?redirect_path={context['redirect_path']}" 20 | return url 21 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0002_alter_user_email_alter_user_phone_number.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.6 on 2023-02-09 06:26 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("test_app", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="user", 14 | name="email", 15 | field=models.EmailField(max_length=255, null=True, unique=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="user", 19 | name="phone_number", 20 | field=models.CharField(max_length=32, null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0007_user_invited_by.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-08-24 10:12 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("test_app", "0006_user_username"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="user", 16 | name="invited_by", 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.SET_NULL, 21 | to=settings.AUTH_USER_MODEL, 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: ['*'] 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - uses: actions/setup-python@v4 13 | with: 14 | python-version: "3.9" 15 | 16 | - name: Install dependencies 17 | run: | 18 | python -m venv venv 19 | . venv/bin/activate 20 | pip install --upgrade pip 21 | pip install -e .[test] 22 | - name: Run linters 23 | run: | 24 | . venv/bin/activate 25 | black . --check 26 | ruff . --no-fix 27 | mypy . 28 | - name: Run tests 29 | run: | 30 | . venv/bin/activate 31 | pytest 32 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0003_alter_user_email_alter_user_phone_number.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.6 on 2023-02-17 03:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("test_app", "0002_alter_user_email_alter_user_phone_number"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="user", 14 | name="email", 15 | field=models.EmailField(blank=True, max_length=255, null=True, unique=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="user", 19 | name="phone_number", 20 | field=models.CharField(blank=True, max_length=32, null=True, unique=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /df_auth/drf/urls.py: -------------------------------------------------------------------------------- 1 | """Djangoflow URL Configuration 2 | 3 | Add these to your root URLconf: 4 | urlpatterns = [ 5 | ... 6 | path('auth/', include('df_auth.urls')) 7 | ] 8 | 9 | """ 10 | from rest_framework.routers import DefaultRouter 11 | 12 | from .viewsets import ( 13 | OtpDeviceViewSet, 14 | OTPViewSet, 15 | SocialTokenViewSet, 16 | TokenViewSet, 17 | UserViewSet, 18 | ) 19 | 20 | router = DefaultRouter() 21 | router.register("token", TokenViewSet, basename="token") 22 | router.register("users", UserViewSet, basename="users") 23 | router.register("otp", OTPViewSet, basename="otp") 24 | router.register("otp-devices", OtpDeviceViewSet, basename="otp-devices") 25 | 26 | router.register("social", SocialTokenViewSet, basename="social") 27 | 28 | urlpatterns = router.urls 29 | -------------------------------------------------------------------------------- /df_auth/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | 4 | 5 | class UserOneToOneMixin(models.Model): 6 | user = models.OneToOneField( 7 | settings.AUTH_USER_MODEL, 8 | on_delete=models.CASCADE, 9 | ) 10 | 11 | class Meta: 12 | abstract = True 13 | 14 | 15 | class User2FA(UserOneToOneMixin): 16 | is_required = models.BooleanField(default=False) 17 | 18 | class Meta: 19 | verbose_name = "User 2FA" 20 | verbose_name_plural = "User 2FA" 21 | 22 | 23 | class UserRegistration(UserOneToOneMixin): 24 | is_registering = models.BooleanField(default=True) 25 | invited_by = models.ForeignKey( 26 | settings.AUTH_USER_MODEL, 27 | on_delete=models.CASCADE, 28 | related_name="invitees", 29 | null=True, 30 | blank=True, 31 | ) 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: 'docs|node_modules|.git|.tox' 2 | default_stages: [commit] 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v4.3.0 7 | hooks: 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | - id: check-yaml 11 | 12 | - repo: https://github.com/psf/black 13 | rev: 23.7.0 14 | hooks: 15 | - id: black 16 | 17 | - repo: https://github.com/charliermarsh/ruff-pre-commit 18 | rev: v0.0.284 19 | hooks: 20 | - id: ruff 21 | 22 | 23 | - repo: https://github.com/pre-commit/mirrors-mypy 24 | rev: v1.4.1 25 | hooks: 26 | - id: mypy 27 | language: system 28 | 29 | 30 | # sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date 31 | ci: 32 | autoupdate_schedule: weekly 33 | skip: [] 34 | submodules: false 35 | -------------------------------------------------------------------------------- /df_auth/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Tuple, Type 2 | 3 | from django.contrib.auth.base_user import AbstractBaseUser 4 | from django.utils.module_loading import import_string 5 | from django_otp.models import Device 6 | 7 | from .settings import api_settings 8 | 9 | 10 | def get_otp_device_models() -> Dict[str, Type[Device]]: 11 | return { 12 | type_: import_string(model_path) 13 | for type_, model_path in api_settings.OTP_DEVICE_MODELS.items() 14 | } 15 | 16 | 17 | def get_otp_device_choices() -> List[Tuple[str, str]]: 18 | return [(type_, type_) for type_ in api_settings.OTP_DEVICE_MODELS] 19 | 20 | 21 | def get_otp_devices(user: AbstractBaseUser) -> List[Device]: 22 | devices = [] 23 | for DeviceModel in get_otp_device_models().values(): 24 | devices.extend(DeviceModel.objects.filter(user=user)) 25 | return devices 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | env: 4 | DOCKER_BUILDKIT: 1 5 | COMPOSE_DOCKER_CLI_BUILD: 1 6 | 7 | on: 8 | pull_request: 9 | tags: [ "*" ] 10 | paths-ignore: [ "docs/**" ] 11 | 12 | push: 13 | tags: [ "*" ] 14 | paths-ignore: [ "docs/**" ] 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout Code Repository 21 | uses: actions/checkout@v3 22 | - name: Set up Python 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: '3.x' 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install setuptools wheel twine 30 | - name: Build and publish 31 | run: | 32 | python setup.py sdist bdist_wheel 33 | - name: Publish a Python distribution to PyPI 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | with: 36 | password: ${{ secrets.PYPI_API_TOKEN }} 37 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0006_user_username.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-08-24 07:27 2 | 3 | import django.contrib.auth.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("test_app", "0005_alter_user_managers"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="user", 15 | name="username", 16 | field=models.CharField( 17 | default="1", 18 | error_messages={"unique": "A user with that username already exists."}, 19 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 20 | max_length=150, 21 | unique=True, 22 | validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], 23 | verbose_name="username", 24 | ), 25 | preserve_default=False, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Apexive.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/requirements.md: -------------------------------------------------------------------------------- 1 | - as developer i want to be able to 2 | - configure 3 | - 2fa required for different user/application (i.e. rest app, django admin etc) 4 | - configure which fields can be used for a user to identify themselves (email/phone/username) 5 | e.g. `DF_AUTH_USER_IDENTITY_FIELDS` defaults to `email` or `username` 6 | - a social app integration for 7 | - facebook 8 | - google 9 | - apple 10 | - as an unauthenticated user i want 11 | - obtain jwt token 12 | - with username|phone/email/password 13 | - with username|phone/email/password/otp 14 | - with username|phone/email/otp 15 | - with social login flow 16 | - facebook 17 | - google 18 | - apple 19 | - request otp 20 | - with username 21 | - username/password (2fa) 22 | 23 | - as an authenticated (logged-in) user i want 24 | - connect/disconnect my social account 25 | - facebook 26 | - google 27 | - apple 28 | - connect my phone number with otp 29 | - ~~connect~~ replace my email with another one by using otp 30 | - reset my password via single-use link (otp) 31 | - email 32 | - phone 33 | 34 | - as new (an unregistered) user 35 | - register myself with 36 | - username/phone/email (`DF_AUTH_USER_IDENTITY_FIELDS`) combined with password and/or otp 37 | - social app 38 | - facebook 39 | - google 40 | - apple 41 | -------------------------------------------------------------------------------- /df_auth/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-09-19 04:28 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="User2FA", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("is_required", models.BooleanField(default=False)), 29 | ( 30 | "user", 31 | models.OneToOneField( 32 | on_delete=django.db.models.deletion.CASCADE, 33 | related_name="user_2fa", 34 | to=settings.AUTH_USER_MODEL, 35 | ), 36 | ), 37 | ], 38 | options={ 39 | "verbose_name": "User 2FA", 40 | "verbose_name_plural": "User 2FA", 41 | }, 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /df_auth/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from rest_framework.settings import APISettings 3 | 4 | DEFAULTS = { 5 | "USER_OPTIONAL_FIELDS": { 6 | "first_name": "rest_framework.serializers.CharField", 7 | "last_name": "rest_framework.serializers.CharField", 8 | "password": "rest_framework.serializers.CharField", 9 | }, 10 | "USER_SOCIAL_AUTH_FIELDS": { 11 | "first_name": "rest_framework.serializers.CharField", 12 | "last_name": "rest_framework.serializers.CharField", 13 | }, 14 | "USER_IDENTITY_FIELDS": { 15 | "username": "rest_framework.serializers.CharField", 16 | "email": "rest_framework.serializers.CharField", 17 | "phone_number": "phonenumber_field.serializerfields.PhoneNumberField", 18 | }, 19 | "REQUIRED_AUTH_FIELDS": {}, 20 | "OPTIONAL_AUTH_FIELDS": { 21 | "otp": "rest_framework.serializers.CharField", 22 | "password": "rest_framework.serializers.CharField", 23 | }, 24 | "TEST_USER_EMAIL": None, 25 | "OTP_IDENTITY_UPDATE_FIELD": True, 26 | "DEFER_IDENTITY_UPDATE": False, 27 | "AUTO_SEND_IDENTITY_VERIFICATION_OTP": False, 28 | "OTP_DEVICE_MODELS": { 29 | "email": "django_otp.plugins.otp_email.models.EmailDevice", 30 | "totp": "django_otp.plugins.otp_totp.models.TOTPDevice", 31 | "sms": "otp_twilio.models.TwilioSMSDevice", 32 | }, 33 | "OTP_AUTO_CREATE_ACCOUNT": True, 34 | "OTP_SEND_UNAUTHORIZED_USER": True, 35 | "SIGNUP_ALLOWED": True, 36 | "INVITE_ALLOWED": True, 37 | "SITE_LOGIN_URL": "/login/", 38 | } 39 | 40 | api_settings = APISettings(getattr(settings, "DF_AUTH", None), DEFAULTS) 41 | -------------------------------------------------------------------------------- /df_auth/managers.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.base_user import BaseUserManager 5 | 6 | 7 | class UserManager(BaseUserManager): 8 | def create_superuser(self, **extra_fields: Any) -> Any: 9 | """Create and save a SuperUser with the given email and password.""" 10 | extra_fields.setdefault("is_staff", True) 11 | extra_fields.setdefault("is_superuser", True) 12 | 13 | if extra_fields.get("is_staff") is not True: 14 | raise ValueError("Superuser must have is_staff=True.") 15 | if extra_fields.get("is_superuser") is not True: 16 | raise ValueError("Superuser must have is_superuser=True.") 17 | 18 | return self._create_user(**extra_fields) 19 | 20 | def _create_user(self, **extra_fields: Any) -> Any: 21 | """Create and save a User with the given email and password.""" 22 | User = get_user_model() 23 | password = extra_fields.pop("password", None) 24 | 25 | if not extra_fields.get(User.USERNAME_FIELD): 26 | raise ValueError(f"The given {User.USERNAME_FIELD} must be set") 27 | if User.EMAIL_FIELD in extra_fields: 28 | extra_fields[User.EMAIL_FIELD] = self.normalize_email( 29 | extra_fields[User.EMAIL_FIELD] 30 | ) 31 | 32 | user = self.model(**extra_fields) 33 | user.set_password(password) 34 | user.save(using=self._db) 35 | return user 36 | 37 | def create_user(self, **extra_fields: Any) -> Any: 38 | """Create and save a regular User with the given email and password.""" 39 | extra_fields.setdefault("is_staff", False) 40 | extra_fields.setdefault("is_superuser", False) 41 | return self._create_user(**extra_fields) 42 | -------------------------------------------------------------------------------- /df_auth/migrations/0002_alter_user2fa_user_userregistration.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2023-10-04 11:18 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("df_auth", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="user2fa", 17 | name="user", 18 | field=models.OneToOneField( 19 | on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL 20 | ), 21 | ), 22 | migrations.CreateModel( 23 | name="UserRegistration", 24 | fields=[ 25 | ( 26 | "id", 27 | models.AutoField( 28 | auto_created=True, 29 | primary_key=True, 30 | serialize=False, 31 | verbose_name="ID", 32 | ), 33 | ), 34 | ("is_registering", models.BooleanField(default=False)), 35 | ( 36 | "invited_by", 37 | models.ForeignKey( 38 | blank=True, 39 | null=True, 40 | on_delete=django.db.models.deletion.CASCADE, 41 | related_name="invitees", 42 | to=settings.AUTH_USER_MODEL, 43 | ), 44 | ), 45 | ( 46 | "user", 47 | models.OneToOneField( 48 | on_delete=django.db.models.deletion.CASCADE, 49 | to=settings.AUTH_USER_MODEL, 50 | ), 51 | ), 52 | ], 53 | options={ 54 | "abstract": False, 55 | }, 56 | ), 57 | ] 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | 132 | .idea/ 133 | -------------------------------------------------------------------------------- /df_auth/admin.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | 3 | from django.contrib import admin, messages 4 | from django.db.models import QuerySet 5 | from django.http import HttpRequest 6 | from django_otp.plugins.otp_email.models import EmailDevice 7 | from otp_twilio.models import TwilioSMSDevice 8 | 9 | from df_auth.models import User2FA, UserRegistration 10 | from df_auth.settings import api_settings 11 | 12 | 13 | @admin.register(TwilioSMSDevice) 14 | class TwilioSMSDeviceAdmin(admin.ModelAdmin): 15 | list_display = ( 16 | "id", 17 | "name", 18 | "number", 19 | "user", 20 | ) 21 | 22 | search_fields = ( 23 | "name", 24 | "number", 25 | ) 26 | autocomplete_fields = ("user",) 27 | 28 | @admin.action(description="Send challenge") 29 | def send_challenge( 30 | self, request: HttpRequest, queryset: QuerySet[TwilioSMSDevice] 31 | ) -> None: 32 | for device in queryset: 33 | device.generate_challenge() 34 | messages.success(request, f"{device.number}: {device.token}") 35 | 36 | actions = [send_challenge] 37 | 38 | 39 | @admin.register(EmailDevice) 40 | class EmailDeviceAdmin(admin.ModelAdmin): 41 | list_display = ( 42 | "id", 43 | "name", 44 | "email", 45 | "user", 46 | ) 47 | 48 | search_fields = ( 49 | "name", 50 | "email", 51 | ) 52 | autocomplete_fields = ("user",) 53 | 54 | @admin.action(description="Send challenge") 55 | def send_challenge( 56 | self, request: HttpRequest, queryset: QuerySet[EmailDevice] 57 | ) -> None: 58 | for device in queryset: 59 | device.generate_challenge() 60 | messages.success(request, f"{device.email}: {device.token}") 61 | 62 | actions = [send_challenge] 63 | 64 | 65 | class BaseUserAdmin(admin.ModelAdmin): 66 | def get_search_fields(self, request: HttpRequest) -> Sequence[str]: 67 | return ["user__id"] + [ 68 | f"user__{field}" for field in api_settings.USER_IDENTITY_FIELDS 69 | ] 70 | 71 | 72 | @admin.register(User2FA) 73 | class User2FAAdmin(BaseUserAdmin): 74 | list_display = ("user", "is_required") 75 | autocomplete_fields = ("user",) 76 | 77 | def enable(self, request: HttpRequest, queryset: QuerySet[User2FA]) -> None: 78 | queryset.update(is_required=True) 79 | messages.success(request, f"Enabled {queryset.count()} users") 80 | 81 | def disable(self, request: HttpRequest, queryset: QuerySet[User2FA]) -> None: 82 | queryset.update(is_required=False) 83 | messages.success(request, f"Disabled {queryset.count()} users") 84 | 85 | actions = [enable, disable] 86 | 87 | 88 | @admin.register(UserRegistration) 89 | class UserRegistrationAdmin(BaseUserAdmin): 90 | list_display = ("user", "is_registering", "invited_by") 91 | autocomplete_fields = ("user", "invited_by") 92 | -------------------------------------------------------------------------------- /df_auth/remote_config.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from df_remote_config.handlers import DefaultHandler 4 | from django.conf import settings 5 | from django.contrib.auth.backends import ModelBackend 6 | from django.utils.module_loading import import_string 7 | 8 | from df_auth.backends import BaseOTPBackend 9 | 10 | AUTHENTICATION_BACKENDS = [ 11 | import_string(backend) for backend in settings.AUTHENTICATION_BACKENDS 12 | ] 13 | 14 | auth_schema = { 15 | "type": "object", 16 | "properties": { 17 | "providers": { 18 | "type": "object", 19 | "additionalProperties": { 20 | "type": "object", 21 | "properties": { 22 | "button_text": { 23 | "type": "string", 24 | }, 25 | "redirect_uri": { 26 | "type": "object", 27 | "properties": { 28 | "web": {"type": "string"}, 29 | "mobile": {"type": "string"}, 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | "otp": { 36 | "type": "object", 37 | "properties": { 38 | "enabled": { 39 | "type": "boolean", 40 | }, 41 | }, 42 | }, 43 | "email_password": { 44 | "type": "object", 45 | "properties": { 46 | "enabled": { 47 | "type": "boolean", 48 | }, 49 | }, 50 | }, 51 | }, 52 | "required": ["providers", "otp", "email_password"], 53 | } 54 | 55 | 56 | class AuthHandler(DefaultHandler): 57 | def get_part_data(self, part: Optional[Any]) -> dict: 58 | data = super().get_part_data(part) 59 | 60 | enabled_providers = [ 61 | backend.name 62 | for backend in AUTHENTICATION_BACKENDS 63 | if hasattr(backend, "name") 64 | ] 65 | 66 | for provider in data["providers"]: 67 | if "enabled" not in data["providers"][provider]: 68 | data["providers"][provider]["enabled"] = provider in enabled_providers 69 | data["providers"][provider]["client_id"] = getattr( 70 | settings, f"SOCIAL_AUTH_{provider.upper()}_KEY", None 71 | ) 72 | 73 | for provider in AUTHENTICATION_BACKENDS: 74 | if ( 75 | issubclass(provider, ModelBackend) 76 | and "enabled" not in data["email_password"] 77 | ): 78 | data["email_password"].update( 79 | { 80 | "enabled": True, 81 | } 82 | ) 83 | if issubclass(provider, BaseOTPBackend) and "enabled" not in data["otp"]: 84 | data["otp"].update( 85 | { 86 | "enabled": True, 87 | } 88 | ) 89 | 90 | return data 91 | -------------------------------------------------------------------------------- /df_auth/exceptions.py: -------------------------------------------------------------------------------- 1 | from df_api_drf.exceptions import ExtraDataAPIException 2 | from django.utils.translation import gettext_lazy as _ 3 | from rest_framework import status 4 | from rest_framework.exceptions import ( 5 | AuthenticationFailed, 6 | PermissionDenied, 7 | ValidationError, 8 | ) 9 | 10 | 11 | class DfAuthValidationError(ValidationError): 12 | """ 13 | This is a base exception for custom validation errors 14 | """ 15 | 16 | status_code = status.HTTP_400_BAD_REQUEST 17 | default_detail = _("Authentication error") 18 | 19 | 20 | class WrongOTPError(DfAuthValidationError): 21 | """ 22 | This exception is used when token for otp is not verified 23 | """ 24 | 25 | default_detail = _("Wrong or expired one-time password") 26 | default_code = "wrong_otp" 27 | 28 | 29 | class UserAlreadyExistError(DfAuthValidationError): 30 | """ 31 | This exception is used when user already exists 32 | """ 33 | 34 | default_detail = _("User with this identity already exists, try logging in") 35 | default_code = "user_already_exists" 36 | 37 | 38 | class UserDoesNotExistError(AuthenticationFailed): 39 | """ 40 | This exception is used when user already exists 41 | """ 42 | 43 | default_detail = _("User with this identity does not exist, try signup instead") 44 | default_code = "user_does_not_exist" 45 | 46 | 47 | class UserInactiveError(DfAuthValidationError): 48 | """ 49 | This exception is used when user already exists 50 | """ 51 | 52 | default_detail = _("Your account was deactivated. Please contact support") 53 | default_code = "user_inactive" 54 | 55 | 56 | class DeviceTakenError(DfAuthValidationError): 57 | """ 58 | This exception is used when device is already registered 59 | """ 60 | 61 | default_detail = _("This device is already taken, unlink it first") 62 | default_code = "device_already_taken" 63 | 64 | 65 | class InvalidPhoneNumberError(DfAuthValidationError): 66 | """ 67 | This exception is used when phone number is not valid 68 | """ 69 | 70 | default_detail = _("Invalid phone number") 71 | default_code = "invalid_phone_number" 72 | 73 | 74 | class DeviceDoesNotExistError(DfAuthValidationError): 75 | """ 76 | This exception is used when device is not registered 77 | """ 78 | 79 | default_detail = _("Device does not exist") 80 | default_code = "device_does_not_exists" 81 | 82 | 83 | class LastDeviceError(DfAuthValidationError): 84 | """ 85 | This exception is used when there is no device registered with user 86 | """ 87 | 88 | default_detail = _("Cannot remove the last device") 89 | default_code = "last_device_error" 90 | 91 | 92 | class Authentication2FARequiredError(ExtraDataAPIException): 93 | default_detail = "2FA is required for this user." 94 | default_code = "2fa_required" 95 | status_code = status.HTTP_401_UNAUTHORIZED 96 | 97 | 98 | class SignupNotAllowedError(PermissionDenied): 99 | default_detail = "Signup is disabled." 100 | default_code = "signup_not_allowed" 101 | -------------------------------------------------------------------------------- /df_auth/defaults.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | DF_AUTH_APPS_BASE = [ 4 | "df_auth", 5 | "rest_framework_simplejwt", 6 | ] 7 | 8 | DF_AUTH_APPS_OTP = [ 9 | "django_otp", 10 | "django_otp.plugins.otp_email", 11 | "django_otp.plugins.otp_totp", 12 | "django_otp.plugins.otp_static", 13 | "otp_twilio", 14 | ] 15 | 16 | DF_AUTH_APPS_SOCIAL = [ 17 | "social_django", 18 | ] 19 | 20 | # Most common use case 21 | DF_AUTH_INSTALLED_APPS = [ 22 | *DF_AUTH_APPS_BASE, 23 | *DF_AUTH_APPS_OTP, 24 | *DF_AUTH_APPS_SOCIAL, 25 | ] 26 | 27 | SIMPLE_JWT = { 28 | "ACCESS_TOKEN_LIFETIME": timedelta(days=14), 29 | "AUTH_HEADER_TYPES": ("Bearer",), 30 | "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.SlidingToken",), 31 | "BLACKLIST_AFTER_ROTATION": False, 32 | "JTI_CLAIM": "jti", 33 | "REFRESH_TOKEN_LIFETIME": timedelta(days=14), 34 | "ROTATE_REFRESH_TOKENS": True, 35 | "SLIDING_TOKEN_LIFETIME": timedelta(days=14), 36 | "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", 37 | "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=14), 38 | "TOKEN_TYPE_CLAIM": "sliding", 39 | "USER_ID_CLAIM": "user_id", 40 | "USER_ID_FIELD": "id", 41 | "TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer", 42 | "TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer", 43 | } 44 | 45 | SOCIAL_AUTH_PIPELINE = [ 46 | # Get the information we can about the user and return it in a simple 47 | # format to create the user instance later. On some cases the details are 48 | # already part of the auth response from the provider, but sometimes this 49 | # could hit a provider API. 50 | "social_core.pipeline.social_auth.social_details", 51 | # Get the social uid from whichever service we're authing thru. The uid is 52 | # the unique identifier of the given user in the provider. 53 | "social_core.pipeline.social_auth.social_uid", 54 | # Verifies that the current auth process is valid within the current 55 | # project, this is where emails and domains whitelists are applied (if 56 | # defined). 57 | # 'social_core.pipeline.social_auth.auth_allowed', 58 | # Checks if the current social-account is already associated in the site. 59 | "social_core.pipeline.social_auth.social_user", 60 | # Make up a username for this person, appends a random string at the end if 61 | # there's any collision. 62 | "social_core.pipeline.user.get_username", 63 | # Send a validation email to the user to verify its email address. 64 | # 'social_core.pipeline.mail.mail_validation', 65 | # Associates the current social details with another user account with 66 | # a similar email address. 67 | "social_core.pipeline.social_auth.associate_by_email", 68 | # Create a user account if we haven't found one yet. 69 | "social_core.pipeline.user.create_user", 70 | # Create the record that associated the social account with this user. 71 | "social_core.pipeline.social_auth.associate_user", 72 | # Populate the extra_data field in the social record with the values 73 | # specified by settings (and the default ones like access_token, etc). 74 | "social_core.pipeline.social_auth.load_extra_data", 75 | # Update the user record with any changed info from the auth service. 76 | "social_core.pipeline.user.user_details", 77 | ] 78 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-df-auth" 3 | version = "1.0.11" 4 | description = "Opinionated Django REST auth endpoints for JWT authentication and social accounts." 5 | readme = "README.md" 6 | authors = [{name = "Apexive OSS", email = "open-source@apexive.com"}] 7 | license = { file = "LICENSE" } 8 | classifiers = [ 9 | "Environment :: Web Environment", 10 | "Framework :: Django", 11 | "Framework :: Django :: 4.2", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3 :: Only", 18 | "Programming Language :: Python :: 3.9", 19 | "Topic :: Internet" 20 | ] 21 | requires-python = ">=3.9" 22 | urls = { homepage = "https://apexive.com/" } 23 | 24 | dependencies = [ 25 | "Django>4", 26 | "djangorestframework>=3", 27 | "djangorestframework-simplejwt", 28 | "django-otp", 29 | "django-otp-twilio", #should be optional 30 | "twilio", # should optional 31 | "social-auth-app-django", # might be optional 32 | "django-phonenumber-field[phonenumbers]", 33 | "django-df-remote-config>=0.0.5", 34 | "django-df-api-drf>=1.0.4", 35 | ] 36 | 37 | [project.optional-dependencies] 38 | test = [ 39 | "pytest", 40 | "pytest-django", 41 | "django-stubs[compatible-mypy]", 42 | "black==23.7.0", 43 | "ruff==0.0.284", 44 | "httpretty", 45 | ] 46 | 47 | [build-system] 48 | requires = ['setuptools>=68.1.0', 'wheel'] 49 | build-backend = 'setuptools.build_meta' 50 | 51 | [tool.setuptools] 52 | include-package-data = true 53 | 54 | [tool.setuptools.packages.find] 55 | include = ["df_auth*"] 56 | 57 | [tools.black] 58 | max-line-length = 88 59 | 60 | 61 | [tool.ruff] 62 | line-length = 79 63 | fix = true 64 | 65 | 66 | # Enable Flake's "E" and "F" codes by default. 67 | select = ["E", "F", "I", "W", "A", "B", "Q", "C", "S"] 68 | 69 | ignore = ["B008", "E501", "S101", "A003", "B904"] 70 | 71 | # Exclude a variety of commonly ignored directories. 72 | exclude = [ 73 | ".bzr", 74 | ".direnv", 75 | ".eggs", 76 | ".git", 77 | ".hg", 78 | ".mypy_cache", 79 | ".nox", 80 | ".pants.d", 81 | ".ruff_cache", 82 | ".svn", 83 | ".tox", 84 | ".venv", 85 | "__pypackages__", 86 | "_build", 87 | "buck-out", 88 | "build", 89 | "dist", 90 | "node_modules", 91 | "venv", 92 | 'static', 93 | 'migrations', 94 | '__pycache__', 95 | '.pytest_cache', 96 | '__init__.py', 97 | ] 98 | 99 | 100 | # Allow unused variables when underscore-prefixed. 101 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 102 | 103 | # Assume Python 3.9.x 104 | target-version = "py39" 105 | 106 | 107 | [tool.ruff.per-file-ignores] 108 | "tests/*" = ["S105", "S106"] 109 | 110 | [tool.mypy] 111 | 112 | ignore_missing_imports = true 113 | disable_error_code = "attr-defined, valid-type" 114 | disallow_untyped_defs = true 115 | mypy_path = "df_auth" 116 | exclude = "venv|migrations|build|dist|docs" 117 | 118 | plugins = ["mypy_django_plugin.main"] 119 | 120 | [tool.django-stubs] 121 | django_settings_module = "tests.settings" 122 | 123 | [tool.pytest.ini_options] 124 | 125 | python_files = "tests.py test_*.py" 126 | DJANGO_SETTINGS_MODULE = "tests.settings" 127 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.6 on 2023-02-08 03:50 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [ 13 | ("auth", "0012_alter_user_first_name_max_length"), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="User", 19 | fields=[ 20 | ( 21 | "id", 22 | models.AutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("password", models.CharField(max_length=128, verbose_name="password")), 30 | ( 31 | "last_login", 32 | models.DateTimeField( 33 | blank=True, null=True, verbose_name="last login" 34 | ), 35 | ), 36 | ( 37 | "is_superuser", 38 | models.BooleanField( 39 | default=False, 40 | help_text="Designates that this user has all permissions without explicitly assigning them.", 41 | verbose_name="superuser status", 42 | ), 43 | ), 44 | ( 45 | "username", 46 | models.CharField( 47 | error_messages={ 48 | "unique": "A user with that username already exists." 49 | }, 50 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 51 | max_length=150, 52 | unique=True, 53 | validators=[ 54 | django.contrib.auth.validators.UnicodeUsernameValidator() 55 | ], 56 | verbose_name="username", 57 | ), 58 | ), 59 | ( 60 | "first_name", 61 | models.CharField( 62 | blank=True, max_length=150, verbose_name="first name" 63 | ), 64 | ), 65 | ( 66 | "last_name", 67 | models.CharField( 68 | blank=True, max_length=150, verbose_name="last name" 69 | ), 70 | ), 71 | ( 72 | "email", 73 | models.EmailField( 74 | blank=True, max_length=254, verbose_name="email address" 75 | ), 76 | ), 77 | ( 78 | "is_staff", 79 | models.BooleanField( 80 | default=False, 81 | help_text="Designates whether the user can log into this admin site.", 82 | verbose_name="staff status", 83 | ), 84 | ), 85 | ( 86 | "is_active", 87 | models.BooleanField( 88 | default=True, 89 | help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", 90 | verbose_name="active", 91 | ), 92 | ), 93 | ( 94 | "date_joined", 95 | models.DateTimeField( 96 | default=django.utils.timezone.now, verbose_name="date joined" 97 | ), 98 | ), 99 | ("phone_number", models.CharField(max_length=32)), 100 | ( 101 | "groups", 102 | models.ManyToManyField( 103 | blank=True, 104 | help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", 105 | related_name="user_set", 106 | related_query_name="user", 107 | to="auth.group", 108 | verbose_name="groups", 109 | ), 110 | ), 111 | ( 112 | "user_permissions", 113 | models.ManyToManyField( 114 | blank=True, 115 | help_text="Specific permissions for this user.", 116 | related_name="user_set", 117 | related_query_name="user", 118 | to="auth.permission", 119 | verbose_name="user permissions", 120 | ), 121 | ), 122 | ], 123 | options={ 124 | "verbose_name": "user", 125 | "verbose_name_plural": "users", 126 | "abstract": False, 127 | }, 128 | managers=[ 129 | ("objects", django.contrib.auth.models.UserManager()), 130 | ], 131 | ), 132 | ] 133 | -------------------------------------------------------------------------------- /df_auth/backends.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Type 2 | 3 | from df_api_drf.resolvers import client_url 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.auth.backends import ModelBackend 6 | from django.http import HttpRequest 7 | from django_otp.models import SideChannelDevice 8 | from django_otp.plugins.otp_email.models import EmailDevice 9 | from otp_twilio.models import TwilioSMSDevice 10 | 11 | from .exceptions import ( 12 | UserDoesNotExistError, 13 | UserInactiveError, 14 | WrongOTPError, 15 | ) 16 | from .settings import api_settings 17 | 18 | User = get_user_model() 19 | 20 | 21 | class TestEmailBackend(ModelBackend): 22 | def authenticate(self, request: Optional[HttpRequest], **kwargs: Any) -> Optional[User]: # type: ignore 23 | if ( 24 | api_settings.TEST_USER_EMAIL 25 | and kwargs.get("email") == api_settings.TEST_USER_EMAIL 26 | ): 27 | return User._default_manager.get(email=api_settings.TEST_USER_EMAIL) 28 | 29 | return None 30 | 31 | 32 | class BaseOTPBackend(ModelBackend): 33 | identity_field: str 34 | device_identity_field: str 35 | DeviceModel: Type[SideChannelDevice] 36 | 37 | def update_user_identity_field(self, device: SideChannelDevice) -> None: 38 | if api_settings.OTP_IDENTITY_UPDATE_FIELD: 39 | user = device.user 40 | setattr( 41 | user, self.identity_field, getattr(device, self.device_identity_field) 42 | ) 43 | user.save() 44 | 45 | def generate_challenge( 46 | self, request: HttpRequest, user: Optional[User], **kwargs: Any 47 | ) -> Optional[User]: 48 | """ 49 | - if user not authorized: 50 | - find User by some identity field: email/phone. 51 | - if not found: 52 | - if OTP_AUTO_CREATE_ACCOUNT -> create a User with form data 53 | - else: raise an error "No user, please register" 54 | 55 | - Create active device if does not exist 56 | - send otp for the device 57 | """ 58 | if not kwargs.get(self.identity_field): 59 | return None 60 | 61 | if not user: 62 | user = User.objects.filter( 63 | **{self.identity_field: kwargs.get(self.identity_field)} 64 | ).first() 65 | if not user: 66 | if api_settings.OTP_AUTO_CREATE_ACCOUNT and api_settings.SIGNUP_ALLOWED: 67 | user = User.objects.create( 68 | **{ 69 | k: v 70 | for k, v in kwargs.items() 71 | if k 72 | in [ 73 | *api_settings.USER_OPTIONAL_FIELDS, 74 | *api_settings.USER_IDENTITY_FIELDS, 75 | ] 76 | } 77 | ) 78 | kwargs["is_new_user"] = True 79 | else: 80 | raise UserDoesNotExistError() 81 | if not user.is_active: 82 | raise UserInactiveError() 83 | 84 | device, _ = self.DeviceModel.objects.get_or_create( 85 | user=user, 86 | **{self.device_identity_field: kwargs.get(self.identity_field)}, 87 | defaults={ 88 | "name": kwargs.get("name", kwargs.get(self.identity_field)), 89 | "confirmed": True, # Because User already has this device as identity field 90 | }, 91 | ) 92 | 93 | self.send_challenge(device, request, **kwargs) 94 | return device.user 95 | 96 | def send_challenge( 97 | self, device: SideChannelDevice, request: HttpRequest, **kwargs: Any 98 | ) -> None: 99 | device.generate_challenge() 100 | 101 | def authenticate(self, request: Optional[HttpRequest], **kwargs: Any) -> Optional[User]: # type: ignore 102 | """ 103 | Check OTP and authenticate User 104 | """ 105 | if not kwargs.get(self.identity_field) or not kwargs.get("otp"): 106 | return None 107 | 108 | user = User.objects.filter( 109 | **{self.identity_field: kwargs.get(self.identity_field)}, is_active=True 110 | ).first() 111 | 112 | if not user: 113 | return None 114 | 115 | device = self.DeviceModel.objects.filter( 116 | user=user, **{self.device_identity_field: kwargs.get(self.identity_field)} 117 | ).first() 118 | if device is None: 119 | return None 120 | 121 | if not device.verify_token(kwargs.get("otp")): 122 | raise WrongOTPError() 123 | 124 | return device.user 125 | 126 | 127 | class EmailOTPBackend(BaseOTPBackend): 128 | identity_field = "email" 129 | device_identity_field = "email" 130 | DeviceModel = EmailDevice 131 | 132 | def send_challenge( 133 | self, device: EmailDevice, request: HttpRequest, **kwargs: Any 134 | ) -> None: 135 | device.generate_challenge(self.extra_context(device, request, **kwargs)) 136 | 137 | def extra_context( 138 | self, device: EmailDevice, request: HttpRequest, **kwargs: Any 139 | ) -> Dict[str, Any]: 140 | return { 141 | "username": device.email, 142 | "base_url": client_url(request=request, user=device.user) 143 | + api_settings.SITE_LOGIN_URL, 144 | "redirect_path": kwargs.get("redirect_path", ""), 145 | } 146 | 147 | 148 | class TwilioSMSOTPBackend(BaseOTPBackend): 149 | identity_field = "phone_number" 150 | device_identity_field = "number" 151 | DeviceModel = TwilioSMSDevice 152 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from df_api_drf.defaults import ( 4 | DF_API_DRF_INSTALLED_APPS, 5 | REST_FRAMEWORK, 6 | SPECTACULAR_SETTINGS, 7 | ) 8 | from df_remote_config.defaults import DF_REMOTE_CONFIG_INSTALLED_APPS 9 | 10 | from df_auth.defaults import DF_AUTH_INSTALLED_APPS 11 | 12 | DEBUG = True 13 | 14 | ROOT_URLCONF = "tests.urls" 15 | SECRET_KEY = "111111" 16 | 17 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 18 | AUTH_USER_MODEL = "test_app.User" 19 | 20 | AUTHENTICATION_BACKENDS = [ 21 | "df_auth.backends.TestEmailBackend", 22 | "df_auth.backends.TwilioSMSOTPBackend", 23 | "df_auth.backends.EmailOTPBackend", 24 | "django.contrib.auth.backends.ModelBackend", 25 | "social_core.backends.google.GoogleOAuth2", 26 | "social_core.backends.facebook.FacebookOAuth2", 27 | "social_core.backends.apple.AppleIdAuth", 28 | "social_core.backends.twitter.TwitterOAuth", 29 | ] 30 | 31 | INSTALLED_APPS = [ 32 | "django.contrib.admin", 33 | "django.contrib.auth", 34 | "django.contrib.contenttypes", 35 | "django.contrib.sessions", 36 | "django.contrib.sites", 37 | "django.contrib.messages", 38 | "django.contrib.staticfiles", 39 | *DF_API_DRF_INSTALLED_APPS, 40 | *DF_REMOTE_CONFIG_INSTALLED_APPS, 41 | *DF_AUTH_INSTALLED_APPS, 42 | "tests.test_app.apps.TestAppConfig", 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | "django.middleware.security.SecurityMiddleware", 47 | "django.contrib.sessions.middleware.SessionMiddleware", 48 | "django.middleware.common.CommonMiddleware", 49 | "django.middleware.csrf.CsrfViewMiddleware", 50 | "django.contrib.auth.middleware.AuthenticationMiddleware", 51 | "django.contrib.messages.middleware.MessageMiddleware", 52 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 53 | ] 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "OPTIONS": { 60 | "context_processors": [ 61 | "django.template.context_processors.debug", 62 | "django.template.context_processors.request", 63 | "django.contrib.auth.context_processors.auth", 64 | "django.contrib.messages.context_processors.messages", 65 | ], 66 | "loaders": [ 67 | "django.template.loaders.filesystem.Loader", 68 | "django.template.loaders.app_directories.Loader", 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | SITE_ID = 1 75 | 76 | DATABASES = { 77 | "default": { 78 | "ENGINE": "django.db.backends.sqlite3", 79 | "NAME": "db.sqlite3", 80 | } 81 | } 82 | 83 | LOGGING = { 84 | "version": 1, 85 | "disable_existing_loggers": False, 86 | "handlers": { 87 | "console": { 88 | "class": "logging.StreamHandler", 89 | }, 90 | }, 91 | "root": { 92 | "handlers": ["console"], 93 | "level": "INFO", 94 | }, 95 | } 96 | 97 | STATIC_URL = "/static/" 98 | 99 | ALLOWED_HOSTS = ["*"] 100 | 101 | DF_AUTH = { 102 | "TEST_USER_EMAIL": "a@a.aa", 103 | "OTP_IDENTITY_UPDATE_FIELD": True, 104 | } 105 | 106 | OTP_TWILIO_ACCOUNT = os.environ.get("OTP_TWILIO_ACCOUNT", "") 107 | OTP_TWILIO_AUTH = os.environ.get("OTP_TWILIO_AUTH", "") 108 | OTP_TWILIO_FROM = os.environ.get("OTP_TWILIO_FROM", "") 109 | OTP_TWILIO_TOKEN_VALIDITY = 300 110 | OTP_TWILIO_NO_DELIVERY = True 111 | 112 | EMAIL_HOST = os.environ.get("EMAIL_HOST", "") 113 | EMAIL_PORT = os.environ.get("EMAIL_PORT", "") 114 | EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "") 115 | EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") 116 | EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") 117 | 118 | SOCIAL_AUTH_TWITTER_KEY = os.environ.get("SOCIAL_AUTH_TWITTER_KEY", "") 119 | SOCIAL_AUTH_TWITTER_SECRET = os.environ.get("SOCIAL_AUTH_TWITTER_SECRET", "") 120 | 121 | SPECTACULAR_SETTINGS = {**SPECTACULAR_SETTINGS} 122 | 123 | REST_FRAMEWORK = {**REST_FRAMEWORK} 124 | REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] += ( 125 | "rest_framework.authentication.SessionAuthentication", 126 | ) 127 | 128 | SOCIAL_AUTH_PIPELINE = [ 129 | # Get the information we can about the user and return it in a simple 130 | # format to create the user instance later. On some cases the details are 131 | # already part of the auth response from the provider, but sometimes this 132 | # could hit a provider API. 133 | "social_core.pipeline.social_auth.social_details", 134 | # Get the social uid from whichever service we're authing thru. The uid is 135 | # the unique identifier of the given user in the provider. 136 | "social_core.pipeline.social_auth.social_uid", 137 | # Verifies that the current auth process is valid within the current 138 | # project, this is where emails and domains whitelists are applied (if 139 | # defined). 140 | # 'social_core.pipeline.social_auth.auth_allowed', 141 | # Checks if the current social-account is already associated in the site. 142 | "social_core.pipeline.social_auth.social_user", 143 | # Make up a username for this person, appends a random string at the end if 144 | # there's any collision. 145 | "social_core.pipeline.user.get_username", 146 | # Send a validation email to the user to verify its email address. 147 | # 'social_core.pipeline.mail.mail_validation', 148 | # Associates the current social details with another user account with 149 | # a similar email address. 150 | "social_core.pipeline.social_auth.associate_by_email", 151 | # Create a user account if we haven't found one yet. 152 | "social_core.pipeline.user.create_user", 153 | # Create the record that associated the social account with this user. 154 | "social_core.pipeline.social_auth.associate_user", 155 | # Populate the extra_data field in the social record with the values 156 | # specified by settings (and the default ones like access_token, etc). 157 | "social_core.pipeline.social_auth.load_extra_data", 158 | # Update the user record with any changed info from the auth service. 159 | "social_core.pipeline.user.user_details", 160 | ] 161 | 162 | DF_REMOTE_CONFIG = { 163 | "PARTS": { 164 | "auth": { 165 | "SCHEMA": "df_auth.remote_config.auth_schema", 166 | "HANDLER_CLASS": "df_auth.remote_config.AuthHandler", 167 | } 168 | } 169 | } 170 | 171 | 172 | SOCIAL_AUTH_FACEBOOK_KEY = "dqdqwqdwdqwqw" 173 | -------------------------------------------------------------------------------- /df_auth/drf/viewsets.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Iterable, List, Type 2 | 3 | from django.conf import settings 4 | from django.http import HttpRequest, HttpResponse 5 | from django_otp.models import Device 6 | from drf_spectacular.types import OpenApiTypes 7 | from drf_spectacular.utils import OpenApiParameter, extend_schema 8 | from rest_framework import permissions, response, status, viewsets 9 | from rest_framework.decorators import action 10 | from rest_framework.permissions import BasePermission 11 | from rest_framework.settings import import_string 12 | from rest_framework_simplejwt.settings import ( 13 | api_settings as simple_jwt_settings, 14 | ) 15 | 16 | from ..exceptions import ( 17 | DfAuthValidationError, 18 | ) 19 | from ..models import User2FA 20 | from ..permissions import IsUnauthenticated, IsUserCreateAllowed 21 | from ..utils import get_otp_device_models, get_otp_devices 22 | from .serializers import ( 23 | ChangePasswordSerializer, 24 | OTPDeviceConfirmSerializer, 25 | OTPDeviceSerializer, 26 | OTPObtainSerializer, 27 | SocialTokenObtainSerializer, 28 | TokenObtainSerializer, 29 | TokenSerializer, 30 | User2FASerializer, 31 | UserIdentitySerializer, 32 | ) 33 | 34 | 35 | class ValidationOnlyCreateViewSet(viewsets.GenericViewSet): 36 | def create(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: 37 | serializer = self.get_serializer(data=request.data) 38 | serializer.is_valid(raise_exception=True) 39 | return response.Response(serializer.data, status=status.HTTP_200_OK) 40 | 41 | 42 | class TokenViewSet(ValidationOnlyCreateViewSet): 43 | serializer_class = TokenObtainSerializer 44 | response_serializer_class = TokenSerializer 45 | permission_classes = (permissions.AllowAny,) 46 | 47 | @action( 48 | methods=["post"], 49 | detail=False, 50 | serializer_class=import_string(simple_jwt_settings.TOKEN_REFRESH_SERIALIZER), 51 | ) 52 | def refresh(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: 53 | return self.create(request, *args, **kwargs) 54 | 55 | @action( 56 | methods=["post"], 57 | detail=False, 58 | serializer_class=import_string(simple_jwt_settings.TOKEN_VERIFY_SERIALIZER), 59 | ) 60 | def verify(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: 61 | return self.create(request, *args, **kwargs) 62 | 63 | @action( 64 | methods=["post"], 65 | detail=False, 66 | serializer_class=import_string(simple_jwt_settings.TOKEN_BLACKLIST_SERIALIZER), 67 | ) 68 | def blacklist( 69 | self, request: HttpRequest, *args: Any, **kwargs: Any 70 | ) -> HttpResponse: 71 | if "rest_framework_simplejwt.token_blacklist" not in settings.INSTALLED_APPS: 72 | raise NotImplementedError 73 | 74 | return self.create(request, *args, **kwargs) 75 | 76 | 77 | class OTPViewSet(ValidationOnlyCreateViewSet): 78 | throttle_scope = "otp" 79 | serializer_class = OTPObtainSerializer 80 | permission_classes = (permissions.AllowAny,) 81 | 82 | 83 | class SocialTokenViewSet(ValidationOnlyCreateViewSet): 84 | serializer_class = SocialTokenObtainSerializer 85 | response_serializer_class = TokenSerializer 86 | permission_classes = (IsUnauthenticated,) 87 | 88 | @action( 89 | methods=["post"], 90 | detail=False, 91 | serializer_class=SocialTokenObtainSerializer, 92 | permission_classes=(permissions.IsAuthenticated,), 93 | ) 94 | def connect(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: 95 | return self.create(request, *args, **kwargs) 96 | 97 | 98 | otp_device_detail_params = [ 99 | OpenApiParameter( 100 | name="type", 101 | type=OpenApiTypes.STR, 102 | location=OpenApiParameter.QUERY, 103 | description="OTP Device type", 104 | required=True, 105 | ) 106 | ] 107 | 108 | 109 | class OtpDeviceViewSet( 110 | viewsets.GenericViewSet, 111 | viewsets.mixins.ListModelMixin, 112 | viewsets.mixins.DestroyModelMixin, 113 | viewsets.mixins.RetrieveModelMixin, 114 | viewsets.mixins.CreateModelMixin, 115 | ): 116 | throttle_scope = "otp" 117 | serializer_class = OTPDeviceSerializer 118 | permission_classes = (permissions.IsAuthenticated,) 119 | 120 | def get_queryset(self) -> List[Device]: 121 | return get_otp_devices(self.request.user) 122 | 123 | def get_device_model(self) -> Type[Device]: 124 | device_type = self.request.GET.get("type") 125 | otp_device_models = get_otp_device_models() 126 | if device_type not in otp_device_models: 127 | raise DfAuthValidationError( 128 | f"Invalid device type. Must be one of {', '.join(otp_device_models.keys())}" 129 | ) 130 | return otp_device_models[device_type] 131 | 132 | def get_object(self) -> Device: 133 | return self.get_device_model().objects.get( 134 | user=self.request.user, pk=self.kwargs["pk"] 135 | ) 136 | 137 | @extend_schema(parameters=otp_device_detail_params) 138 | @action( 139 | methods=["post"], 140 | detail=True, 141 | serializer_class=OTPDeviceConfirmSerializer, 142 | ) 143 | def confirm(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: 144 | serializer = OTPDeviceConfirmSerializer( 145 | data=request.data, instance=self.get_object() 146 | ) 147 | serializer.is_valid(raise_exception=True) 148 | serializer.save() 149 | return response.Response({}) 150 | 151 | @extend_schema(parameters=otp_device_detail_params) 152 | def destroy(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: 153 | return super().destroy(request, *args, **kwargs) 154 | 155 | @extend_schema(parameters=otp_device_detail_params) 156 | def retrieve(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: 157 | return super().retrieve(request, *args, **kwargs) 158 | 159 | 160 | class UserViewSet( 161 | viewsets.GenericViewSet, 162 | viewsets.mixins.RetrieveModelMixin, 163 | viewsets.mixins.CreateModelMixin, 164 | viewsets.mixins.UpdateModelMixin, 165 | ): 166 | serializer_class = UserIdentitySerializer 167 | permission_classes = (permissions.IsAuthenticated,) 168 | http_method_names = ["get", "post", "patch"] 169 | 170 | def get_permissions(self) -> Iterable[BasePermission]: 171 | if self.action == "create": 172 | return (IsUserCreateAllowed(),) 173 | return super().get_permissions() 174 | 175 | def get_object(self) -> Any: 176 | return self.request.user 177 | 178 | @action( 179 | detail=True, 180 | methods=["POST"], 181 | serializer_class=ChangePasswordSerializer, 182 | url_path="set-password", 183 | ) 184 | def set_password( 185 | self, request: HttpRequest, *args: Any, **kwargs: Any 186 | ) -> HttpResponse: 187 | return super().update(request, *args, **kwargs) 188 | 189 | @action( 190 | detail=True, 191 | methods=["GET", "PATCH"], 192 | serializer_class=User2FASerializer, 193 | url_path="two-fa", 194 | ) 195 | def two_fa(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: 196 | instance = User2FA.objects.get_or_create(user=self.get_object())[0] 197 | if request.method == "GET": 198 | serializer = self.get_serializer(instance) 199 | else: 200 | serializer = self.get_serializer(instance, data=request.data, partial=True) 201 | serializer.is_valid(raise_exception=True) 202 | serializer.save() 203 | return response.Response(serializer.data) 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-df-auth 2 | 3 | This is a simple opinionated module that implements JWT authentication via REST API. 4 | For more complex applications please consider using an external authentication service such as https://goauthentik.io 5 | 6 | The module is a glue and uses: 7 | 8 | - drf - for the API 9 | - simplejwt - for JWT 10 | - pysocial - for social login 11 | - django-otp* for otp and 2fa 12 | - twilio - for text messages 13 | 14 | The module also provides very limited extra functionality to the packages above: 15 | 16 | - otp devices management OTPDeviceViewSet 17 | - Create, Delete 18 | - user registration and invitation methods and template 19 | - standard User fields = first_name, last_name, email, phone 20 | - extra User fields / serializer override in settings 21 | - 22 | - phone number white/black listing rules (to be removed?) => registration identity blacklist? 23 | 24 | Blacklisting: 25 | - phone / email registration blacklisting (e.g. premium numbers, disposable emails ) regex 26 | - otp sending blacklisting 27 | - ip address blacklisting (honey trap) 28 | - usernames pattern - avoid religiously offensive words 29 | 30 | 31 | The OTP supports following flows: 32 | - otp (email/phone/static/totp) verification - can also be used for confirming email/phone 33 | - 2FA 34 | - magic signin link 35 | 36 | ## Configuration 37 | 38 | ### Basic Setup 39 | 40 | Add to `settings.py`: 41 | 42 | ```python 43 | from df_auth.defaults import DF_AUTH_INSTALLED_APPS 44 | 45 | INSTALLED_APPS = [ 46 | # ... your apps 47 | *DF_AUTH_INSTALLED_APPS, 48 | ] 49 | 50 | AUTHENTICATION_BACKENDS = [ 51 | 'df_auth.backends.EmailOTPBackend', 52 | 'df_auth.backends.TwilioSMSOTPBackend', 53 | 'django.contrib.auth.backends.ModelBackend', 54 | # Optional: social auth backends 55 | 'social_core.backends.google.GoogleOAuth2', 56 | ] 57 | ``` 58 | 59 | Add to `urls.py`: 60 | 61 | ```python 62 | urlpatterns = [ 63 | path('api/v1/auth/', include('df_auth.drf.urls')), 64 | ] 65 | ``` 66 | 67 | ### Registration Flow Configuration 68 | 69 | #### Immediate Account Creation (Default) 70 | 71 | By default, accounts are created immediately when users request an OTP: 72 | 73 | ```python 74 | DF_AUTH = { 75 | 'OTP_AUTO_CREATE_ACCOUNT': True, # Default 76 | 'SIGNUP_ALLOWED': True, # Default 77 | } 78 | ``` 79 | 80 | **Flow:** 81 | 1. User requests OTP at `/auth/otp/` with email/phone 82 | 2. User account created immediately (unverified) 83 | 3. OTP sent to email/phone 84 | 4. User authenticates with OTP to get JWT token 85 | 5. User can confirm device via `/auth/otp-devices/{id}/confirm/` 86 | 87 | #### Verified Registration Only 88 | 89 | To prevent account creation until OTP is confirmed, disable auto-creation: 90 | 91 | ```python 92 | DF_AUTH = { 93 | 'OTP_AUTO_CREATE_ACCOUNT': False, 94 | 'SIGNUP_ALLOWED': True, 95 | } 96 | ``` 97 | 98 | **Flow:** 99 | 1. User must first register via `/auth/users/` with email/phone/password 100 | 2. An unconfirmed OTP device is created automatically 101 | 3. User requests OTP via `/auth/otp/` 102 | 4. User confirms device via `/auth/otp-devices/{id}/confirm/` 103 | 5. User's email/phone is updated from confirmed device (if `OTP_IDENTITY_UPDATE_FIELD=True`) 104 | 105 | This ensures users must complete the full registration flow before their identity is verified. 106 | 107 | #### Deferred Identity Assignment (Username-only with Email Confirmation) 108 | 109 | To allow users to register with a username but defer email/phone assignment until OTP is confirmed: 110 | 111 | ```python 112 | DF_AUTH = { 113 | 'USER_IDENTITY_FIELDS': { 114 | 'username': 'rest_framework.serializers.CharField', 115 | }, 116 | 'USER_OPTIONAL_FIELDS': { 117 | 'email': 'rest_framework.serializers.CharField', 118 | 'phone_number': 'phonenumber_field.serializerfields.PhoneNumberField', 119 | 'password': 'rest_framework.serializers.CharField', 120 | }, 121 | 'DEFER_IDENTITY_UPDATE': True, # Defer email/phone until device confirmed 122 | 'OTP_IDENTITY_UPDATE_FIELD': True, # Update user from confirmed device 123 | } 124 | ``` 125 | 126 | **Flow:** 127 | 1. User registers: `POST /auth/users/` with `{ username: "john", email: "john@example.com", password: "..." }` 128 | 2. User created with `username="john"`, `email=""` (blank!) ✅ 129 | 3. EmailDevice created with `email="john@example.com"`, `confirmed=False` ✅ 130 | 4. User requests OTP: `POST /auth/otp/` with `{ email: "john@example.com" }` 131 | 5. OTP sent to john@example.com ✅ 132 | 6. User confirms: `POST /auth/otp-devices/{id}/confirm/` with `{ otp: "123456" }` 133 | 7. Device marked `confirmed=True` ✅ 134 | 8. User's `email` field NOW updated to "john@example.com" ✅ 135 | 136 | **Benefits:** 137 | - Email/phone not saved to user model until verified 138 | - Multiple users can register with the same unconfirmed email (no uniqueness constraint) 139 | - Only confirmed email/phone is assigned to the user 140 | - Prevents spam registrations with disposable emails 141 | - Username remains the unique identity field 142 | 143 | #### Auto-Send Identity Verification OTP 144 | 145 | Automatically send verification OTPs to email/phone when users register: 146 | 147 | ```python 148 | DF_AUTH = { 149 | 'AUTO_SEND_IDENTITY_VERIFICATION_OTP': True, # Auto-send OTPs on signup 150 | } 151 | ``` 152 | 153 | **Flow:** 154 | 1. User registers: `POST /auth/users/` with `{ email: "john@example.com", password: "..." }` 155 | 2. User created with email/phone ✅ 156 | 3. EmailDevice/TwilioSMSDevice created with `confirmed=False` ✅ 157 | 4. **OTP automatically sent to email/phone** ✅ 158 | 5. User confirms via `/auth/otp-devices/{id}/confirm/` with received OTP ✅ 159 | 6. Device marked `confirmed=True` ✅ 160 | 161 | **Combine with Deferred Identity:** 162 | 163 | ```python 164 | DF_AUTH = { 165 | 'USER_IDENTITY_FIELDS': {'username': '...'}, 166 | 'USER_OPTIONAL_FIELDS': {'email': '...', 'phone_number': '...'}, 167 | 'DEFER_IDENTITY_UPDATE': True, # Don't save until confirmed 168 | 'AUTO_SEND_IDENTITY_VERIFICATION_OTP': True, # Auto-send OTP 169 | 'OTP_IDENTITY_UPDATE_FIELD': True, # Update on confirmation 170 | } 171 | ``` 172 | 173 | **Combined Flow:** 174 | 1. User registers with username + email 175 | 2. User created: `username="john"`, `email=""` (deferred) 176 | 3. Device created with email, **OTP automatically sent** 📧 177 | 4. User confirms with received OTP 178 | 5. User's email updated to confirmed email ✅ 179 | 180 | **Benefits:** 181 | - Seamless onboarding - users receive OTP immediately 182 | - No need to manually request OTP via `/auth/otp/` endpoint 183 | - Works with both standard and deferred identity flows 184 | - Ensures email/phone ownership from registration 185 | 186 | #### Invite-Only Registration 187 | 188 | Disable public signups and require authenticated users to invite: 189 | 190 | ```python 191 | DF_AUTH = { 192 | 'SIGNUP_ALLOWED': False, # Public signup disabled 193 | 'INVITE_ALLOWED': True, # Default - authenticated users can invite 194 | } 195 | ``` 196 | 197 | **Flow:** 198 | 1. Unauthenticated users cannot POST to `/auth/users/` 199 | 2. Authenticated users can create invitations via `/auth/users/` 200 | 3. Created user has `UserRegistration.invited_by` tracking inviter 201 | 202 | ### OTP Configuration 203 | 204 | ```python 205 | DF_AUTH = { 206 | # Update user's email/phone when OTP device is confirmed 207 | 'OTP_IDENTITY_UPDATE_FIELD': True, # Default 208 | 209 | # Allow OTP requests from non-authenticated users 210 | 'OTP_SEND_UNAUTHORIZED_USER': True, # Default 211 | 212 | # Available OTP device types 213 | 'OTP_DEVICE_MODELS': { 214 | 'email': 'django_otp.plugins.otp_email.models.EmailDevice', 215 | 'totp': 'django_otp.plugins.otp_totp.models.TOTPDevice', 216 | 'sms': 'otp_twilio.models.TwilioSMSDevice', 217 | }, 218 | } 219 | ``` 220 | 221 | ### Identity Field Configuration 222 | 223 | Configure which fields uniquely identify users: 224 | 225 | ```python 226 | DF_AUTH = { 227 | # Default: email, phone, and username are all identity fields 228 | 'USER_IDENTITY_FIELDS': { 229 | 'username': 'rest_framework.serializers.CharField', 230 | 'email': 'rest_framework.serializers.CharField', 231 | 'phone_number': 'phonenumber_field.serializerfields.PhoneNumberField', 232 | }, 233 | } 234 | ``` 235 | 236 | **Username-only identity** (allow duplicate emails/phones): 237 | 238 | ```python 239 | DF_AUTH = { 240 | 'USER_IDENTITY_FIELDS': { 241 | 'username': 'rest_framework.serializers.CharField', 242 | }, 243 | 'USER_OPTIONAL_FIELDS': { 244 | 'email': 'rest_framework.serializers.CharField', 245 | 'phone_number': 'phonenumber_field.serializerfields.PhoneNumberField', 246 | 'password': 'rest_framework.serializers.CharField', 247 | }, 248 | } 249 | ``` 250 | 251 | ### 2FA Configuration 252 | 253 | 2FA is controlled per-user via the `User2FA` model: 254 | 255 | ```python 256 | # Enable 2FA for a user 257 | from df_auth.models import User2FA 258 | User2FA.objects.create(user=user, is_required=True) 259 | 260 | # Via API 261 | PATCH /api/v1/auth/users/0/two-fa/ 262 | { 263 | "is_required": true 264 | } 265 | ``` 266 | 267 | When 2FA is enabled: 268 | - User must provide valid OTP from a confirmed device 269 | - Works with password auth, social login, and magic links 270 | - API returns available devices if 2FA required but not provided 271 | 272 | ### Twilio Configuration 273 | 274 | Configure Twilio for SMS OTP: 275 | 276 | ```python 277 | OTP_TWILIO_ACCOUNT = 'your-account-sid' 278 | OTP_TWILIO_AUTH = 'your-auth-token' 279 | OTP_TWILIO_FROM = 'your-twilio-phone-number' 280 | OTP_TWILIO_TOKEN_VALIDITY = 300 # 5 minutes 281 | ``` 282 | 283 | ### Social Auth Configuration 284 | 285 | Example Google OAuth2 setup: 286 | 287 | ```python 288 | SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = 'your-client-id' 289 | SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = 'your-client-secret' 290 | 291 | # Fields to populate from social auth 292 | DF_AUTH = { 293 | 'USER_SOCIAL_AUTH_FIELDS': { 294 | 'first_name': 'rest_framework.serializers.CharField', 295 | 'last_name': 'rest_framework.serializers.CharField', 296 | }, 297 | } 298 | ``` 299 | 300 | ## API Endpoints 301 | 302 | ### Authentication 303 | - `POST /auth/token/` - Obtain JWT token (username/email/phone + password/otp) 304 | - `POST /auth/token/refresh/` - Refresh JWT token 305 | - `POST /auth/token/verify/` - Verify JWT token 306 | - `POST /auth/token/blacklist/` - Blacklist JWT token 307 | 308 | ### User Management 309 | - `POST /auth/users/` - Register new user or invite user (if authenticated) 310 | - `GET /auth/users/0/` - Get current user profile 311 | - `PATCH /auth/users/0/` - Update user profile 312 | - `POST /auth/users/0/set-password/` - Change password 313 | - `GET /auth/users/0/two-fa/` - Get 2FA status 314 | - `PATCH /auth/users/0/two-fa/` - Update 2FA requirement 315 | 316 | ### OTP 317 | - `POST /auth/otp/` - Request OTP (email or SMS) 318 | 319 | ### OTP Devices 320 | - `GET /auth/otp-devices/` - List user's OTP devices 321 | - `POST /auth/otp-devices/` - Create OTP device (email, sms, totp) 322 | - `GET /auth/otp-devices/{id}/?type={type}` - Get device details 323 | - `POST /auth/otp-devices/{id}/confirm/?type={type}` - Confirm device with OTP 324 | - `DELETE /auth/otp-devices/{id}/?type={type}` - Delete device 325 | 326 | ### Social Authentication 327 | - `POST /auth/social/` - Login with social provider (google-oauth2, facebook, apple) 328 | - `POST /auth/social/connect/` - Connect social account (requires authentication) 329 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | `django-df-auth` is an opinionated Django REST authentication module providing JWT-based authentication with OTP/2FA support and social login integration. It's a glue package combining djangorestframework, simplejwt, python-social-auth, django-otp, and twilio for a complete authentication solution. 8 | 9 | ## Development Commands 10 | 11 | ### Testing 12 | 13 | ```bash 14 | # Run all tests 15 | pytest 16 | 17 | # Run specific test file 18 | pytest tests/test_app/tests.py 19 | 20 | # Run specific test class 21 | pytest tests/test_app/tests.py::OtpDeviceViewSetAPITest 22 | 23 | # Run specific test method 24 | pytest tests/test_app/tests.py::OtpDeviceViewSetAPITest::test_create_email_device 25 | 26 | # Run with verbose output 27 | pytest -v 28 | 29 | # Run with output (show print statements) 30 | pytest -s 31 | ``` 32 | 33 | ### Code Quality 34 | 35 | ```bash 36 | # Format code with black 37 | black . 38 | 39 | # Lint with ruff (auto-fix) 40 | ruff check --fix . 41 | 42 | # Type checking with mypy 43 | mypy df_auth 44 | 45 | # Run pre-commit hooks manually 46 | pre-commit run --all-files 47 | ``` 48 | 49 | ### Development Server 50 | 51 | ```bash 52 | # Run Django development server (for testing) 53 | python manage.py runserver 54 | 55 | # Create migrations (if extending models) 56 | python manage.py makemigrations 57 | 58 | # Apply migrations 59 | python manage.py migrate 60 | 61 | # Create superuser 62 | python manage.py createsuperuser 63 | ``` 64 | 65 | ### Building & Distribution 66 | 67 | ```bash 68 | # Build package 69 | python -m build 70 | 71 | # Install in editable mode for development 72 | pip install -e . 73 | 74 | # Install with test dependencies 75 | pip install -e ".[test]" 76 | ``` 77 | 78 | ## Architecture 79 | 80 | ### Core Authentication Flow 81 | 82 | The package provides multiple authentication backends that work together: 83 | 84 | 1. **TestEmailBackend** (`df_auth/backends.py:21`) - Test-only backend for automated testing 85 | 2. **EmailOTPBackend** (`df_auth/backends.py:127`) - OTP via email with magic link support 86 | 3. **TwilioSMSOTPBackend** (`df_auth/backends.py:148`) - OTP via SMS using Twilio 87 | 4. **ModelBackend** - Standard Django username/password authentication 88 | 5. **Social Auth Backends** - Google OAuth2, Facebook, Apple ID, Twitter 89 | 90 | All backends are configured in `AUTHENTICATION_BACKENDS` and are tried sequentially during authentication. 91 | 92 | ### Key Components 93 | 94 | **Authentication Backends** (`df_auth/backends.py`) 95 | - `BaseOTPBackend` - Abstract base for OTP authentication with `generate_challenge()` and `authenticate()` methods 96 | - OTP backends implement identity field mapping (email -> email, phone_number -> number) 97 | - Supports auto-account creation via `OTP_AUTO_CREATE_ACCOUNT` setting 98 | 99 | **Serializers** (`df_auth/drf/serializers.py`) 100 | - `TokenObtainSerializer` (line 81) - Handles multiple auth methods (password, OTP, combinations) 101 | - `OTPObtainSerializer` (line 148) - Generates and sends OTP challenges 102 | - `SocialTokenObtainSerializer` (line 186) - Social login with optional 2FA 103 | - `UserIdentitySerializer` (line 324) - User registration/update with flexible identity fields 104 | - All serializers use dynamic field building based on `DF_AUTH` settings 105 | 106 | **ViewSets** (`df_auth/drf/viewsets.py`) 107 | - `TokenViewSet` - Token obtain/refresh/verify/blacklist endpoints 108 | - `OTPViewSet` - OTP request endpoint 109 | - `OtpDeviceViewSet` - Manage user's OTP devices (create, list, confirm, delete) 110 | - `UserViewSet` - User registration, profile management, password changes, 2FA settings 111 | - `SocialTokenViewSet` - Social login and account connection 112 | 113 | **Models** (`df_auth/models.py`) 114 | - `User2FA` - Per-user 2FA requirement flag 115 | - `UserRegistration` - Tracks user registration state and invitation relationships 116 | - Models use `UserOneToOneMixin` for relationship to AUTH_USER_MODEL 117 | 118 | ### Settings Configuration 119 | 120 | Settings are managed through `DF_AUTH` dict in Django settings (see `df_auth/settings.py`): 121 | 122 | **Identity Fields** - Configurable fields for user identification: 123 | - `USER_IDENTITY_FIELDS` - Fields that uniquely identify users (default: username, email, phone_number) 124 | - `USER_OPTIONAL_FIELDS` - Optional user profile fields 125 | - `USER_SOCIAL_AUTH_FIELDS` - Fields populated from social auth 126 | 127 | **Authentication Options**: 128 | - `REQUIRED_AUTH_FIELDS` - Always required for authentication 129 | - `OPTIONAL_AUTH_FIELDS` - Optional auth fields (otp, password) 130 | - `OTP_AUTO_CREATE_ACCOUNT` - Auto-create user on OTP request (default: True) 131 | - `OTP_SEND_UNAUTHORIZED_USER` - Allow OTP for non-logged-in users (default: True) 132 | - `OTP_IDENTITY_UPDATE_FIELD` - Update user identity when OTP device confirmed (default: True) 133 | - `DEFER_IDENTITY_UPDATE` - Defer email/phone assignment until device confirmed (default: False) 134 | - `AUTO_SEND_IDENTITY_VERIFICATION_OTP` - Auto-send OTP on registration (default: False) 135 | 136 | **Feature Flags**: 137 | - `SIGNUP_ALLOWED` - Enable public registration (default: True) 138 | - `INVITE_ALLOWED` - Enable authenticated users to invite others (default: True) 139 | 140 | **OTP Configuration**: 141 | - `OTP_DEVICE_MODELS` - Maps device type names to model classes (email, totp, sms) 142 | 143 | ### URL Routing 144 | 145 | API endpoints are mounted via Django REST Framework router (`df_auth/drf/urls.py`): 146 | 147 | ```python 148 | # Add to your root urls.py: 149 | path('auth/', include('df_auth.drf.urls')) 150 | ``` 151 | 152 | Endpoints: 153 | - `/auth/token/` - POST: obtain token, /refresh/, /verify/, /blacklist/ 154 | - `/auth/users/` - POST: create user, PATCH: update profile, /0/set-password/, /0/two-fa/ 155 | - `/auth/otp/` - POST: request OTP 156 | - `/auth/otp-devices/` - GET: list devices, POST: create device, /{id}/confirm/, DELETE: remove device 157 | - `/auth/social/` - POST: social login, /connect/ - connect social account 158 | 159 | ### 2FA Implementation 160 | 161 | 2FA is implemented through OTP devices: 162 | 1. User enables 2FA via `User2FA.is_required = True` 163 | 2. On authentication, `check_user_2fa()` (`df_auth/drf/serializers.py:42`) verifies OTP from confirmed devices 164 | 3. If 2FA required but not provided, raises `Authentication2FARequiredError` with available devices 165 | 4. Works with password auth, social login, and magic link flows 166 | 167 | ### OTP Device Lifecycle 168 | 169 | 1. **Create**: User creates device via `/otp-devices/` endpoint (unconfirmed) 170 | 2. **Challenge**: Device generates OTP token via `generate_challenge()` 171 | 3. **Confirm**: User confirms device with OTP via `/otp-devices/{id}/confirm/` 172 | 4. **Update Identity**: If `OTP_IDENTITY_UPDATE_FIELD=True`, user's email/phone updated from confirmed device 173 | 5. **Use**: Confirmed devices can authenticate user or satisfy 2FA requirement 174 | 175 | ### Flexible Identity Fields 176 | 177 | The package supports configurable user identity fields via `USER_IDENTITY_FIELDS`: 178 | - Default: username, email, phone_number 179 | - Can be restricted (e.g., username-only) to allow duplicate emails/phones 180 | - Serializers validate uniqueness only for configured identity fields (`df_auth/drf/serializers.py:342-397`) 181 | - Username auto-populated from first available identity field if not provided (`df_auth/drf/serializers.py:406`) 182 | 183 | ## Testing Architecture 184 | 185 | Tests use pytest-django with Django's APITestCase (`tests/test_app/tests.py`): 186 | - Test app defines custom User model (`tests/test_app/models.py`) 187 | - Test settings in `tests/settings.py` configure all backends and apps 188 | - Tests cover: OTP devices, user registration, token auth, 2FA, social login, feature flags 189 | - Use `api_settings` override pattern for testing different configurations (see `UserViewSetWithUsernameOnlyIdentityFieldAPITest`) 190 | 191 | ## Code Style 192 | 193 | - **Formatting**: Black with max line length 88 194 | - **Linting**: Ruff with line length 79, enabled rules: E, F, I, W, A, B, Q, C, S 195 | - **Type Checking**: mypy with django-stubs plugin 196 | - **Pre-commit hooks**: Runs black, ruff, mypy automatically 197 | 198 | Ruff ignores: 199 | - B008 (function call in default argument) 200 | - E501 (line too long - handled by black) 201 | - S101 (use of assert - allowed in tests) 202 | - A003 (builtin attribute shadowing) 203 | - B904 (raise without from inside except) 204 | 205 | ## Important Patterns 206 | 207 | ### Dynamic Serializer Fields 208 | 209 | Serializers use `build_fields()` helper to dynamically construct fields from settings: 210 | 211 | ```python 212 | def build_fields(fields: Dict[str, str], **kwargs) -> Dict[str, serializers.Field]: 213 | return {name: import_string(klass)(**kwargs) for name, klass in fields.items()} 214 | ``` 215 | 216 | This allows runtime field configuration based on `DF_AUTH` settings. 217 | 218 | ### Backend Method Dispatch 219 | 220 | `AuthBackendSerializer` (`df_auth/drf/serializers.py:115`) iterates through `AUTHENTICATION_BACKENDS` calling specified method: 221 | - Set `backend_method_name` on serializer subclass 222 | - Backend method called with serializer attrs + context 223 | - First backend to return a user wins 224 | 225 | ### OTP Challenge Pattern 226 | 227 | OTP backends follow consistent pattern: 228 | 1. `generate_challenge()` - Find/create user, create/get device, send OTP 229 | 2. Device's `generate_challenge()` - Generate token, send via channel (email/SMS) 230 | 3. `authenticate()` - Verify token against device 231 | 4. `update_user_identity_field()` - Sync confirmed device identity to user model 232 | 233 | ### Deferred Identity Update Pattern 234 | 235 | When `DEFER_IDENTITY_UPDATE=True`, email/phone fields are deferred until device confirmation: 236 | 237 | **Implementation** (`df_auth/drf/serializers.py:405-452`): 238 | 1. `UserIdentitySerializer.create()` checks if field is NOT in `USER_IDENTITY_FIELDS` 239 | 2. If deferred, field is popped from `validated_data` and stored separately 240 | 3. User created without email/phone fields (they remain blank/null) 241 | 4. OTP device created with the deferred email/phone value 242 | 5. When device confirmed, `OTPDeviceConfirmSerializer.update()` (line 313-319) updates user fields 243 | 244 | **Use Case**: Username-only identity with email verification 245 | - User registers with username (unique) + email (not unique) 246 | - Multiple users can register with same unconfirmed email 247 | - Only when email is verified via OTP is it assigned to user 248 | - Prevents spam registrations and ensures email ownership 249 | 250 | **Configuration**: 251 | ```python 252 | DF_AUTH = { 253 | 'USER_IDENTITY_FIELDS': {'username': '...'}, # Only username is unique 254 | 'USER_OPTIONAL_FIELDS': {'email': '...'}, # Email is optional 255 | 'DEFER_IDENTITY_UPDATE': True, # Don't save email until confirmed 256 | 'OTP_IDENTITY_UPDATE_FIELD': True, # Update from confirmed device 257 | } 258 | ``` 259 | 260 | ### Auto-Send Identity Verification OTP Pattern 261 | 262 | When `AUTO_SEND_IDENTITY_VERIFICATION_OTP=True`, OTPs are automatically sent during registration: 263 | 264 | **Implementation** (`df_auth/drf/serializers.py:454-460`): 265 | 1. `UserIdentitySerializer.create()` creates user and devices as normal 266 | 2. After device creation, checks if `AUTO_SEND_IDENTITY_VERIFICATION_OTP=True` 267 | 3. If enabled, calls `device.generate_challenge()` for each created device 268 | 4. Email/SMS sent immediately with verification OTP 269 | 270 | **Use Cases**: 271 | - **Immediate verification**: Users receive OTP right after registration 272 | - **Seamless onboarding**: No need for separate OTP request step 273 | - **Combines with DEFER_IDENTITY_UPDATE**: Auto-send OTP for deferred fields 274 | 275 | **Example Flow**: 276 | ```python 277 | # Configuration 278 | DF_AUTH = { 279 | 'AUTO_SEND_IDENTITY_VERIFICATION_OTP': True, 280 | } 281 | 282 | # User registers 283 | POST /auth/users/ { email: "john@example.com", password: "..." } 284 | 285 | # Behind the scenes: 286 | # 1. User created 287 | # 2. EmailDevice created (confirmed=False) 288 | # 3. device.generate_challenge() called automatically 289 | # 4. Email sent with OTP token 290 | 291 | # User confirms 292 | POST /auth/otp-devices/{id}/confirm/ { otp: "123456" } 293 | # Device marked confirmed=True 294 | ``` 295 | 296 | **Combined with Deferred Identity**: 297 | ```python 298 | DF_AUTH = { 299 | 'USER_IDENTITY_FIELDS': {'username': '...'}, 300 | 'DEFER_IDENTITY_UPDATE': True, # Email not on user model 301 | 'AUTO_SEND_IDENTITY_VERIFICATION_OTP': True, # But OTP still auto-sent 302 | 'OTP_IDENTITY_UPDATE_FIELD': True, # Update on confirmation 303 | } 304 | 305 | # Flow: 306 | # 1. User registers: username="john", email in request 307 | # 2. User created: email="" (deferred) 308 | # 3. Device created with email, OTP auto-sent 309 | # 4. User confirms OTP 310 | # 5. User's email field updated from device 311 | ``` 312 | 313 | ### Validation-Only ViewSets 314 | 315 | `ValidationOnlyCreateViewSet` pattern returns 200 OK instead of 201 Created: 316 | - Used for `TokenViewSet` and `OTPViewSet` 317 | - Semantically correct for operations that validate credentials vs creating resources 318 | 319 | ## Package Dependencies 320 | 321 | Core dependencies (see `pyproject.toml`): 322 | - Django >4 (tested with 4.2) 323 | - djangorestframework >=3 324 | - djangorestframework-simplejwt (JWT tokens) 325 | - django-otp, django-otp-twilio (OTP/2FA) 326 | - social-auth-app-django (social login) 327 | - django-phonenumber-field[phonenumbers] (phone validation) 328 | - django-df-remote-config (remote configuration) 329 | - django-df-api-drf (DRF utilities) 330 | 331 | ## Configuration Examples 332 | 333 | ### Minimal Setup 334 | 335 | ```python 336 | # settings.py 337 | INSTALLED_APPS = [ 338 | # ... Django apps 339 | *DF_AUTH_INSTALLED_APPS, # from df_auth.defaults 340 | ] 341 | 342 | AUTHENTICATION_BACKENDS = [ 343 | 'df_auth.backends.EmailOTPBackend', 344 | 'django.contrib.auth.backends.ModelBackend', 345 | ] 346 | 347 | # urls.py 348 | urlpatterns = [ 349 | path('api/v1/auth/', include('df_auth.drf.urls')), 350 | ] 351 | ``` 352 | 353 | ### Custom Configuration 354 | 355 | ```python 356 | # Restrict to username-only identity, allow duplicate emails 357 | DF_AUTH = { 358 | 'USER_IDENTITY_FIELDS': { 359 | 'username': 'rest_framework.serializers.CharField', 360 | }, 361 | 'USER_OPTIONAL_FIELDS': { 362 | 'email': 'rest_framework.serializers.CharField', 363 | 'password': 'rest_framework.serializers.CharField', 364 | }, 365 | 'OTP_AUTO_CREATE_ACCOUNT': False, # Require manual registration 366 | 'SIGNUP_ALLOWED': False, # Invite-only 367 | } 368 | ``` 369 | 370 | ## Common Development Tasks 371 | 372 | ### Adding a New OTP Device Type 373 | 374 | 1. Create Django OTP device model or use existing 375 | 2. Add to `DF_AUTH['OTP_DEVICE_MODELS']` mapping 376 | 3. If needed, create backend subclassing `BaseOTPBackend` 377 | 4. Set `identity_field`, `device_identity_field`, `DeviceModel` 378 | 5. Override `send_challenge()` if custom delivery needed 379 | 6. Add to `AUTHENTICATION_BACKENDS` 380 | 381 | ### Customizing User Registration 382 | 383 | Override `UserIdentitySerializer.create()` or extend the serializer: 384 | - Add custom validation in `validate_()` methods 385 | - Modify `USER_OPTIONAL_FIELDS` to add custom fields 386 | - Hook into `UserRegistration` model for invitation tracking 387 | 388 | ### Testing Configuration Changes 389 | 390 | Use the override pattern in test setUp/tearDown: 391 | 392 | ```python 393 | def setUp(self): 394 | api_settings.SETTING_NAME = new_value 395 | 396 | def tearDown(self): 397 | api_settings.SETTING_NAME = DEFAULTS["SETTING_NAME"] 398 | ``` 399 | 400 | ## Migration Notes 401 | 402 | The package includes migrations for `User2FA` and `UserRegistration` models. Custom user models in consuming projects need their own migrations for OTP device relationships. 403 | -------------------------------------------------------------------------------- /df_auth/drf/serializers.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | from django.conf import settings 4 | from django.contrib.auth import authenticate, get_user_model 5 | from django.contrib.auth.base_user import AbstractBaseUser 6 | from django.contrib.auth.models import update_last_login 7 | from django.db.models import Model 8 | from django.utils.module_loading import import_string 9 | from django_otp.models import Device 10 | from django_otp.plugins.otp_email.models import EmailDevice 11 | from django_otp.plugins.otp_totp.models import TOTPDevice 12 | from otp_twilio.models import TwilioSMSDevice 13 | from rest_framework import exceptions, serializers 14 | from rest_framework_simplejwt.settings import ( 15 | api_settings as simplejwt_settings, 16 | ) 17 | from social_core.exceptions import AuthCanceled, AuthForbidden 18 | from social_django.models import DjangoStorage 19 | from social_django.utils import load_backend 20 | 21 | from ..exceptions import Authentication2FARequiredError 22 | from ..models import User2FA, UserRegistration 23 | from ..settings import api_settings 24 | from ..strategy import DRFStrategy 25 | from ..utils import ( 26 | get_otp_device_choices, 27 | get_otp_device_models, 28 | get_otp_devices, 29 | ) 30 | 31 | User = get_user_model() 32 | 33 | AUTHENTICATION_BACKENDS = [ 34 | import_string(backend) for backend in settings.AUTHENTICATION_BACKENDS 35 | ] 36 | 37 | 38 | def build_fields(fields: Dict[str, str], **kwargs: Any) -> Dict[str, serializers.Field]: 39 | return {name: import_string(klass)(**kwargs) for name, klass in fields.items()} 40 | 41 | 42 | def check_user_2fa(user: Optional[AbstractBaseUser], otp: Optional[str]) -> None: 43 | if user and hasattr(user, "user2fa") and user.user2fa.is_required: 44 | devices = [d for d in get_otp_devices(user) if d.confirmed] 45 | 46 | if not any(d.verify_token(otp) for d in devices): 47 | raise Authentication2FARequiredError( 48 | extra_data={"devices": OTPDeviceSerializer(devices, many=True).data} 49 | ) 50 | 51 | 52 | class EmptySerializer(serializers.Serializer): 53 | pass 54 | 55 | 56 | class TokenSerializer(serializers.Serializer): 57 | token = serializers.CharField(read_only=True) 58 | token_class = simplejwt_settings.AUTH_TOKEN_CLASSES[0] 59 | user = None 60 | 61 | 62 | class TokenCreateSerializer(TokenSerializer): 63 | @classmethod 64 | def get_token(cls, user: AbstractBaseUser) -> None: 65 | return cls.token_class.for_user(user) 66 | 67 | def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: 68 | if not simplejwt_settings.USER_AUTHENTICATION_RULE(self.user): 69 | raise exceptions.AuthenticationFailed() 70 | 71 | token = self.get_token(self.user) # type: ignore 72 | 73 | attrs["token"] = str(token) 74 | 75 | if simplejwt_settings.UPDATE_LAST_LOGIN: 76 | update_last_login(None, self.user) # type: ignore 77 | 78 | return attrs 79 | 80 | 81 | class TokenObtainSerializer(TokenCreateSerializer): 82 | def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: 83 | """ 84 | Remove empty values to pass to authenticate or send_otp 85 | """ 86 | attrs = {k: v for k, v in attrs.items() if v} 87 | self.user = authenticate(**attrs, **self.context) # type: ignore 88 | check_user_2fa(self.user, attrs.get("otp")) 89 | 90 | return super().validate(attrs) 91 | 92 | def get_fields(self) -> Dict[str, serializers.Field]: 93 | fields = super().get_fields() 94 | fields.update( 95 | **build_fields( 96 | api_settings.REQUIRED_AUTH_FIELDS, 97 | write_only=True, 98 | required=True, 99 | allow_blank=False, 100 | ), 101 | **build_fields( 102 | { 103 | **api_settings.OPTIONAL_AUTH_FIELDS, 104 | **api_settings.USER_IDENTITY_FIELDS, 105 | }, 106 | write_only=True, 107 | required=False, 108 | allow_blank=True, 109 | ), 110 | ) 111 | 112 | return fields 113 | 114 | 115 | class AuthBackendSerializer(serializers.Serializer): 116 | backend_method_name: Optional[str] = None 117 | backend_extra_kwargs: Dict[str, Any] = {} 118 | 119 | def get_fields(self) -> Dict[str, serializers.Field]: 120 | return { 121 | **super().get_fields(), 122 | **build_fields( 123 | { 124 | **api_settings.USER_IDENTITY_FIELDS, 125 | **api_settings.REQUIRED_AUTH_FIELDS, 126 | **api_settings.OPTIONAL_AUTH_FIELDS, 127 | }, 128 | required=False, 129 | allow_blank=True, 130 | ), 131 | } 132 | 133 | def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: 134 | attrs = {k: v for k, v in attrs.items() if v} 135 | attrs = super().validate(attrs) 136 | for backend in AUTHENTICATION_BACKENDS: 137 | if self.backend_method_name and hasattr(backend, self.backend_method_name): 138 | self.user = getattr(backend(), self.backend_method_name)( 139 | **attrs, **self.backend_extra_kwargs, **self.context 140 | ) 141 | if self.user: 142 | return attrs 143 | raise exceptions.AuthenticationFailed( 144 | "Authorization backend not found", code="not_found" 145 | ) 146 | 147 | 148 | class OTPObtainSerializer(AuthBackendSerializer): 149 | backend_method_name = "generate_challenge" 150 | 151 | def get_fields(self) -> Dict[str, serializers.Field]: 152 | return { 153 | **super().get_fields(), 154 | "redirect_path": serializers.CharField(required=False, write_only=True), 155 | } 156 | 157 | def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: 158 | """ 159 | - check user auth: 160 | - if request.user authorized else 161 | - if user = authenticate(**attrs, **self.context) 162 | - else not authorized 163 | - if not authorized + OTP_SEND_UNAUTHORIZED_USER=False -> raise an error 164 | - send otp for the device 165 | """ 166 | attrs = {k: v for k, v in attrs.items() if v} 167 | 168 | # check user auth 169 | if self.context["request"].user.is_authenticated: 170 | user = self.context["request"].user 171 | else: 172 | user = authenticate(**attrs, **self.context) 173 | 174 | # if not authorized + OTP_SEND_UNAUTHORIZED_USER=False -> raise an error 175 | if not user and not api_settings.OTP_SEND_UNAUTHORIZED_USER: 176 | raise exceptions.AuthenticationFailed( 177 | "Please log in to request your OTP code.", 178 | code="unauthorized_otp_request", 179 | ) 180 | self.context["user"] = user 181 | 182 | # retrieve device + generate challenge 183 | return super().validate(attrs) 184 | 185 | 186 | class SocialTokenObtainSerializer(TokenCreateSerializer): 187 | access_token = serializers.CharField(write_only=True) 188 | provider = serializers.ChoiceField( 189 | choices=[ 190 | (backend.name, backend.name) 191 | for backend in AUTHENTICATION_BACKENDS 192 | if hasattr(backend, "name") 193 | ], 194 | ) 195 | 196 | response = serializers.JSONField(read_only=True) 197 | 198 | def get_fields(self) -> Dict[str, serializers.Field]: 199 | return { 200 | **super().get_fields(), 201 | **build_fields( 202 | { 203 | **api_settings.USER_SOCIAL_AUTH_FIELDS, 204 | **api_settings.OPTIONAL_AUTH_FIELDS, 205 | }, 206 | write_only=True, 207 | required=False, 208 | allow_blank=True, 209 | ), 210 | } 211 | 212 | def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: 213 | request = self.context["request"] 214 | user = request.user if request.user.is_authenticated else None 215 | request.social_strategy = DRFStrategy(DjangoStorage, request) 216 | request.backend = load_backend( 217 | request.social_strategy, attrs["provider"], redirect_uri=None 218 | ) 219 | 220 | try: 221 | self.user = request.backend.do_auth(attrs["access_token"], user=user) 222 | except (AuthCanceled, AuthForbidden): 223 | raise exceptions.AuthenticationFailed() 224 | 225 | update_fields = [] 226 | for attr in api_settings.USER_SOCIAL_AUTH_FIELDS: 227 | if not getattr(self.user, attr, None): 228 | value = attrs.get(attr, None) 229 | if value: 230 | setattr(self.user, attr, value) 231 | update_fields.append(attr) 232 | if update_fields: 233 | self.user.save(update_fields=update_fields) 234 | 235 | check_user_2fa(self.user, attrs.get("otp")) 236 | return super().validate(attrs) 237 | 238 | 239 | class OTPDeviceTypeField(serializers.ChoiceField): 240 | def __init__(self, **kwargs: Any) -> None: 241 | kwargs["source"] = "*" 242 | super().__init__(**kwargs) 243 | 244 | def to_representation(self, value: Model) -> Optional[str]: 245 | for type_, model in get_otp_device_models().items(): 246 | if isinstance(value, model): 247 | return type_ 248 | return None 249 | 250 | def to_internal_value(self, data: Any) -> Dict[str, Any]: 251 | return {self.field_name: data} 252 | 253 | 254 | class OTPDeviceSerializer(serializers.Serializer): 255 | id = serializers.IntegerField(read_only=True) 256 | name = serializers.CharField(required=False) 257 | type = OTPDeviceTypeField(choices=get_otp_device_choices(), source="*") 258 | confirmed = serializers.BooleanField(read_only=True) 259 | extra_data = serializers.SerializerMethodField() 260 | 261 | def get_extra_data(self, obj: Device) -> Dict[str, str]: 262 | # We need `url` field for TOTP devices on `create` action 263 | if ( 264 | isinstance(obj, TOTPDevice) 265 | and "view" in self.context 266 | and self.context["view"].action == "create" 267 | ): 268 | return { 269 | "url": obj.config_url, 270 | } 271 | 272 | return {} 273 | 274 | def create(self, validated_data: Dict[str, Any]) -> Device: 275 | device_type = validated_data.pop("type") 276 | DeviceModel = get_otp_device_models()[device_type] 277 | 278 | data = { 279 | "user": self.context["request"].user, 280 | } 281 | 282 | # TODO: create common interface to 283 | # - check if user already this device 284 | # - add additional fields 285 | # - validate if we can create device with given name (email/phone) 286 | if device_type == "sms": 287 | data["number"] = validated_data["name"] 288 | if device_type == "email": 289 | data["email"] = validated_data["name"] 290 | 291 | device, _ = DeviceModel.objects.get_or_create( 292 | **data, 293 | defaults={ 294 | "confirmed": False, 295 | **validated_data, 296 | }, 297 | ) 298 | return device 299 | 300 | 301 | class OTPDeviceConfirmSerializer(serializers.Serializer): 302 | otp = serializers.CharField(required=True, write_only=True) 303 | 304 | def validate_otp(self, value: str) -> str: 305 | if not self.instance.verify_token(value): 306 | raise serializers.ValidationError("Invalid OTP code") 307 | return value 308 | 309 | def update(self, instance: Device, validated_data: Dict[str, Any]) -> Device: 310 | instance.confirmed = True 311 | instance.save() 312 | 313 | if api_settings.OTP_IDENTITY_UPDATE_FIELD: 314 | # TODO: create a common interface for this 315 | if isinstance(instance, EmailDevice): 316 | instance.user.email = instance.name 317 | elif isinstance(instance, TwilioSMSDevice): 318 | instance.user.phone_number = instance.number 319 | instance.user.save() 320 | 321 | return instance 322 | 323 | 324 | class UserIdentitySerializer(serializers.Serializer): 325 | def get_fields(self) -> Dict[str, serializers.Field]: 326 | fields = build_fields( 327 | { 328 | **api_settings.USER_OPTIONAL_FIELDS, 329 | **api_settings.USER_IDENTITY_FIELDS, 330 | "id": "rest_framework.serializers.CharField", 331 | }, 332 | required=False, 333 | allow_blank=True, 334 | ) 335 | 336 | fields["id"].read_only = True 337 | if "password" in fields: 338 | fields["password"].write_only = True 339 | 340 | return fields 341 | 342 | def validate_email(self, value: Optional[str]) -> Optional[str]: 343 | if not value: 344 | return None 345 | # TODO: check for black list 346 | 347 | # If User has no confirmed EmailDevice on update 348 | if ( 349 | self.instance 350 | and not self.instance.emaildevice_set.filter( 351 | email=value, confirmed=True 352 | ).exists() 353 | ): 354 | raise serializers.ValidationError("You need to confirm your email first.") 355 | 356 | if ( 357 | self.instance is None 358 | and "email" in api_settings.USER_IDENTITY_FIELDS 359 | and value is not None 360 | and User.objects.filter(email=value).exists() 361 | ): 362 | raise serializers.ValidationError("User with this email already exists.") 363 | 364 | return User.objects.normalize_email(value) 365 | 366 | def validate_username(self, value: Optional[str]) -> Optional[str]: 367 | if not value: 368 | return None 369 | # TODO: check for black list 370 | return value 371 | 372 | def validate_phone_number(self, value: Optional[str]) -> Optional[str]: 373 | if not value: 374 | return None 375 | 376 | # TODO: check for black list 377 | if ( 378 | self.instance 379 | and not self.instance.twiliosmsdevice_set.filter( 380 | number=value, confirmed=True 381 | ).exists() 382 | ): 383 | raise serializers.ValidationError( 384 | "You need to confirm your phone number first." 385 | ) 386 | 387 | if ( 388 | self.instance is None 389 | and "phone_number" in api_settings.USER_IDENTITY_FIELDS 390 | and value is not None 391 | and User.objects.filter(phone_number=value).exists() 392 | ): 393 | raise serializers.ValidationError( 394 | "User with this phone number already exists." 395 | ) 396 | 397 | return value 398 | 399 | def update(self, instance: User, validated_data: Dict[str, Any]) -> User: 400 | for attr, value in validated_data.items(): 401 | setattr(instance, attr, value) 402 | instance.save() 403 | return instance 404 | 405 | def create(self, validated_data: Any) -> User: 406 | # Extract email/phone for device creation if DEFER_IDENTITY_UPDATE is enabled 407 | # and these fields are not identity fields 408 | deferred_fields = {} 409 | 410 | if api_settings.DEFER_IDENTITY_UPDATE: 411 | if "email" not in api_settings.USER_IDENTITY_FIELDS and "email" in validated_data: 412 | deferred_fields["email"] = validated_data.pop("email") 413 | if "phone_number" not in api_settings.USER_IDENTITY_FIELDS and "phone_number" in validated_data: 414 | deferred_fields["phone_number"] = validated_data.pop("phone_number") 415 | 416 | if not validated_data.get("username") and getattr(User, "username", False): 417 | for field in api_settings.USER_IDENTITY_FIELDS: 418 | if validated_data.get(field): 419 | validated_data["username"] = validated_data[field] 420 | break 421 | 422 | user = User(**validated_data) 423 | if validated_data.get("password"): 424 | user.set_password(validated_data["password"]) 425 | user.save() 426 | 427 | request_user = self.context["request"].user 428 | UserRegistration.objects.create( 429 | user=user, 430 | invited_by=request_user if request_user.is_authenticated else None, 431 | ) 432 | 433 | # Create devices with deferred fields or from user fields 434 | email_for_device = deferred_fields.get("email") or (user.email if getattr(User, "email", False) else None) # type: ignore 435 | email_device = None 436 | if email_for_device: 437 | email_device = EmailDevice.objects.create( 438 | user=user, 439 | email=email_for_device, 440 | confirmed=False, 441 | name=email_for_device 442 | ) 443 | 444 | phone_for_device = deferred_fields.get("phone_number") or (user.phone_number if getattr(User, "phone_number", False) else None) # type: ignore 445 | phone_device = None 446 | if phone_for_device: 447 | phone_device = TwilioSMSDevice.objects.create( 448 | user=user, 449 | number=phone_for_device, 450 | confirmed=False, 451 | name=phone_for_device, 452 | ) 453 | 454 | # Auto-send identity verification OTPs if enabled 455 | if api_settings.AUTO_SEND_IDENTITY_VERIFICATION_OTP: 456 | request = self.context.get("request") 457 | if email_device and request: 458 | email_device.generate_challenge() 459 | if phone_device and request: 460 | phone_device.generate_challenge() 461 | 462 | return user 463 | 464 | 465 | class ChangePasswordSerializer(serializers.Serializer): 466 | old_password = serializers.CharField(required=True, write_only=True) 467 | new_password = serializers.CharField(required=True, write_only=True) 468 | 469 | def validate_old_password(self, value: str) -> str: 470 | if not self.instance.check_password(value): 471 | raise serializers.ValidationError("Old password is incorrect.") 472 | return value 473 | 474 | def update(self, instance: User, validated_data: Dict[str, Any]) -> User: 475 | instance.set_password(validated_data["new_password"]) 476 | instance.save() 477 | return instance 478 | 479 | 480 | class User2FASerializer(serializers.ModelSerializer): 481 | class Meta: 482 | model = User2FA 483 | fields = ["is_required"] 484 | -------------------------------------------------------------------------------- /tests/test_app/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import httpretty 4 | import pytest 5 | from django.db import IntegrityError 6 | from django_otp.plugins.otp_email.models import EmailDevice 7 | from django_otp.plugins.otp_totp.models import TOTPDevice 8 | from otp_twilio.models import TwilioSMSDevice 9 | from rest_framework import status 10 | from rest_framework.test import APIClient, APITestCase 11 | 12 | from df_auth.exceptions import UserDoesNotExistError 13 | from df_auth.models import User2FA 14 | from df_auth.settings import DEFAULTS, api_settings 15 | from tests.test_app.models import User 16 | 17 | pytestmark = pytest.mark.django_db 18 | 19 | 20 | class OtpDeviceViewSetAPITest(APITestCase): 21 | def setUp(self) -> None: 22 | # Create a test user and set up any other objects you need 23 | self.user = User.objects.create_user(username="testuser", password="testpass") 24 | self.client = APIClient() 25 | self.client.force_authenticate(user=self.user) 26 | self.email = "test@te.st" 27 | self.phone_number = "+1234567890" 28 | 29 | def test_create_email_device(self) -> None: 30 | # Define the URL and the payload 31 | 32 | # Make the API request to create a new email Device 33 | response = self.client.post( 34 | "/api/v1/auth/otp-devices/", 35 | { 36 | "type": "email", 37 | "name": self.email, 38 | }, 39 | ) 40 | 41 | # Check that the response indicates success 42 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 43 | self.assertEqual(response.data["name"], self.email) 44 | 45 | # Check that a Device object was actually created 46 | device = EmailDevice.objects.filter( 47 | user=self.user, 48 | name=self.email, 49 | ).first() 50 | self.assertIsNotNone(device) 51 | # Check that it's not verified 52 | self.assertFalse(device.confirmed) 53 | self.assertEqual(device.email, self.email) 54 | 55 | def test_create_totp_device(self) -> None: 56 | response = self.client.post( 57 | "/api/v1/auth/otp-devices/", 58 | { 59 | "type": "totp", 60 | "name": "totp", 61 | }, 62 | ) 63 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 64 | # Check that a key was returned 65 | device = TOTPDevice.objects.filter(user=self.user).first() 66 | self.assertIsNotNone(device) 67 | self.assertEqual(response.data["type"], "totp") 68 | self.assertIn("url", response.data["extra_data"]) 69 | self.assertEqual(device.config_url, response.data["extra_data"]["url"]) 70 | 71 | # Check we will not return the URL again 72 | response = self.client.get("/api/v1/auth/otp-devices/") 73 | self.assertEqual(response.status_code, status.HTTP_200_OK) 74 | self.assertEqual(len(response.data["results"]), 1) 75 | self.assertEqual(response.data["results"][0]["type"], "totp") 76 | self.assertNotIn("url", response.data["results"][0]["extra_data"]) 77 | 78 | def test_confirm_email_device(self) -> None: 79 | email_device = EmailDevice.objects.create( 80 | user=self.user, 81 | name=self.email, 82 | email=self.email, 83 | confirmed=False, 84 | ) 85 | self.assertIsNone(email_device.token) 86 | 87 | response = self.client.post( 88 | "/api/v1/auth/otp/", 89 | { 90 | "email": email_device.email, 91 | }, 92 | ) 93 | self.assertEqual(response.status_code, status.HTTP_200_OK) 94 | email_device.refresh_from_db() 95 | self.assertIsNotNone(email_device.token) 96 | 97 | response = self.client.post( 98 | f"/api/v1/auth/otp-devices/{email_device.pk}/confirm/?type=email", 99 | {"otp": "wrong-token"}, 100 | ) 101 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 102 | email_device.refresh_from_db() 103 | self.assertFalse(email_device.confirmed) 104 | email_device.throttle_reset(commit=True) 105 | 106 | self.user.refresh_from_db() 107 | self.assertIsNone(self.user.email) 108 | response = self.client.post( 109 | f"/api/v1/auth/otp-devices/{email_device.pk}/confirm/?type=email", 110 | {"otp": email_device.token}, 111 | ) 112 | self.assertEqual(response.status_code, status.HTTP_200_OK) 113 | email_device.refresh_from_db() 114 | self.assertTrue(email_device.confirmed) 115 | self.user.refresh_from_db() 116 | self.assertEqual(self.user.email, self.email) 117 | 118 | def test_destroy_email_device(self) -> None: 119 | email_device = EmailDevice.objects.create( 120 | user=self.user, 121 | name=self.email, 122 | confirmed=True, 123 | ) 124 | 125 | response = self.client.get("/api/v1/auth/otp-devices/") 126 | self.assertEqual(response.status_code, status.HTTP_200_OK) 127 | self.assertEqual(len(response.data["results"]), 1) 128 | self.assertEqual(response.data["results"][0]["name"], self.email) 129 | self.assertEqual(response.data["results"][0]["type"], "email") 130 | 131 | response = self.client.delete( 132 | f"/api/v1/auth/otp-devices/{email_device.pk}/?type=email" 133 | ) 134 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 135 | 136 | response = self.client.get("/api/v1/auth/otp-devices/") 137 | self.assertEqual(response.status_code, status.HTTP_200_OK) 138 | self.assertEqual(len(response.data["results"]), 0) 139 | 140 | def test_list_device_with_different_types(self) -> None: 141 | EmailDevice.objects.create( 142 | user=self.user, 143 | name=self.email, 144 | ) 145 | TwilioSMSDevice.objects.create( 146 | user=self.user, 147 | name=self.phone_number, 148 | ) 149 | TOTPDevice.objects.create( 150 | user=self.user, 151 | name="default", 152 | ) 153 | response = self.client.get("/api/v1/auth/otp-devices/") 154 | self.assertEqual(response.status_code, status.HTTP_200_OK) 155 | self.assertEqual(len(response.data["results"]), 3) 156 | types = [device["type"] for device in response.data["results"]] 157 | self.assertIn("email", types) 158 | self.assertIn("sms", types) 159 | self.assertIn("totp", types) 160 | 161 | 162 | class UserViewSetAPITest(APITestCase): 163 | def setUp(self) -> None: 164 | # Create a test user and set up any other objects you need 165 | self.client = APIClient() 166 | self.email = "test@te.st" 167 | self.phone_number = "+31612345678" 168 | self.password = "passwd" 169 | 170 | def test_create_user_with_email_password(self) -> None: 171 | response = self.client.post( 172 | "/api/v1/auth/users/", 173 | { 174 | "email": self.email, 175 | "password": self.password, 176 | }, 177 | ) 178 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 179 | self.assertEqual(response.data["email"], self.email) 180 | self.assertNotIn("password", response.data) 181 | 182 | user = User.objects.get(email=self.email) 183 | self.assertTrue(user.check_password(self.password)) 184 | self.assertEqual(user.username, self.email) 185 | 186 | device = EmailDevice.objects.get(user=user, name=self.email) 187 | self.assertFalse(device.confirmed) 188 | 189 | def test_retrieve_user(self) -> None: 190 | user = User.objects.create_user( 191 | username=self.email, 192 | email=self.email, 193 | password=self.password, 194 | ) 195 | client = APIClient() 196 | client.force_authenticate(user=user) 197 | 198 | response = client.get( 199 | "/api/v1/auth/users/0/", 200 | ) 201 | self.assertEqual(response.status_code, status.HTTP_200_OK) 202 | self.assertEqual(response.data["email"], self.email) 203 | self.assertNotIn("password", response.data) 204 | 205 | def test_user_invites_user_by_email_phone(self) -> None: 206 | user_1 = User.objects.create_user(username="testuser", password="testpass") 207 | client = APIClient() 208 | client.force_authenticate(user=user_1) 209 | 210 | email_2 = "test2@te.st" 211 | phone_2 = "+31645427185" 212 | 213 | response = client.post( 214 | "/api/v1/auth/users/", 215 | { 216 | "phone_number": phone_2, 217 | "email": email_2, 218 | }, 219 | ) 220 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 221 | self.assertEqual(response.data["phone_number"], phone_2) 222 | 223 | user_2 = User.objects.get(phone_number=phone_2) 224 | self.assertEqual(user_2.userregistration.invited_by, user_1) 225 | phone_device = TwilioSMSDevice.objects.get(user=user_2, name=phone_2) 226 | self.assertFalse(phone_device.confirmed) 227 | email_device = EmailDevice.objects.get(user=user_2, name=email_2) 228 | self.assertFalse(email_device.confirmed) 229 | 230 | def test_user_nl_phone_strips_zero(self) -> None: 231 | response = self.client.post( 232 | "/api/v1/auth/users/", 233 | { 234 | "phone_number": "+310612345678", 235 | "email": "test2@te.st", 236 | }, 237 | ) 238 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 239 | self.assertEqual(response.data["phone_number"], "+31612345678") # 0 stripped 240 | 241 | def test_user_can_update_first_name(self) -> None: 242 | user = User.objects.create_user(username="testuser", password="testpass") 243 | client = APIClient() 244 | client.force_authenticate(user=user) 245 | 246 | response = client.patch( 247 | "/api/v1/auth/users/0/", 248 | { 249 | "first_name": "test", 250 | }, 251 | ) 252 | self.assertEqual(response.status_code, status.HTTP_200_OK) 253 | self.assertEqual(response.data["first_name"], "test") 254 | 255 | def test_user_cannot_update_phone_number_if_not_verified(self) -> None: 256 | user = User.objects.create_user(username="testuser", password="testpass") 257 | client = APIClient() 258 | client.force_authenticate(user=user) 259 | 260 | response = client.patch( 261 | "/api/v1/auth/users/0/", 262 | { 263 | "phone_number": self.phone_number, 264 | }, 265 | ) 266 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 267 | self.assertEqual(response.data["errors"][0]["code"], "invalid") 268 | 269 | def test_user_can_update_phone_number_if_verified(self) -> None: 270 | user = User.objects.create_user(username="testuser", password="testpass") 271 | client = APIClient() 272 | client.force_authenticate(user=user) 273 | 274 | TwilioSMSDevice.objects.create( 275 | user=user, 276 | name=self.phone_number, 277 | number=self.phone_number, 278 | confirmed=True, 279 | ) 280 | 281 | response = client.patch( 282 | "/api/v1/auth/users/0/", 283 | { 284 | "phone_number": self.phone_number, 285 | }, 286 | ) 287 | self.assertEqual(response.status_code, status.HTTP_200_OK) 288 | self.assertEqual(response.data["phone_number"], self.phone_number) 289 | 290 | def test_set_password_wrong_old_password_raises_error(self) -> None: 291 | user = User.objects.create_user(username="testuser", password="testpass") 292 | client = APIClient() 293 | client.force_authenticate(user=user) 294 | 295 | response = client.post( 296 | "/api/v1/auth/users/0/set-password/", 297 | { 298 | "old_password": "wrong", 299 | "new_password": "new", 300 | }, 301 | ) 302 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 303 | self.assertEqual(response.data["errors"][0]["code"], "invalid") 304 | 305 | def test_set_password_correct_old_password_sets_new_password(self) -> None: 306 | user = User.objects.create_user(username="testuser", password="testpass") 307 | client = APIClient() 308 | client.force_authenticate(user=user) 309 | 310 | response = client.post( 311 | "/api/v1/auth/users/0/set-password/", 312 | { 313 | "old_password": "testpass", 314 | "new_password": "new", 315 | }, 316 | ) 317 | self.assertEqual(response.status_code, status.HTTP_200_OK) 318 | self.assertTrue(user.check_password("new")) 319 | 320 | def test_user_cannot_signup_if_email_already_taken(self) -> None: 321 | User.objects.create_user( 322 | username="testuser", password="testpass", email=self.email 323 | ) 324 | response = self.client.post( 325 | "/api/v1/auth/users/", 326 | { 327 | "email": self.email, 328 | "password": "testpass", 329 | }, 330 | ) 331 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 332 | self.assertEqual(response.data["errors"][0]["code"], "invalid") 333 | 334 | def test_user_cannot_signup_if_phone_number_already_taken(self) -> None: 335 | User.objects.create_user( 336 | username="testuser", password="testpass", phone_number=self.phone_number 337 | ) 338 | response = self.client.post( 339 | "/api/v1/auth/users/", 340 | { 341 | "phone_number": self.phone_number, 342 | "password": "testpass", 343 | }, 344 | ) 345 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 346 | self.assertEqual(response.data["errors"][0]["code"], "invalid") 347 | 348 | 349 | class UserViewSetWithUsernameOnlyIdentityFieldAPITest(APITestCase): 350 | def setUp(self) -> None: 351 | self.client = APIClient() 352 | self.email = "test@te.st" 353 | self.phone_number = "+31612345678" 354 | api_settings.USER_IDENTITY_FIELDS = { 355 | "username": "rest_framework.serializers.CharField", 356 | } 357 | api_settings.USER_OPTIONAL_FIELDS = { 358 | "first_name": "rest_framework.serializers.CharField", 359 | "last_name": "rest_framework.serializers.CharField", 360 | "password": "rest_framework.serializers.CharField", 361 | "email": "rest_framework.serializers.CharField", 362 | "phone_number": "phonenumber_field.serializerfields.PhoneNumberField", 363 | } 364 | 365 | def tearDown(self) -> None: 366 | api_settings.USER_IDENTITY_FIELDS = DEFAULTS["USER_IDENTITY_FIELDS"] 367 | api_settings.USER_OPTIONAL_FIELDS = DEFAULTS["USER_OPTIONAL_FIELDS"] 368 | 369 | def test_user_can_signup_with_the_same_email(self) -> None: 370 | response = self.client.post( 371 | "/api/v1/auth/users/", 372 | { 373 | "username": "test1", 374 | "email": self.email, 375 | "password": "testpass", 376 | }, 377 | ) 378 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 379 | self.assertEqual(response.data["email"], self.email) 380 | self.assertEqual(response.data["username"], "test1") 381 | 382 | # Validation allows the same email to be used again 383 | # But we will get an IntegrityError from the database 384 | self.assertRaises( 385 | IntegrityError, 386 | lambda: self.client.post( 387 | "/api/v1/auth/users/", 388 | { 389 | "username": "test2", 390 | "email": self.email, 391 | "password": "testpass", 392 | }, 393 | ), 394 | ) 395 | 396 | def test_user_can_signup_with_the_same_phone_number(self) -> None: 397 | response = self.client.post( 398 | "/api/v1/auth/users/", 399 | { 400 | "username": "test1", 401 | "phone_number": self.phone_number, 402 | "password": "testpass", 403 | }, 404 | ) 405 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 406 | self.assertEqual(response.data["phone_number"], self.phone_number) 407 | self.assertEqual(response.data["username"], "test1") 408 | 409 | # Validation allows the same phone number to be used again 410 | # But we will get an IntegrityError from the database 411 | self.assertRaises( 412 | IntegrityError, 413 | lambda: self.client.post( 414 | "/api/v1/auth/users/", 415 | { 416 | "username": "test2", 417 | "phone_number": self.phone_number, 418 | "password": "testpass", 419 | }, 420 | ), 421 | ) 422 | 423 | 424 | class OtpViewSetAPITest(APITestCase): 425 | def setUp(self) -> None: 426 | self.client = APIClient() 427 | self.email = "test@te.st" 428 | 429 | def test_user_can_request_otp_with_registration(self) -> None: 430 | response = self.client.post( 431 | "/api/v1/auth/otp/", 432 | { 433 | "email": self.email, 434 | }, 435 | ) 436 | self.assertEqual(response.status_code, status.HTTP_200_OK) 437 | user = User.objects.get(email=self.email) 438 | device = EmailDevice.objects.get(user=user, name=self.email) 439 | self.assertTrue(device.confirmed) 440 | self.assertTrue(device.verify_token(device.token)) 441 | 442 | 443 | class OtpViewSetWithDisabledAnonOtpAPITest(APITestCase): 444 | def setUp(self) -> None: 445 | self.client = APIClient() 446 | self.email = "test@te.st" 447 | api_settings.OTP_SEND_UNAUTHORIZED_USER = False 448 | 449 | def tearDown(self) -> None: 450 | api_settings.OTP_SEND_UNAUTHORIZED_USER = True 451 | 452 | def test_unauthorized_user_cannot_request_otp(self) -> None: 453 | user = User.objects.create_user( 454 | username="testuser", password="testpass", email=self.email 455 | ) 456 | EmailDevice.objects.create( 457 | user=user, name=self.email, confirmed=True, email=self.email 458 | ) 459 | response = self.client.post( 460 | "/api/v1/auth/otp/", 461 | { 462 | "email": self.email, 463 | }, 464 | ) 465 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 466 | self.assertEqual(response.data["errors"][0]["code"], "unauthorized_otp_request") 467 | 468 | 469 | class OtpViewSetWithDisabledOtpAutoCreateAPITest(APITestCase): 470 | def setUp(self) -> None: 471 | self.client = APIClient() 472 | self.email = "test@te.st" 473 | api_settings.OTP_AUTO_CREATE_ACCOUNT = False 474 | 475 | def tearDown(self) -> None: 476 | api_settings.OTP_AUTO_CREATE_ACCOUNT = True 477 | 478 | def test_user_cannot_request_otp_without_registration(self) -> None: 479 | response = self.client.post( 480 | "/api/v1/auth/otp/", 481 | { 482 | "email": self.email, 483 | }, 484 | ) 485 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 486 | self.assertEqual( 487 | response.data["errors"][0]["code"], UserDoesNotExistError.default_code 488 | ) 489 | 490 | 491 | class TokenViewSetAPITest(APITestCase): 492 | def setUp(self) -> None: 493 | self.client = APIClient() 494 | self.user = User.objects.create_user( 495 | username="testuser", password="testpass", email="test@te.st" 496 | ) 497 | self.device = EmailDevice.objects.create( 498 | user=self.user, name=self.user.email, confirmed=True, email=self.user.email 499 | ) 500 | 501 | def test_obtain_token_by_email_and_otp(self) -> None: 502 | self.device.generate_challenge() 503 | response = self.client.post( 504 | "/api/v1/auth/token/", 505 | { 506 | "email": self.user.email, 507 | "otp": self.device.token, 508 | }, 509 | ) 510 | self.assertEqual(response.status_code, status.HTTP_200_OK) 511 | self.assertNotEqual(response.data.get("token", ""), "") 512 | 513 | def test_obtain_token_by_username_and_password(self) -> None: 514 | response = self.client.post( 515 | "/api/v1/auth/token/", 516 | { 517 | "username": self.user.username, 518 | "password": "testpass", 519 | }, 520 | ) 521 | self.assertEqual(response.status_code, status.HTTP_200_OK) 522 | self.assertNotEqual(response.data.get("token", ""), "") 523 | 524 | 525 | class TokenViewSet2FAAPITest(APITestCase): 526 | def setUp(self) -> None: 527 | self.client = APIClient() 528 | self.user = User.objects.create_user( 529 | username="testuser", 530 | password="testpass", 531 | email="test@te.st", 532 | ) 533 | User2FA.objects.create(user=self.user, is_required=True) 534 | self.device = EmailDevice.objects.create( 535 | user=self.user, name=self.user.email, confirmed=True, email=self.user.email 536 | ) 537 | 538 | def test_user_with_2fa_cannot_authorize_without_otp(self) -> None: 539 | response = self.client.post( 540 | "/api/v1/auth/token/", 541 | { 542 | "username": self.user.username, 543 | "password": "testpass", 544 | }, 545 | ) 546 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 547 | self.assertEqual(response.data["errors"][0]["code"], "2fa_required") 548 | self.assertIn("devices", response.data["errors"][0]["extra_data"]) 549 | devices = response.data["errors"][0]["extra_data"]["devices"] 550 | self.assertEqual(len(devices), 1) 551 | self.assertEqual(devices[0]["name"], self.device.name) 552 | self.assertEqual(devices[0]["type"], "email") 553 | 554 | def test_user_with_2fa_can_authorize_with_otp(self) -> None: 555 | self.device.generate_challenge() 556 | response = self.client.post( 557 | "/api/v1/auth/token/", 558 | { 559 | "username": self.user.username, 560 | "password": "testpass", 561 | "otp": self.device.token, 562 | }, 563 | ) 564 | self.assertEqual(response.status_code, status.HTTP_200_OK) 565 | self.assertNotEqual(response.data.get("token", ""), "") 566 | 567 | 568 | class SocialTokenViewSetAPITest(APITestCase): 569 | def setUp(self) -> None: 570 | self.client = APIClient() 571 | self.email = "test@te.st" 572 | self.first_name = "Test" 573 | self.last_name = "User" 574 | httpretty.enable(verbose=True, allow_net_connect=False) 575 | httpretty.register_uri( 576 | httpretty.GET, 577 | "https://www.googleapis.com/oauth2/v3/userinfo", 578 | body=json.dumps( 579 | { 580 | "email": self.email, 581 | "name": f"{self.first_name} {self.last_name}", 582 | } 583 | ), 584 | ) 585 | 586 | def tearDown(self) -> None: 587 | httpretty.disable() 588 | httpretty.reset() 589 | super().tearDown() 590 | 591 | def test_obtain_social_token_by_google_oauth2(self) -> None: 592 | User.objects.create_user( 593 | username="testuser", 594 | password="testpass", 595 | email=self.email, 596 | ) 597 | 598 | response = self.client.post( 599 | "/api/v1/auth/social/", 600 | { 601 | "provider": "google-oauth2", 602 | "access_token": "test", 603 | }, 604 | ) 605 | self.assertEqual(response.status_code, status.HTTP_200_OK) 606 | self.assertNotEqual(response.data.get("token", ""), "") 607 | 608 | def test_social_login_creates_new_user(self) -> None: 609 | response = self.client.post( 610 | "/api/v1/auth/social/", 611 | { 612 | "provider": "google-oauth2", 613 | "access_token": "test", 614 | }, 615 | ) 616 | self.assertEqual(response.status_code, status.HTTP_200_OK) 617 | self.assertNotEqual(response.data.get("token", ""), "") 618 | users = list(User.objects.all()) 619 | self.assertEqual(len(users), 1) 620 | user = users[0] 621 | self.assertEqual(user.email, self.email) # type: ignore 622 | self.assertEqual(user.first_name, self.first_name) 623 | self.assertEqual(user.last_name, self.last_name) 624 | 625 | def test_social_login_fails_if_2fa_enabled(self) -> None: 626 | user = User.objects.create_user( 627 | username="testuser", 628 | password="testpass", 629 | email=self.email, 630 | ) 631 | User2FA.objects.create(user=user, is_required=True) 632 | response = self.client.post( 633 | "/api/v1/auth/social/", 634 | { 635 | "provider": "google-oauth2", 636 | "access_token": "test", 637 | }, 638 | ) 639 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 640 | self.assertEqual(response.data["errors"][0]["code"], "2fa_required") 641 | self.assertIn("devices", response.data["errors"][0]["extra_data"]) 642 | 643 | def test_obtain_social_token_with_otp_for_2fa_user(self) -> None: 644 | user = User.objects.create_user( 645 | username="testuser", 646 | password="testpass", 647 | email=self.email, 648 | phone_number="+31612345678", 649 | ) 650 | User2FA.objects.create(user=user, is_required=True) 651 | device = TwilioSMSDevice.objects.create( 652 | user=user, name=user.phone_number, confirmed=True, number=user.phone_number 653 | ) 654 | device.generate_challenge() 655 | 656 | response = self.client.post( 657 | "/api/v1/auth/social/", 658 | { 659 | "provider": "google-oauth2", 660 | "access_token": "test", 661 | "otp": device.token, 662 | }, 663 | ) 664 | self.assertEqual(response.status_code, status.HTTP_200_OK) 665 | self.assertNotEqual(response.data.get("token", ""), "") 666 | 667 | 668 | class SocialTokenViewSetWithoutNameAPITest(APITestCase): 669 | def setUp(self) -> None: 670 | self.client = APIClient() 671 | self.email = "test@te.st" 672 | self.first_name = "Test" 673 | self.last_name = "User" 674 | httpretty.enable(verbose=True, allow_net_connect=False) 675 | httpretty.register_uri( 676 | httpretty.GET, 677 | "https://www.googleapis.com/oauth2/v3/userinfo", 678 | body=json.dumps( 679 | { 680 | "email": self.email, 681 | } 682 | ), 683 | ) 684 | 685 | def tearDown(self) -> None: 686 | httpretty.disable() 687 | httpretty.reset() 688 | super().tearDown() 689 | 690 | def test_social_login_accepts_first_last_names_from_body(self) -> None: 691 | response = self.client.post( 692 | "/api/v1/auth/social/", 693 | { 694 | "provider": "google-oauth2", 695 | "access_token": "test", 696 | "first_name": self.first_name, 697 | "last_name": self.last_name, 698 | }, 699 | ) 700 | self.assertEqual(response.status_code, status.HTTP_200_OK) 701 | self.assertNotEqual(response.data.get("token", ""), "") 702 | users = list(User.objects.all()) 703 | self.assertEqual(len(users), 1) 704 | user = users[0] 705 | self.assertEqual(user.email, self.email) # type: ignore 706 | self.assertEqual(user.first_name, self.first_name) 707 | self.assertEqual(user.last_name, self.last_name) 708 | 709 | 710 | class DisabledSignupAPITest(APITestCase): 711 | def setUp(self) -> None: 712 | self.client = APIClient() 713 | api_settings.SIGNUP_ALLOWED = False 714 | 715 | def tearDown(self) -> None: 716 | api_settings.SIGNUP_ALLOWED = True 717 | 718 | def test_signup_not_allowed(self) -> None: 719 | response = self.client.post( 720 | "/api/v1/auth/users/", 721 | { 722 | "password": "testpass", 723 | "email": "test@te.st", 724 | }, 725 | ) 726 | self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 727 | self.assertEqual(response.data["errors"][0]["code"], "not_authenticated") 728 | 729 | 730 | class DisabledInviteAPITest(APITestCase): 731 | def setUp(self) -> None: 732 | self.client = APIClient() 733 | self.user = User.objects.create_user( 734 | username="testuser", 735 | password="testpass", 736 | ) 737 | self.client.force_authenticate(self.user) 738 | api_settings.INVITE_ALLOWED = False 739 | 740 | def tearDown(self) -> None: 741 | api_settings.INVITE_ALLOWED = True 742 | 743 | def test_invite_not_allowed(self) -> None: 744 | response = self.client.post( 745 | "/api/v1/auth/users/", 746 | { 747 | "password": "testpass", 748 | "email": "test@te.st", 749 | }, 750 | ) 751 | self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) 752 | self.assertEqual(response.data["errors"][0]["code"], "permission_denied") 753 | 754 | 755 | class User2FAAPITest(APITestCase): 756 | def setUp(self) -> None: 757 | self.client = APIClient() 758 | self.user = User.objects.create_user( 759 | username="testuser", 760 | password="testpass", 761 | ) 762 | self.client.force_authenticate(self.user) 763 | 764 | def test_user_2fa_retrieve(self) -> None: 765 | response = self.client.get( 766 | "/api/v1/auth/users/0/two-fa/", 767 | ) 768 | 769 | self.assertEqual(response.status_code, status.HTTP_200_OK) 770 | self.assertFalse(response.data["is_required"]) 771 | 772 | def test_user_2fa_update(self) -> None: 773 | response = self.client.patch( 774 | "/api/v1/auth/users/0/two-fa/", 775 | { 776 | "is_required": True, 777 | }, 778 | ) 779 | 780 | self.assertEqual(response.status_code, status.HTTP_200_OK) 781 | self.assertTrue(response.data["is_required"]) 782 | 783 | 784 | class InactiveUserAPITest(APITestCase): 785 | def setUp(self) -> None: 786 | # Create a test user and set up any other objects you need 787 | self.email = "test@te.st" 788 | self.user = User.objects.create_user( 789 | username="testuser", password="testpass", is_active=False, email=self.email 790 | ) 791 | self.client = APIClient() 792 | 793 | def test_inactive_user_request_otp(self) -> None: 794 | email_device = EmailDevice.objects.create( 795 | user=self.user, 796 | name=self.email, 797 | email=self.email, 798 | confirmed=True, 799 | ) 800 | 801 | response = self.client.post( 802 | "/api/v1/auth/otp/", 803 | { 804 | "email": email_device.email, 805 | }, 806 | ) 807 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 808 | self.assertEqual(response.json()["errors"][0]["code"], "user_inactive") 809 | 810 | 811 | def test_create_superuser() -> None: 812 | User.objects.create_superuser( 813 | username="testuser", 814 | password="testpass", 815 | ) 816 | 817 | 818 | class DeferredIdentityUpdateAPITest(APITestCase): 819 | """Test DEFER_IDENTITY_UPDATE setting that defers email/phone assignment until OTP confirmation""" 820 | 821 | def setUp(self) -> None: 822 | self.client = APIClient() 823 | self.email = "test@te.st" 824 | self.phone_number = "+31612345678" 825 | self.password = "testpass" 826 | # Configure username-only identity with deferred updates 827 | api_settings.USER_IDENTITY_FIELDS = { 828 | "username": "rest_framework.serializers.CharField", 829 | } 830 | api_settings.USER_OPTIONAL_FIELDS = { 831 | "first_name": "rest_framework.serializers.CharField", 832 | "last_name": "rest_framework.serializers.CharField", 833 | "password": "rest_framework.serializers.CharField", 834 | "email": "rest_framework.serializers.CharField", 835 | "phone_number": "phonenumber_field.serializerfields.PhoneNumberField", 836 | } 837 | api_settings.DEFER_IDENTITY_UPDATE = True 838 | api_settings.OTP_IDENTITY_UPDATE_FIELD = True 839 | 840 | def tearDown(self) -> None: 841 | api_settings.USER_IDENTITY_FIELDS = DEFAULTS["USER_IDENTITY_FIELDS"] 842 | api_settings.USER_OPTIONAL_FIELDS = DEFAULTS["USER_OPTIONAL_FIELDS"] 843 | api_settings.DEFER_IDENTITY_UPDATE = DEFAULTS["DEFER_IDENTITY_UPDATE"] 844 | 845 | def test_user_created_without_email_when_deferred(self) -> None: 846 | """Email should NOT be saved to user model during registration""" 847 | response = self.client.post( 848 | "/api/v1/auth/users/", 849 | { 850 | "username": "john", 851 | "email": self.email, 852 | "password": self.password, 853 | }, 854 | ) 855 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 856 | self.assertEqual(response.data["username"], "john") 857 | # Email should NOT be in the response or user model 858 | self.assertIsNone(response.data.get("email")) 859 | 860 | user = User.objects.get(username="john") 861 | self.assertFalse(user.email) # Email is None or blank 862 | 863 | def test_email_device_created_with_deferred_email(self) -> None: 864 | """EmailDevice should be created with the email even though user.email is blank""" 865 | response = self.client.post( 866 | "/api/v1/auth/users/", 867 | { 868 | "username": "john", 869 | "email": self.email, 870 | "password": self.password, 871 | }, 872 | ) 873 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 874 | 875 | user = User.objects.get(username="john") 876 | self.assertFalse(user.email) # User email is None or blank 877 | 878 | # But device should exist with the email 879 | device = EmailDevice.objects.get(user=user) 880 | self.assertEqual(device.email, self.email) 881 | self.assertEqual(device.name, self.email) 882 | self.assertFalse(device.confirmed) 883 | 884 | def test_email_updated_after_otp_confirmation(self) -> None: 885 | """User's email should be updated from device when OTP is confirmed""" 886 | # Create user with deferred email 887 | self.client.post( 888 | "/api/v1/auth/users/", 889 | { 890 | "username": "john", 891 | "email": self.email, 892 | "password": self.password, 893 | }, 894 | ) 895 | 896 | user = User.objects.get(username="john") 897 | self.assertFalse(user.email) # Email is None or blank initially 898 | 899 | device = EmailDevice.objects.get(user=user) 900 | self.assertFalse(device.confirmed) 901 | 902 | # Authenticate and confirm device 903 | client = APIClient() 904 | client.force_authenticate(user=user) 905 | 906 | # Generate OTP token directly (simulating email being sent) 907 | device.generate_challenge() 908 | device.refresh_from_db() 909 | self.assertIsNotNone(device.token) 910 | 911 | # Confirm device with OTP 912 | response = client.post( 913 | f"/api/v1/auth/otp-devices/{device.pk}/confirm/?type=email", 914 | {"otp": device.token}, 915 | ) 916 | self.assertEqual(response.status_code, status.HTTP_200_OK) 917 | 918 | # Now user's email should be updated! 919 | user.refresh_from_db() 920 | self.assertEqual(user.email, self.email) 921 | device.refresh_from_db() 922 | self.assertTrue(device.confirmed) 923 | 924 | def test_phone_number_deferred_and_updated(self) -> None: 925 | """Phone number should be deferred and updated on confirmation""" 926 | # Create user with deferred phone 927 | response = self.client.post( 928 | "/api/v1/auth/users/", 929 | { 930 | "username": "john", 931 | "phone_number": self.phone_number, 932 | "password": self.password, 933 | }, 934 | ) 935 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 936 | 937 | user = User.objects.get(username="john") 938 | self.assertIsNone(user.phone_number) # Phone is None! 939 | 940 | # Device should exist with phone number 941 | device = TwilioSMSDevice.objects.get(user=user) 942 | self.assertEqual(str(device.number), self.phone_number) 943 | self.assertFalse(device.confirmed) 944 | 945 | # Authenticate and confirm device 946 | client = APIClient() 947 | client.force_authenticate(user=user) 948 | 949 | # Generate OTP token directly (simulating SMS being sent) 950 | device.generate_challenge() 951 | device.refresh_from_db() 952 | self.assertIsNotNone(device.token) 953 | 954 | # Confirm device 955 | response = client.post( 956 | f"/api/v1/auth/otp-devices/{device.pk}/confirm/?type=sms", 957 | {"otp": device.token}, 958 | ) 959 | self.assertEqual(response.status_code, status.HTTP_200_OK) 960 | 961 | # Phone should now be updated! 962 | user.refresh_from_db() 963 | self.assertEqual(str(user.phone_number), self.phone_number) 964 | 965 | def test_both_email_and_phone_deferred(self) -> None: 966 | """Both email and phone should be deferred when provided together""" 967 | response = self.client.post( 968 | "/api/v1/auth/users/", 969 | { 970 | "username": "john", 971 | "email": self.email, 972 | "phone_number": self.phone_number, 973 | "password": self.password, 974 | }, 975 | ) 976 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 977 | 978 | user = User.objects.get(username="john") 979 | self.assertFalse(user.email) # Email is None or blank 980 | self.assertIsNone(user.phone_number) 981 | 982 | # Both devices should exist 983 | email_device = EmailDevice.objects.get(user=user) 984 | self.assertEqual(email_device.email, self.email) 985 | self.assertFalse(email_device.confirmed) 986 | 987 | phone_device = TwilioSMSDevice.objects.get(user=user) 988 | self.assertEqual(str(phone_device.number), self.phone_number) 989 | self.assertFalse(phone_device.confirmed) 990 | 991 | def test_multiple_users_can_have_same_unconfirmed_email(self) -> None: 992 | """Multiple users should be able to register with same email when it's not an identity field""" 993 | # User 1 994 | response = self.client.post( 995 | "/api/v1/auth/users/", 996 | { 997 | "username": "john", 998 | "email": self.email, 999 | "password": self.password, 1000 | }, 1001 | ) 1002 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 1003 | 1004 | # User 2 with SAME email - should succeed! 1005 | response = self.client.post( 1006 | "/api/v1/auth/users/", 1007 | { 1008 | "username": "jane", 1009 | "email": self.email, # Same email! 1010 | "password": self.password, 1011 | }, 1012 | ) 1013 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 1014 | 1015 | # Both users should exist with blank emails 1016 | user1 = User.objects.get(username="john") 1017 | user2 = User.objects.get(username="jane") 1018 | self.assertFalse(user1.email) # Email is None or blank 1019 | self.assertFalse(user2.email) # Email is None or blank 1020 | 1021 | # Both should have unconfirmed devices with same email 1022 | device1 = EmailDevice.objects.get(user=user1) 1023 | device2 = EmailDevice.objects.get(user=user2) 1024 | self.assertEqual(device1.email, self.email) 1025 | self.assertEqual(device2.email, self.email) 1026 | 1027 | 1028 | class AutoSendIdentityVerificationOTPAPITest(APITestCase): 1029 | """Test AUTO_SEND_IDENTITY_VERIFICATION_OTP setting that auto-sends OTPs on registration""" 1030 | 1031 | def setUp(self) -> None: 1032 | self.client = APIClient() 1033 | self.email = "test@te.st" 1034 | self.phone_number = "+31612345678" 1035 | self.password = "testpass" 1036 | api_settings.AUTO_SEND_IDENTITY_VERIFICATION_OTP = True 1037 | 1038 | def tearDown(self) -> None: 1039 | api_settings.AUTO_SEND_IDENTITY_VERIFICATION_OTP = DEFAULTS[ 1040 | "AUTO_SEND_IDENTITY_VERIFICATION_OTP" 1041 | ] 1042 | 1043 | def test_otp_sent_to_email_on_registration(self) -> None: 1044 | """OTP should be automatically sent to email when user registers""" 1045 | response = self.client.post( 1046 | "/api/v1/auth/users/", 1047 | { 1048 | "email": self.email, 1049 | "password": self.password, 1050 | }, 1051 | ) 1052 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 1053 | 1054 | user = User.objects.get(email=self.email) 1055 | device = EmailDevice.objects.get(user=user, email=self.email) 1056 | 1057 | # Device should be created but not confirmed 1058 | self.assertFalse(device.confirmed) 1059 | # OTP token should have been generated 1060 | self.assertIsNotNone(device.token) 1061 | 1062 | def test_otp_sent_to_phone_on_registration(self) -> None: 1063 | """OTP should be automatically sent to phone when user registers""" 1064 | response = self.client.post( 1065 | "/api/v1/auth/users/", 1066 | { 1067 | "phone_number": self.phone_number, 1068 | "password": self.password, 1069 | }, 1070 | ) 1071 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 1072 | 1073 | user = User.objects.get(phone_number=self.phone_number) 1074 | device = TwilioSMSDevice.objects.get(user=user, number=self.phone_number) 1075 | 1076 | # Device should be created but not confirmed 1077 | self.assertFalse(device.confirmed) 1078 | # OTP token should have been generated 1079 | self.assertIsNotNone(device.token) 1080 | 1081 | def test_otp_sent_to_both_email_and_phone(self) -> None: 1082 | """OTPs should be sent to both email and phone when both provided""" 1083 | response = self.client.post( 1084 | "/api/v1/auth/users/", 1085 | { 1086 | "email": self.email, 1087 | "phone_number": self.phone_number, 1088 | "password": self.password, 1089 | }, 1090 | ) 1091 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 1092 | 1093 | user = User.objects.get(email=self.email) 1094 | 1095 | email_device = EmailDevice.objects.get(user=user, email=self.email) 1096 | phone_device = TwilioSMSDevice.objects.get(user=user, number=self.phone_number) 1097 | 1098 | # Both devices should have OTP tokens 1099 | self.assertIsNotNone(email_device.token) 1100 | self.assertIsNotNone(phone_device.token) 1101 | self.assertFalse(email_device.confirmed) 1102 | self.assertFalse(phone_device.confirmed) 1103 | 1104 | def test_user_can_confirm_with_auto_sent_otp(self) -> None: 1105 | """User should be able to confirm their email with the auto-sent OTP""" 1106 | response = self.client.post( 1107 | "/api/v1/auth/users/", 1108 | { 1109 | "email": self.email, 1110 | "password": self.password, 1111 | }, 1112 | ) 1113 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 1114 | 1115 | user = User.objects.get(email=self.email) 1116 | device = EmailDevice.objects.get(user=user, email=self.email) 1117 | 1118 | # OTP was auto-sent 1119 | self.assertIsNotNone(device.token) 1120 | otp_token = device.token 1121 | 1122 | # User authenticates and confirms device 1123 | client = APIClient() 1124 | client.force_authenticate(user=user) 1125 | 1126 | response = client.post( 1127 | f"/api/v1/auth/otp-devices/{device.pk}/confirm/?type=email", 1128 | {"otp": otp_token}, 1129 | ) 1130 | self.assertEqual(response.status_code, status.HTTP_200_OK) 1131 | 1132 | device.refresh_from_db() 1133 | self.assertTrue(device.confirmed) 1134 | 1135 | def test_no_otp_sent_when_setting_disabled(self) -> None: 1136 | """OTP should NOT be sent when AUTO_SEND_IDENTITY_VERIFICATION_OTP is False""" 1137 | api_settings.AUTO_SEND_IDENTITY_VERIFICATION_OTP = False 1138 | 1139 | response = self.client.post( 1140 | "/api/v1/auth/users/", 1141 | { 1142 | "email": self.email, 1143 | "password": self.password, 1144 | }, 1145 | ) 1146 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 1147 | 1148 | user = User.objects.get(email=self.email) 1149 | device = EmailDevice.objects.get(user=user, email=self.email) 1150 | 1151 | # Device created but no OTP sent 1152 | self.assertFalse(device.confirmed) 1153 | self.assertIsNone(device.token) 1154 | 1155 | 1156 | class AutoSendWithDeferredIdentityAPITest(APITestCase): 1157 | """Test AUTO_SEND_IDENTITY_VERIFICATION_OTP with DEFER_IDENTITY_UPDATE enabled""" 1158 | 1159 | def setUp(self) -> None: 1160 | self.client = APIClient() 1161 | self.email = "test@te.st" 1162 | self.phone_number = "+31612345678" 1163 | self.password = "testpass" 1164 | 1165 | # Configure username-only identity with both features enabled 1166 | api_settings.USER_IDENTITY_FIELDS = { 1167 | "username": "rest_framework.serializers.CharField", 1168 | } 1169 | api_settings.USER_OPTIONAL_FIELDS = { 1170 | "email": "rest_framework.serializers.CharField", 1171 | "phone_number": "phonenumber_field.serializerfields.PhoneNumberField", 1172 | "password": "rest_framework.serializers.CharField", 1173 | } 1174 | api_settings.DEFER_IDENTITY_UPDATE = True 1175 | api_settings.AUTO_SEND_IDENTITY_VERIFICATION_OTP = True 1176 | api_settings.OTP_IDENTITY_UPDATE_FIELD = True 1177 | 1178 | def tearDown(self) -> None: 1179 | api_settings.USER_IDENTITY_FIELDS = DEFAULTS["USER_IDENTITY_FIELDS"] 1180 | api_settings.USER_OPTIONAL_FIELDS = DEFAULTS["USER_OPTIONAL_FIELDS"] 1181 | api_settings.DEFER_IDENTITY_UPDATE = DEFAULTS["DEFER_IDENTITY_UPDATE"] 1182 | api_settings.AUTO_SEND_IDENTITY_VERIFICATION_OTP = DEFAULTS[ 1183 | "AUTO_SEND_IDENTITY_VERIFICATION_OTP" 1184 | ] 1185 | 1186 | def test_deferred_email_receives_auto_otp(self) -> None: 1187 | """Deferred email should still receive auto-sent OTP""" 1188 | response = self.client.post( 1189 | "/api/v1/auth/users/", 1190 | { 1191 | "username": "john", 1192 | "email": self.email, 1193 | "password": self.password, 1194 | }, 1195 | ) 1196 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 1197 | 1198 | user = User.objects.get(username="john") 1199 | self.assertFalse(user.email) # Email is deferred (not on user model) 1200 | 1201 | device = EmailDevice.objects.get(user=user) 1202 | self.assertEqual(device.email, self.email) 1203 | self.assertFalse(device.confirmed) 1204 | # OTP should have been auto-sent 1205 | self.assertIsNotNone(device.token) 1206 | 1207 | def test_confirm_deferred_email_updates_user(self) -> None: 1208 | """Confirming deferred email should update user's email field""" 1209 | response = self.client.post( 1210 | "/api/v1/auth/users/", 1211 | { 1212 | "username": "john", 1213 | "email": self.email, 1214 | "password": self.password, 1215 | }, 1216 | ) 1217 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 1218 | 1219 | user = User.objects.get(username="john") 1220 | device = EmailDevice.objects.get(user=user) 1221 | otp_token = device.token 1222 | 1223 | # Confirm device with auto-sent OTP 1224 | client = APIClient() 1225 | client.force_authenticate(user=user) 1226 | 1227 | response = client.post( 1228 | f"/api/v1/auth/otp-devices/{device.pk}/confirm/?type=email", 1229 | {"otp": otp_token}, 1230 | ) 1231 | self.assertEqual(response.status_code, status.HTTP_200_OK) 1232 | 1233 | # User's email should now be updated from the confirmed device 1234 | user.refresh_from_db() 1235 | self.assertEqual(user.email, self.email) 1236 | device.refresh_from_db() 1237 | self.assertTrue(device.confirmed) 1238 | --------------------------------------------------------------------------------