stapel-auth 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. stapel_auth/__init__.py +46 -0
  2. stapel_auth/admin/__init__.py +0 -0
  3. stapel_auth/admin/dto.py +46 -0
  4. stapel_auth/admin/serializers.py +60 -0
  5. stapel_auth/admin/views.py +129 -0
  6. stapel_auth/admin.py +73 -0
  7. stapel_auth/apps.py +52 -0
  8. stapel_auth/conf.py +196 -0
  9. stapel_auth/conftest.py +166 -0
  10. stapel_auth/dto.py +28 -0
  11. stapel_auth/errors.py +178 -0
  12. stapel_auth/events.py +35 -0
  13. stapel_auth/flows.py +167 -0
  14. stapel_auth/functions.py +53 -0
  15. stapel_auth/gdpr.py +160 -0
  16. stapel_auth/magic_link/__init__.py +0 -0
  17. stapel_auth/magic_link/dto.py +18 -0
  18. stapel_auth/magic_link/serializers.py +45 -0
  19. stapel_auth/magic_link/services.py +81 -0
  20. stapel_auth/magic_link/views.py +131 -0
  21. stapel_auth/mfa/__init__.py +0 -0
  22. stapel_auth/mfa/dto.py +124 -0
  23. stapel_auth/mfa/serializers.py +87 -0
  24. stapel_auth/mfa/services.py +394 -0
  25. stapel_auth/mfa/views.py +497 -0
  26. stapel_auth/migrations/0001_initial.py +102 -0
  27. stapel_auth/migrations/0002_initial.py +23 -0
  28. stapel_auth/migrations/0003_alter_emailverification_code_and_more.py +44 -0
  29. stapel_auth/migrations/0004_authenticatorchangerequest.py +45 -0
  30. stapel_auth/migrations/0005_user_sessions_totp_device.py +60 -0
  31. stapel_auth/migrations/0006_rename_usersession_user_revoked_idx_user_sessio_user_id_4d7d26_idx_and_more.py +29 -0
  32. stapel_auth/migrations/0007_add_audit_log_passkeys_suspicious_session.py +60 -0
  33. stapel_auth/migrations/0008_add_device_type_details_to_session.py +23 -0
  34. stapel_auth/migrations/0009_add_access_jti_to_session.py +17 -0
  35. stapel_auth/migrations/0010_sso_models.py +62 -0
  36. stapel_auth/migrations/0011_verification_preference.py +28 -0
  37. stapel_auth/migrations/__init__.py +0 -0
  38. stapel_auth/models.py +476 -0
  39. stapel_auth/monitoring_proxy.py +36 -0
  40. stapel_auth/oauth/__init__.py +0 -0
  41. stapel_auth/oauth/dto.py +71 -0
  42. stapel_auth/oauth/providers.py +255 -0
  43. stapel_auth/oauth/serializers.py +42 -0
  44. stapel_auth/oauth/services.py +64 -0
  45. stapel_auth/oauth_providers.py +260 -0
  46. stapel_auth/openid/__init__.py +0 -0
  47. stapel_auth/openid/views.py +177 -0
  48. stapel_auth/otp/__init__.py +0 -0
  49. stapel_auth/otp/dto.py +114 -0
  50. stapel_auth/otp/serializers.py +230 -0
  51. stapel_auth/otp/services.py +687 -0
  52. stapel_auth/otp/utils.py +2 -0
  53. stapel_auth/otp/views.py +1853 -0
  54. stapel_auth/password/__init__.py +0 -0
  55. stapel_auth/password/dto.py +62 -0
  56. stapel_auth/password/serializers.py +167 -0
  57. stapel_auth/password/services.py +265 -0
  58. stapel_auth/password/views.py +496 -0
  59. stapel_auth/permissions.py +69 -0
  60. stapel_auth/py.typed +0 -0
  61. stapel_auth/qr/__init__.py +0 -0
  62. stapel_auth/qr/dto.py +64 -0
  63. stapel_auth/qr/serializers.py +64 -0
  64. stapel_auth/qr/services.py +90 -0
  65. stapel_auth/qr/views.py +366 -0
  66. stapel_auth/schemas/emits/user.registered.json +13 -0
  67. stapel_auth/schemas/emits/user.session_created.json +15 -0
  68. stapel_auth/schemas/emits/user.session_revoked.json +12 -0
  69. stapel_auth/schemas/functions/auth.verification.policy.json +14 -0
  70. stapel_auth/security/__init__.py +0 -0
  71. stapel_auth/security/dto.py +140 -0
  72. stapel_auth/security/serializers.py +86 -0
  73. stapel_auth/security/services.py +120 -0
  74. stapel_auth/security/views.py +301 -0
  75. stapel_auth/security_views.py +518 -0
  76. stapel_auth/serializers.py +63 -0
  77. stapel_auth/services.py +12 -0
  78. stapel_auth/sessions/__init__.py +0 -0
  79. stapel_auth/sessions/dto.py +101 -0
  80. stapel_auth/sessions/serializers.py +95 -0
  81. stapel_auth/sessions/services.py +379 -0
  82. stapel_auth/sessions/views.py +446 -0
  83. stapel_auth/sso_service.py +433 -0
  84. stapel_auth/sso_views.py +418 -0
  85. stapel_auth/tasks.py +233 -0
  86. stapel_auth/tests/__init__.py +0 -0
  87. stapel_auth/tests/conftest.py +145 -0
  88. stapel_auth/tests/test_auth.py +4558 -0
  89. stapel_auth/tests/test_extra.py +1380 -0
  90. stapel_auth/tests/test_public_api.py +42 -0
  91. stapel_auth/tests/test_serializer_seams.py +54 -0
  92. stapel_auth/tests/test_services.py +625 -0
  93. stapel_auth/tests/test_sso.py +665 -0
  94. stapel_auth/tests/test_upgrade.py +746 -0
  95. stapel_auth/tests/test_verification.py +1089 -0
  96. stapel_auth/urls.py +293 -0
  97. stapel_auth/utils.py +73 -0
  98. stapel_auth/verification/__init__.py +8 -0
  99. stapel_auth/verification/dto.py +69 -0
  100. stapel_auth/verification/serializers.py +89 -0
  101. stapel_auth/verification/views.py +337 -0
  102. stapel_auth/verification_factors.py +185 -0
  103. stapel_auth/views.py +2 -0
  104. stapel_auth-0.3.2.dist-info/METADATA +63 -0
  105. stapel_auth-0.3.2.dist-info/RECORD +108 -0
  106. stapel_auth-0.3.2.dist-info/WHEEL +5 -0
  107. stapel_auth-0.3.2.dist-info/licenses/LICENSE +21 -0
  108. stapel_auth-0.3.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,46 @@
