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.
- stapel_auth/__init__.py +46 -0
- stapel_auth/admin/__init__.py +0 -0
- stapel_auth/admin/dto.py +46 -0
- stapel_auth/admin/serializers.py +60 -0
- stapel_auth/admin/views.py +129 -0
- stapel_auth/admin.py +73 -0
- stapel_auth/apps.py +52 -0
- stapel_auth/conf.py +196 -0
- stapel_auth/conftest.py +166 -0
- stapel_auth/dto.py +28 -0
- stapel_auth/errors.py +178 -0
- stapel_auth/events.py +35 -0
- stapel_auth/flows.py +167 -0
- stapel_auth/functions.py +53 -0
- stapel_auth/gdpr.py +160 -0
- stapel_auth/magic_link/__init__.py +0 -0
- stapel_auth/magic_link/dto.py +18 -0
- stapel_auth/magic_link/serializers.py +45 -0
- stapel_auth/magic_link/services.py +81 -0
- stapel_auth/magic_link/views.py +131 -0
- stapel_auth/mfa/__init__.py +0 -0
- stapel_auth/mfa/dto.py +124 -0
- stapel_auth/mfa/serializers.py +87 -0
- stapel_auth/mfa/services.py +394 -0
- stapel_auth/mfa/views.py +497 -0
- stapel_auth/migrations/0001_initial.py +102 -0
- stapel_auth/migrations/0002_initial.py +23 -0
- stapel_auth/migrations/0003_alter_emailverification_code_and_more.py +44 -0
- stapel_auth/migrations/0004_authenticatorchangerequest.py +45 -0
- stapel_auth/migrations/0005_user_sessions_totp_device.py +60 -0
- stapel_auth/migrations/0006_rename_usersession_user_revoked_idx_user_sessio_user_id_4d7d26_idx_and_more.py +29 -0
- stapel_auth/migrations/0007_add_audit_log_passkeys_suspicious_session.py +60 -0
- stapel_auth/migrations/0008_add_device_type_details_to_session.py +23 -0
- stapel_auth/migrations/0009_add_access_jti_to_session.py +17 -0
- stapel_auth/migrations/0010_sso_models.py +62 -0
- stapel_auth/migrations/0011_verification_preference.py +28 -0
- stapel_auth/migrations/__init__.py +0 -0
- stapel_auth/models.py +476 -0
- stapel_auth/monitoring_proxy.py +36 -0
- stapel_auth/oauth/__init__.py +0 -0
- stapel_auth/oauth/dto.py +71 -0
- stapel_auth/oauth/providers.py +255 -0
- stapel_auth/oauth/serializers.py +42 -0
- stapel_auth/oauth/services.py +64 -0
- stapel_auth/oauth_providers.py +260 -0
- stapel_auth/openid/__init__.py +0 -0
- stapel_auth/openid/views.py +177 -0
- stapel_auth/otp/__init__.py +0 -0
- stapel_auth/otp/dto.py +114 -0
- stapel_auth/otp/serializers.py +230 -0
- stapel_auth/otp/services.py +687 -0
- stapel_auth/otp/utils.py +2 -0
- stapel_auth/otp/views.py +1853 -0
- stapel_auth/password/__init__.py +0 -0
- stapel_auth/password/dto.py +62 -0
- stapel_auth/password/serializers.py +167 -0
- stapel_auth/password/services.py +265 -0
- stapel_auth/password/views.py +496 -0
- stapel_auth/permissions.py +69 -0
- stapel_auth/py.typed +0 -0
- stapel_auth/qr/__init__.py +0 -0
- stapel_auth/qr/dto.py +64 -0
- stapel_auth/qr/serializers.py +64 -0
- stapel_auth/qr/services.py +90 -0
- stapel_auth/qr/views.py +366 -0
- stapel_auth/schemas/emits/user.registered.json +13 -0
- stapel_auth/schemas/emits/user.session_created.json +15 -0
- stapel_auth/schemas/emits/user.session_revoked.json +12 -0
- stapel_auth/schemas/functions/auth.verification.policy.json +14 -0
- stapel_auth/security/__init__.py +0 -0
- stapel_auth/security/dto.py +140 -0
- stapel_auth/security/serializers.py +86 -0
- stapel_auth/security/services.py +120 -0
- stapel_auth/security/views.py +301 -0
- stapel_auth/security_views.py +518 -0
- stapel_auth/serializers.py +63 -0
- stapel_auth/services.py +12 -0
- stapel_auth/sessions/__init__.py +0 -0
- stapel_auth/sessions/dto.py +101 -0
- stapel_auth/sessions/serializers.py +95 -0
- stapel_auth/sessions/services.py +379 -0
- stapel_auth/sessions/views.py +446 -0
- stapel_auth/sso_service.py +433 -0
- stapel_auth/sso_views.py +418 -0
- stapel_auth/tasks.py +233 -0
- stapel_auth/tests/__init__.py +0 -0
- stapel_auth/tests/conftest.py +145 -0
- stapel_auth/tests/test_auth.py +4558 -0
- stapel_auth/tests/test_extra.py +1380 -0
- stapel_auth/tests/test_public_api.py +42 -0
- stapel_auth/tests/test_serializer_seams.py +54 -0
- stapel_auth/tests/test_services.py +625 -0
- stapel_auth/tests/test_sso.py +665 -0
- stapel_auth/tests/test_upgrade.py +746 -0
- stapel_auth/tests/test_verification.py +1089 -0
- stapel_auth/urls.py +293 -0
- stapel_auth/utils.py +73 -0
- stapel_auth/verification/__init__.py +8 -0
- stapel_auth/verification/dto.py +69 -0
- stapel_auth/verification/serializers.py +89 -0
- stapel_auth/verification/views.py +337 -0
- stapel_auth/verification_factors.py +185 -0
- stapel_auth/views.py +2 -0
- stapel_auth-0.3.2.dist-info/METADATA +63 -0
- stapel_auth-0.3.2.dist-info/RECORD +108 -0
- stapel_auth-0.3.2.dist-info/WHEEL +5 -0
- stapel_auth-0.3.2.dist-info/licenses/LICENSE +21 -0
- stapel_auth-0.3.2.dist-info/top_level.txt +1 -0
stapel_auth/__init__.py
ADDED
|
@@ -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
|
stapel_auth/admin/dto.py
ADDED
|
@@ -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)
|