1
+ """stapel-auth — pluggable authentication for Django.
2
+
3
+ Public API of the ``stapel_auth`` package, exported lazily (PEP 562) so that
4
+ importing the package itself stays side-effect free: the runtime settings
5
+ object (``auth_settings``), the per-feature URL-pattern factories from
6
+ ``stapel_auth.urls`` and the OAuth provider registry (``PROVIDER_REGISTRY``).
7
+ """
8
+
9
+ from importlib import import_module
10
+
11
+ # name -> (relative module, attribute) — resolved on first attribute access.
12
+ _LAZY_EXPORTS = {
13
+ "auth_settings": (".conf", "auth_settings"),
14
+ "PROVIDER_REGISTRY": (".oauth_providers", "PROVIDER_REGISTRY"),
15
+ "get_admin_api_urls": (".urls", "get_admin_api_urls"),
16
+ "get_magic_link_urls": (".urls", "get_magic_link_urls"),
17
+ "get_mfa_urls": (".urls", "get_mfa_urls"),
18
+ "get_oauth_urls": (".urls", "get_oauth_urls"),
19
+ "get_openid_urls": (".urls", "get_openid_urls"),
20
+ "get_otp_urls": (".urls", "get_otp_urls"),
21
+ "get_password_urls": (".urls", "get_password_urls"),
22
+ "get_qr_urls": (".urls", "get_qr_urls"),
23
+ "get_security_urls": (".urls", "get_security_urls"),
24
+ "get_sessions_urls": (".urls", "get_sessions_urls"),
25
+ "get_sso_urls": (".urls", "get_sso_urls"),
26
+ "get_verification_urls": (".urls", "get_verification_urls"),
27
+ }
28
+
29
+ __all__ = sorted(_LAZY_EXPORTS)
30
+
31
+
32
+ def __getattr__(name):
33
+ """PEP 562 lazy exports: resolve, cache into globals(), return."""
34
+ try:
35
+ module_path, attr = _LAZY_EXPORTS[name]
36
+ except KeyError:
37
+ raise AttributeError(
38
+ f"module {__name__!r} has no attribute {name!r}"
39
+ ) from None
40
+ value = getattr(import_module(module_path, __name__), attr)
41
+ globals()[name] = value # cache — __getattr__ is skipped next time
42
+ return value
43
+
44
+
45
+ def __dir__():
46
+ return sorted(set(globals()) | set(__all__))
File without changes
@@ -0,0 +1,46 @@
1
+ """Data Transfer Objects for the admin sub-package."""
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+
6
+ # =============================================================================
7
+ # Admin Broker DTOs
8
+ # =============================================================================
9
+
10
+
11
+ @dataclass
12
+ class AdminUserCreateRequest:
13
+ """Create a user via admin broker, bypassing OTP verification.
14
+
15
+ Attributes:
16
+ email: User email. Example: user@example.com
17
+ phone: Phone in E.164 format. Example: +79001234567
18
+ username: Username. Example: alice
19
+ display_name: Display name. Example: Alice
20
+ password: Initial password (optional). Example: secure123
21
+ send_welcome: Send welcome notification via notification service. Example: false
22
+ mark_verified: Mark email/phone as verified immediately. Example: true
23
+ """
24
+ email: Optional[str] = None
25
+ phone: Optional[str] = None
26
+ username: Optional[str] = None
27
+ display_name: Optional[str] = None
28
+ password: Optional[str] = None
29
+ send_welcome: bool = False
30
+ mark_verified: bool = True
31
+
32
+
33
+ @dataclass
34
+ class AdminUserCreateResponse:
35
+ """Created user summary.
36
+
37
+ Attributes:
38
+ user_id: Created user UUID. Example: 550e8400-e29b-41d4-a716-446655440000
39
+ email: User email. Example: user@example.com
40
+ phone: User phone. Example: +79001234567
41
+ username: Username. Example: alice
42
+ """
43
+ user_id: str
44
+ email: Optional[str]
45
+ phone: Optional[str]
46
+ username: Optional[str]
@@ -0,0 +1,60 @@
1
+ """Serializers for the admin sub-package."""
2
+ from rest_framework import serializers
3
+ from stapel_core.django.api.serializers import StapelDataclassSerializer
4
+ from stapel_core.django.api.errors import StapelValidationError
5
+
6
+
7
+ from stapel_auth.models import ServiceAPIKey
8
+ from stapel_auth.errors import ERR_400_EMAIL_OR_PHONE_REQUIRED
9
+ from stapel_auth.admin.dto import AdminUserCreateResponse
10
+
11
+
12
+ def _normalize_phone(value):
13
+ """Parse and normalize phone number to E.164 format (+79991234567)."""
14
+ import phonenumbers
15
+ from stapel_auth.errors import (
16
+ ERR_400_INVALID_PHONE_FORMAT,
17
+ ERR_400_INVALID_PHONE,
18
+ ERR_400_PHONE_TOO_LONG,
19
+ )
20
+ try:
21
+ phone_number = phonenumbers.parse(value, None)
22
+ except phonenumbers.NumberParseException:
23
+ raise StapelValidationError(ERR_400_INVALID_PHONE_FORMAT)
24
+ if not phonenumbers.is_valid_number(phone_number):
25
+ raise StapelValidationError(ERR_400_INVALID_PHONE)
26
+ e164 = phonenumbers.format_number(phone_number, phonenumbers.PhoneNumberFormat.E164)
27
+ if len(e164) > 16: # E.164: '+' + up to 15 digits
28
+ raise StapelValidationError(ERR_400_PHONE_TOO_LONG)
29
+ return e164
30
+
31
+
32
+ class ServiceAPIKeySerializer(serializers.ModelSerializer):
33
+ """Serializer for Service API Keys"""
34
+
35
+ class Meta:
36
+ model = ServiceAPIKey
37
+ fields = ['id', 'name', 'key', 'description', 'is_active', 'created_at', 'last_used_at', 'allowed_endpoints']
38
+ read_only_fields = ['id', 'key', 'created_at', 'last_used_at']
39
+
40
+
41
+ class AdminUserCreateRequestSerializer(serializers.Serializer):
42
+ email = serializers.EmailField(required=False, allow_null=True, default=None)
43
+ phone = serializers.CharField(required=False, allow_null=True, default=None)
44
+ username = serializers.CharField(required=False, allow_null=True, default=None)
45
+ display_name = serializers.CharField(required=False, allow_null=True, default=None)
46
+ password = serializers.CharField(required=False, allow_null=True, default=None, min_length=8)
47
+ send_welcome = serializers.BooleanField(default=False)
48
+ mark_verified = serializers.BooleanField(default=True)
49
+
50
+ def validate(self, attrs):
51
+ if not any([attrs.get("email"), attrs.get("phone"), attrs.get("username")]):
52
+ raise StapelValidationError(ERR_400_EMAIL_OR_PHONE_REQUIRED)
53
+ if attrs.get("phone"):
54
+ attrs["phone"] = _normalize_phone(attrs["phone"])
55
+ return attrs
56
+
57
+
58
+ class AdminUserCreateResponseSerializer(StapelDataclassSerializer):
59
+ class Meta:
60
+ dataclass = AdminUserCreateResponse
@@ -0,0 +1,129 @@
1
+ """Views for the admin sub-package: ServiceAPIKeyViewSet, AdminUserViewSet, CapabilitiesView."""
2
+
3
+ import logging
4
+
5
+ from drf_spectacular.utils import extend_schema
6
+ from rest_framework import permissions, viewsets
7
+ from rest_framework.decorators import action
8
+ from rest_framework.views import APIView
9
+ from stapel_core.django.api.errors import (
10
+ StapelResponse,
11
+ )
12
+
13
+ from stapel_auth.admin.dto import AdminUserCreateResponse
14
+ from stapel_auth.admin.serializers import (
15
+ AdminUserCreateRequestSerializer,
16
+ AdminUserCreateResponseSerializer,
17
+ ServiceAPIKeySerializer,
18
+ )
19
+ from stapel_auth.models import ServiceAPIKey
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @extend_schema(tags=["API Keys"])
25
+ class ServiceAPIKeyViewSet(viewsets.ModelViewSet):
26
+ """
27
+ ViewSet for managing service API keys
28
+ """
29
+
30
+ queryset = ServiceAPIKey.objects.all()
31
+ serializer_class = ServiceAPIKeySerializer
32
+ permission_classes = [permissions.IsAdminUser]
33
+
34
+ def perform_create(self, serializer):
35
+ """Generate API key on creation"""
36
+ serializer.save(key=ServiceAPIKey.generate_key())
37
+
38
+
39
+ class CapabilitiesView(APIView):
40
+ """Public endpoint returning enabled auth methods for this deployment."""
41
+
42
+ permission_classes = [permissions.AllowAny]
43
+ authentication_classes = []
44
+
45
+ @extend_schema(
46
+ tags=["Auth"],
47
+ description="Return available authentication and registration methods for this deployment.",
48
+ responses={200: None},
49
+ )
50
+ def get(self, request):
51
+ from stapel_auth.oauth.services import AuthCapabilitiesService
52
+ from stapel_auth.serializers import AuthCapabilitiesSerializer
53
+
54
+ caps = AuthCapabilitiesService.get_capabilities()
55
+ return StapelResponse(AuthCapabilitiesSerializer(caps))
56
+
57
+
58
+ # ── Admin User Broker ─────────────────────────────────────────────────────────
59
+
60
+
61
+ class AdminUserViewSet(viewsets.GenericViewSet):
62
+ """Admin broker for creating users without OTP verification."""
63
+
64
+ permission_classes = [permissions.AllowAny]
65
+
66
+ @extend_schema(
67
+ tags=["Admin"],
68
+ description="Create a user account without OTP, bypassing normal registration flow. Requires service API key or admin (staff) credentials.",
69
+ request=AdminUserCreateRequestSerializer,
70
+ responses={201: AdminUserCreateResponseSerializer, 400: None, 403: None},
71
+ )
72
+ @action(detail=False, methods=["post"])
73
+ def create_user(self, request):
74
+ from django.contrib.auth import get_user_model
75
+ from stapel_core.django.api.errors import error_403_forbidden
76
+
77
+ from stapel_auth.permissions import IsServiceAPIKey
78
+
79
+ User = get_user_model()
80
+
81
+ # Allow either staff user or service API key
82
+ is_svc_key = IsServiceAPIKey().has_permission(request, self)
83
+ if not is_svc_key and not (
84
+ request.user.is_authenticated and request.user.is_staff
85
+ ):
86
+ return error_403_forbidden()
87
+
88
+ serializer = AdminUserCreateRequestSerializer(data=request.data)
89
+ serializer.is_valid(raise_exception=True)
90
+ data = serializer.validated_data
91
+
92
+ email = data.get("email")
93
+ phone = data.get("phone")
94
+ username = data.get("username")
95
+ display_name = data.get("display_name")
96
+ password = data.get("password")
97
+ mark_verified = data.get("mark_verified", True)
98
+ send_welcome = data.get("send_welcome", False)
99
+
100
+ user = User.objects.create(
101
+ email=email,
102
+ phone=phone,
103
+ username=username or (email.split("@")[0] if email else phone),
104
+ is_email_verified=mark_verified and bool(email),
105
+ is_phone_verified=mark_verified and bool(phone),
106
+ )
107
+ if display_name:
108
+ user.first_name = display_name
109
+ if password:
110
+ user.set_password(password)
111
+ user.save()
112
+
113
+ if send_welcome:
114
+ try:
115
+ from stapel_core.notifications import request_notification
116
+
117
+ request_notification(notification_type="welcome", user_id=str(user.id))
118
+ except Exception:
119
+ logger.exception(
120
+ "Failed to send welcome notification for user %s", user.id
121
+ )
122
+
123
+ dto = AdminUserCreateResponse(
124
+ user_id=str(user.id),
125
+ email=user.email,
126
+ phone=user.phone,
127
+ username=user.username,
128
+ )
129
+ return StapelResponse(AdminUserCreateResponseSerializer(dto), status=201)
stapel_auth/admin.py ADDED
@@ -0,0 +1,73 @@
1
+ from django.contrib import admin
2
+ from .models import PhoneVerification, EmailVerification, ServiceAPIKey, RefreshTokenTracker, LoginAttempt, AuthenticatorChangeRequest
3
+
4
+
5
+ @admin.register(PhoneVerification)
6
+ class PhoneVerificationAdmin(admin.ModelAdmin):
7
+ """Phone Verification admin"""
8
+
9
+ list_display = ['phone', 'code', 'is_verified', 'created_at', 'expires_at', 'attempts']
10
+ list_filter = ['is_verified', 'created_at']
11
+ search_fields = ['phone']
12
+ ordering = ['-created_at']
13
+ readonly_fields = ['created_at']
14
+
15
+
16
+ @admin.register(EmailVerification)
17
+ class EmailVerificationAdmin(admin.ModelAdmin):
18
+ """Email Verification admin"""
19
+
20
+ list_display = ['email', 'code', 'is_verified', 'created_at', 'expires_at', 'attempts']
21
+ list_filter = ['is_verified', 'created_at']
22
+ search_fields = ['email']
23
+ ordering = ['-created_at']
24
+ readonly_fields = ['created_at']
25
+
26
+
27
+ @admin.register(ServiceAPIKey)
28
+ class ServiceAPIKeyAdmin(admin.ModelAdmin):
29
+ """Service API Key admin"""
30
+
31
+ list_display = ['name', 'key', 'is_active', 'created_at', 'last_used_at']
32
+ list_filter = ['is_active', 'created_at']
33
+ search_fields = ['name', 'key']
34
+ ordering = ['-created_at']
35
+ readonly_fields = ['key', 'created_at', 'last_used_at']
36
+
37
+ def save_model(self, request, obj, form, change):
38
+ if not change: # Only set key on creation
39
+ obj.key = ServiceAPIKey.generate_key()
40
+ super().save_model(request, obj, form, change)
41
+
42
+
43
+ @admin.register(RefreshTokenTracker)
44
+ class RefreshTokenTrackerAdmin(admin.ModelAdmin):
45
+ """Refresh Token Tracker admin"""
46
+
47
+ list_display = ['user', 'created_at', 'expires_at', 'is_revoked', 'device_info']
48
+ list_filter = ['is_revoked', 'created_at']
49
+ search_fields = ['user__username', 'user__email', 'device_info']
50
+ ordering = ['-created_at']
51
+ readonly_fields = ['created_at']
52
+
53
+
54
+ @admin.register(AuthenticatorChangeRequest)
55
+ class AuthenticatorChangeRequestAdmin(admin.ModelAdmin):
56
+ """Authenticator Change Request admin"""
57
+
58
+ list_display = ['user', 'change_type', 'status', 'old_value', 'new_value', 'created_at', 'scheduled_at']
59
+ list_filter = ['status', 'change_type']
60
+ search_fields = ['old_value', 'new_value']
61
+ ordering = ['-created_at']
62
+ readonly_fields = ['id', 'change_token', 'created_at', 'completed_at', 'cancelled_at']
63
+
64
+
65
+ @admin.register(LoginAttempt)
66
+ class LoginAttemptAdmin(admin.ModelAdmin):
67
+ """Login Attempt admin"""
68
+
69
+ list_display = ['identifier', 'attempt_type', 'ip_address', 'created_at']
70
+ list_filter = ['attempt_type', 'created_at']
71
+ search_fields = ['identifier', 'ip_address']
72
+ ordering = ['-created_at']
73
+ readonly_fields = ['created_at']
stapel_auth/apps.py ADDED
@@ -0,0 +1,52 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class StapelAuthConfig(AppConfig):
5
+ default_auto_field = 'django.db.models.BigAutoField'
6
+ name = 'stapel_auth'
7
+ label = 'authentication' # keeps existing migration history / DB tables intact
8
+ verbose_name = 'Stapel Auth'
9
+
10
+ def ready(self):
11
+ import warnings
12
+ from django.conf import settings
13
+ from django.utils.module_loading import import_string
14
+ from stapel_core.oauth import register_provider
15
+ from .conf import auth_settings
16
+
17
+ if not auth_settings.FRONTEND_URL:
18
+ warnings.warn(
19
+ "stapel-auth: FRONTEND_URL is not set. "
20
+ "Set STAPEL_AUTH = {'FRONTEND_URL': '...'} or FRONTEND_URL env var. "
21
+ "Redirects after SSO/magic link/QR login will not work correctly.",
22
+ stacklevel=2,
23
+ )
24
+
25
+ classes = list(auth_settings.OAUTH_PROVIDER_CLASSES)
26
+ if getattr(settings, 'DEBUG', False):
27
+ classes.append('stapel_auth.oauth_providers.TestProvider')
28
+
29
+ for cls_path in classes:
30
+ register_provider(import_string(cls_path)())
31
+
32
+ # Step-up verification factors: the mechanism (challenge/grant
33
+ # stores, @requires_verification) lives in stapel-core; the concrete
34
+ # factor implementations on top of the auth services are registered
35
+ # here. register_factor is idempotent per factor id.
36
+ from stapel_core.verification import register_factor
37
+ from .verification_factors import DEFAULT_FACTOR_CLASSES
38
+ for factor_cls in DEFAULT_FACTOR_CLASSES:
39
+ register_factor(factor_cls())
40
+
41
+ # comm Function providers (auth.verification.policy). Importing the
42
+ # module runs the @function decorators; re-imports are no-ops and
43
+ # re-registering the same handler object is idempotent.
44
+ from . import functions # noqa: F401
45
+
46
+ # In monolith mode (no GDPR_COLLECTING_SERVICES), register the GDPR provider
47
+ # in-process so the orchestrator can call it directly.
48
+ # In microservices mode the bus consumer (management/commands/consume_gdpr.py) handles it.
49
+ if not getattr(settings, 'GDPR_COLLECTING_SERVICES', None):
50
+ from stapel_core.gdpr import gdpr_registry
51
+ from .gdpr import AuthGDPRProvider
52
+ gdpr_registry.register(AuthGDPRProvider())
stapel_auth/conf.py ADDED
@@ -0,0 +1,196 @@
1
+ """
2
+ Stapel-auth app settings.
3
+
4
+ Configure via STAPEL_AUTH dict in Django settings:
5
+
6
+ STAPEL_AUTH = {
7
+ 'FRONTEND_URL': 'https://app.example.com',
8
+ 'USE_MOCK_SMS_OTP': True,
9
+ 'OAUTH_PROVIDERS': {
10
+ 'google': {'client_id': '...', 'client_secret': '...'},
11
+ },
12
+ }
13
+
14
+ Each key falls back to: direct Django setting → env var → built-in default.
15
+ """
16
+ import os
17
+ from dataclasses import dataclass
18
+ from django.test.signals import setting_changed
19
+
20
+
21
+ @dataclass
22
+ class OAuthProviderConfig:
23
+ """Credentials for a single OAuth provider.
24
+
25
+ Attributes:
26
+ client_id: OAuth app client ID. Example: abc123
27
+ client_secret: OAuth app client secret. Example: secret
28
+ """
29
+ client_id: str
30
+ client_secret: str = ''
31
+
32
+ DEFAULTS = {
33
+ # URLs
34
+ 'FRONTEND_URL': None, # Required in production; falls back to env FRONTEND_URL
35
+ 'BACKEND_URL': None, # Required for SAML/OIDC; falls back to env BACKEND_URL
36
+
37
+ # OTP
38
+ 'USE_MOCK_SMS_OTP': False,
39
+ 'USE_MOCK_EMAIL_OTP': False,
40
+ 'MOCK_OTP_CODE': '0000',
41
+ 'OTP_TTL': 600, # seconds
42
+ 'OTP_MAX_ATTEMPTS': 5,
43
+ 'OTP_RATE_LIMIT_PER_HOUR': 3,
44
+
45
+ # Magic links
46
+ 'MAGIC_LINK_TTL': 900, # seconds (15 min)
47
+ 'MAGIC_LINK_RATE_LIMIT_PER_HOUR': 3,
48
+
49
+ # QR auth
50
+ 'QR_TOKEN_TTL': 300, # seconds (5 min)
51
+
52
+ # Sessions
53
+ 'SESSION_TTL_DAYS': 30,
54
+
55
+ # Anonymous users
56
+ 'ANONYMOUS_USER_LIFETIME_DAYS': 30,
57
+
58
+ # JWT cookies (override if needed; usually inherited from stapel-core settings)
59
+ 'JWT_COOKIE_DOMAIN': None,
60
+
61
+ # TOTP
62
+ 'TOTP_ISSUER': 'Stapel',
63
+
64
+ # Passkeys (WebAuthn)
65
+ 'WEBAUTHN_RP_ID': None, # Falls back to request host
66
+ 'WEBAUTHN_RP_NAME': 'Stapel',
67
+ 'WEBAUTHN_ORIGIN': None, # Falls back to FRONTEND_URL
68
+
69
+ # SSO
70
+ 'SSO_ENFORCED_REDIRECT_PATH': '/login',
71
+
72
+ # Notifications (optional integration)
73
+ 'LOGIN_NOTIFICATION_ENABLED': False,
74
+
75
+ # GDPR integration: dotted path to the model that stores re-registration
76
+ # hashes. Resolved lazily — stapel-gdpr is NOT a hard dependency.
77
+ 'REREGISTRATION_MODEL': 'stapel_gdpr.models.ReRegistrationHash',
78
+
79
+ # Service-to-service
80
+ 'INTERNAL_SERVICE_KEY': None, # Falls back to env INTERNAL_SERVICE_KEY
81
+
82
+ # OAuth provider credentials (parsed into dict[str, OAuthProviderConfig])
83
+ 'OAUTH_PROVIDERS': {},
84
+
85
+ # Dotted-path list of OAuthProvider subclasses to register on startup.
86
+ # Extend in settings to add providers without modifying stapel-auth:
87
+ # STAPEL_AUTH = {'OAUTH_PROVIDER_CLASSES': [..., 'myapp.providers.YandexProvider']}
88
+ 'OAUTH_PROVIDER_CLASSES': [
89
+ 'stapel_auth.oauth_providers.GoogleProvider',
90
+ 'stapel_auth.oauth_providers.GitHubProvider',
91
+ 'stapel_auth.oauth_providers.ZoomProvider',
92
+ 'stapel_auth.oauth_providers.FacebookProvider',
93
+ 'stapel_auth.oauth_providers.AppleProvider',
94
+ 'stapel_auth.oauth_providers.TwitterProvider',
95
+ 'stapel_auth.oauth_providers.YandexProvider',
96
+ 'stapel_auth.oauth_providers.VKProvider',
97
+ 'stapel_auth.oauth_providers.SberProvider',
98
+ ],
99
+
100
+ # Registration method gates
101
+ 'AUTH_PHONE_REGISTRATION': True,
102
+ 'AUTH_EMAIL_REGISTRATION': True,
103
+ 'AUTH_OAUTH_REGISTRATION': True,
104
+ 'AUTH_SSO_REGISTRATION': True,
105
+ 'AUTH_PASSWORD_REGISTRATION': False,
106
+
107
+ # Login method gates
108
+ 'AUTH_PHONE_LOGIN': True,
109
+ 'AUTH_EMAIL_LOGIN': True,
110
+ 'AUTH_OAUTH_LOGIN': True,
111
+ 'AUTH_SSO_LOGIN': True,
112
+ 'AUTH_PASSWORD_LOGIN': False,
113
+ 'AUTH_QR_LOGIN': True,
114
+ 'AUTH_PASSKEY_LOGIN': True,
115
+ 'AUTH_MAGIC_LINK_LOGIN': True,
116
+
117
+ # Step-up (TOTP challenge) on existing login flows.
118
+ # OAuth: off by default — the provider already authenticated the user;
119
+ # opt back in with OAUTH_STEP_UP=True.
120
+ 'OAUTH_STEP_UP': False,
121
+ # Password login: on by default (a password alone is phishable) —
122
+ # preserves the pre-0.3 behavior; opt out with PASSWORD_LOGIN_STEP_UP=False.
123
+ 'PASSWORD_LOGIN_STEP_UP': True,
124
+ }
125
+
126
+ # Env var fallbacks for settings that are commonly set via environment
127
+ _ENV_FALLBACKS = {
128
+ 'FRONTEND_URL': 'FRONTEND_URL',
129
+ 'BACKEND_URL': 'BACKEND_URL',
130
+ 'INTERNAL_SERVICE_KEY': 'INTERNAL_SERVICE_KEY',
131
+ 'JWT_COOKIE_DOMAIN': 'JWT_COOKIE_DOMAIN',
132
+ 'WEBAUTHN_RP_ID': 'WEBAUTHN_RP_ID',
133
+ 'WEBAUTHN_ORIGIN': 'WEBAUTHN_ORIGIN',
134
+ 'TOTP_ISSUER': 'TOTP_ISSUER',
135
+ }
136
+
137
+
138
+ class AuthSettings:
139
+ """
140
+ Lazy accessor for STAPEL_AUTH settings.
141
+
142
+ Resolution order per key:
143
+ 1. STAPEL_AUTH['KEY'] in Django settings
144
+ 2. Direct Django setting (legacy / common.django.settings compat)
145
+ 3. Environment variable (for keys in _ENV_FALLBACKS)
146
+ 4. Built-in default
147
+ """
148
+
149
+ def __init__(self):
150
+ self._cache: dict = {}
151
+
152
+ def __getattr__(self, name: str):
153
+ if name.startswith('_') or name not in DEFAULTS:
154
+ raise AttributeError(f'Invalid stapel-auth setting: {name!r}')
155
+
156
+ if name in self._cache:
157
+ return self._cache[name]
158
+
159
+ from django.conf import settings as django_settings
160
+
161
+ user_settings = getattr(django_settings, 'STAPEL_AUTH', {})
162
+
163
+ if name in user_settings:
164
+ value = user_settings[name]
165
+ elif hasattr(django_settings, name):
166
+ # Legacy: setting defined directly (e.g. FRONTEND_URL = '...')
167
+ value = getattr(django_settings, name)
168
+ elif name in _ENV_FALLBACKS:
169
+ value = os.getenv(_ENV_FALLBACKS[name], DEFAULTS[name])
170
+ else:
171
+ value = DEFAULTS[name]
172
+
173
+ if name == 'OAUTH_PROVIDERS' and isinstance(value, dict):
174
+ value = {
175
+ pid: OAuthProviderConfig(**cfg) if isinstance(cfg, dict) else cfg
176
+ for pid, cfg in value.items()
177
+ }
178
+
179
+ self._cache[name] = value
180
+ return value
181
+
182
+ def reload(self):
183
+ self.__dict__['_cache'] = {}
184
+
185
+
186
+ auth_settings = AuthSettings()
187
+
188
+
189
+ def _reload_on_change(*, setting, **kwargs):
190
+ # Also reload when a flat (legacy) setting with the same name changes,
191
+ # e.g. override_settings(USE_MOCK_SMS_OTP=False) in tests.
192
+ if setting == 'STAPEL_AUTH' or setting in DEFAULTS:
193
+ auth_settings.reload()
194
+
195
+
196
+ setting_changed.connect(_reload_on_change)