django-authnest 0.1.0__tar.gz
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.
- django_authnest-0.1.0/LICENSE +21 -0
- django_authnest-0.1.0/PKG-INFO +26 -0
- django_authnest-0.1.0/authnest/__init__.py +0 -0
- django_authnest-0.1.0/authnest/admin.py +143 -0
- django_authnest-0.1.0/authnest/apps.py +10 -0
- django_authnest-0.1.0/authnest/backends.py +46 -0
- django_authnest-0.1.0/authnest/conf.py +97 -0
- django_authnest-0.1.0/authnest/exceptions.py +16 -0
- django_authnest-0.1.0/authnest/managers.py +100 -0
- django_authnest-0.1.0/authnest/migrations/0001_initial.py +50 -0
- django_authnest-0.1.0/authnest/migrations/__init__.py +0 -0
- django_authnest-0.1.0/authnest/models.py +100 -0
- django_authnest-0.1.0/authnest/serializers.py +334 -0
- django_authnest-0.1.0/authnest/services/__init__.py +0 -0
- django_authnest-0.1.0/authnest/services/cookies.py +47 -0
- django_authnest-0.1.0/authnest/services/emails.py +98 -0
- django_authnest-0.1.0/authnest/services/tokens.py +84 -0
- django_authnest-0.1.0/authnest/templates/authnest/email/account_deleted.html +11 -0
- django_authnest-0.1.0/authnest/templates/authnest/email/account_deleted.txt +5 -0
- django_authnest-0.1.0/authnest/templates/authnest/email/base.html +8 -0
- django_authnest-0.1.0/authnest/templates/authnest/email/email_change.html +12 -0
- django_authnest-0.1.0/authnest/templates/authnest/email/email_change.txt +8 -0
- django_authnest-0.1.0/authnest/templates/authnest/email/email_changed_notice.html +7 -0
- django_authnest-0.1.0/authnest/templates/authnest/email/email_changed_notice.txt +5 -0
- django_authnest-0.1.0/authnest/templates/authnest/email/password_changed.html +7 -0
- django_authnest-0.1.0/authnest/templates/authnest/email/password_changed.txt +6 -0
- django_authnest-0.1.0/authnest/templates/authnest/email/password_reset.html +12 -0
- django_authnest-0.1.0/authnest/templates/authnest/email/password_reset.txt +8 -0
- django_authnest-0.1.0/authnest/templates/authnest/email/verify_email.html +12 -0
- django_authnest-0.1.0/authnest/templates/authnest/email/verify_email.txt +7 -0
- django_authnest-0.1.0/authnest/urls.py +24 -0
- django_authnest-0.1.0/authnest/utils.py +9 -0
- django_authnest-0.1.0/authnest/views.py +377 -0
- django_authnest-0.1.0/django_authnest.egg-info/PKG-INFO +26 -0
- django_authnest-0.1.0/django_authnest.egg-info/SOURCES.txt +46 -0
- django_authnest-0.1.0/django_authnest.egg-info/dependency_links.txt +1 -0
- django_authnest-0.1.0/django_authnest.egg-info/requires.txt +9 -0
- django_authnest-0.1.0/django_authnest.egg-info/top_level.txt +1 -0
- django_authnest-0.1.0/pyproject.toml +45 -0
- django_authnest-0.1.0/setup.cfg +4 -0
- django_authnest-0.1.0/tests/test_account.py +416 -0
- django_authnest-0.1.0/tests/test_backend.py +105 -0
- django_authnest-0.1.0/tests/test_conf.py +42 -0
- django_authnest-0.1.0/tests/test_login_tokens.py +359 -0
- django_authnest-0.1.0/tests/test_managers.py +203 -0
- django_authnest-0.1.0/tests/test_password.py +349 -0
- django_authnest-0.1.0/tests/test_profile.py +247 -0
- django_authnest-0.1.0/tests/test_register_verify.py +232 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mithalesh Pal
|
|
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.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-authnest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reusable Django REST auth: email/phone/username login, JWT, email verification, password flows, profile, soft delete.
|
|
5
|
+
Author-email: Mithalesh Pal <mithleshpallearning@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mith1995/custom-auth
|
|
8
|
+
Project-URL: Issues, https://github.com/mith1995/custom-auth/issues
|
|
9
|
+
Keywords: django,drf,authentication,jwt,auth
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 5.1
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: django>=5.1
|
|
19
|
+
Requires-Dist: djangorestframework
|
|
20
|
+
Requires-Dist: djangorestframework-simplejwt
|
|
21
|
+
Requires-Dist: pillow
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-django; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from django.contrib import admin, messages
|
|
2
|
+
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
|
3
|
+
|
|
4
|
+
from django import forms
|
|
5
|
+
from django.contrib.auth.forms import BaseUserCreationForm
|
|
6
|
+
|
|
7
|
+
from .conf import authnest_settings
|
|
8
|
+
|
|
9
|
+
from .models import User
|
|
10
|
+
from .utils import normalize_email, normalize_phone
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeletedFilter(admin.SimpleListFilter):
|
|
14
|
+
title = "account status"
|
|
15
|
+
parameter_name = "account"
|
|
16
|
+
|
|
17
|
+
def lookups(self, request, model_admin):
|
|
18
|
+
return [("alive", "Alive"), ("deleted", "Soft deleted")]
|
|
19
|
+
|
|
20
|
+
def queryset(self, request, queryset):
|
|
21
|
+
if self.value() == "alive":
|
|
22
|
+
return queryset.alive()
|
|
23
|
+
if self.value() == "deleted":
|
|
24
|
+
return queryset.deleted()
|
|
25
|
+
return queryset
|
|
26
|
+
|
|
27
|
+
class AdminUserCreationForm(BaseUserCreationForm):
|
|
28
|
+
class Meta(BaseUserCreationForm.Meta):
|
|
29
|
+
model = User
|
|
30
|
+
fields = ("username", "email", "phone")
|
|
31
|
+
|
|
32
|
+
def clean_email(self):
|
|
33
|
+
email = normalize_email(self.cleaned_data.get("email"))
|
|
34
|
+
# all_objects: soft-deleted user ki email bhi reserved hai
|
|
35
|
+
if email and User.all_objects.filter(email__iexact=email).exists():
|
|
36
|
+
raise forms.ValidationError("Is email se account pehle se maujood hai.")
|
|
37
|
+
return email
|
|
38
|
+
|
|
39
|
+
def clean_phone(self):
|
|
40
|
+
phone = normalize_phone(self.cleaned_data.get("phone"))
|
|
41
|
+
if phone and User.all_objects.filter(phone=phone).exists():
|
|
42
|
+
raise forms.ValidationError("Is phone number se account pehle se maujood hai.")
|
|
43
|
+
return phone
|
|
44
|
+
|
|
45
|
+
def clean(self):
|
|
46
|
+
cleaned = super().clean()
|
|
47
|
+
if not cleaned.get("email") and not cleaned.get("phone"):
|
|
48
|
+
raise forms.ValidationError(
|
|
49
|
+
"Email ya phone mein se kam se kam ek dena zaroori hai."
|
|
50
|
+
)
|
|
51
|
+
return cleaned
|
|
52
|
+
|
|
53
|
+
@admin.register(User)
|
|
54
|
+
class UserAdmin(DjangoUserAdmin):
|
|
55
|
+
list_display = (
|
|
56
|
+
"identifier", "email", "phone", "is_staff", "is_active",
|
|
57
|
+
"is_email_verified", "status", "date_joined",
|
|
58
|
+
)
|
|
59
|
+
list_filter = (DeletedFilter, "is_staff", "is_active", "is_email_verified")
|
|
60
|
+
search_fields = ("email", "phone", "username", "first_name", "last_name")
|
|
61
|
+
ordering = ("-date_joined",)
|
|
62
|
+
readonly_fields = ("last_login", "date_joined", "deleted_at")
|
|
63
|
+
filter_horizontal = ("groups", "user_permissions")
|
|
64
|
+
actions = ["restore_users"]
|
|
65
|
+
|
|
66
|
+
fieldsets = (
|
|
67
|
+
(None, {"fields": ("username", "password")}),
|
|
68
|
+
("Identity", {"fields": ("email", "phone", "is_email_verified", "is_phone_verified")}),
|
|
69
|
+
("Profile", {"fields": ("first_name", "last_name", "dob", "profile_picture")}),
|
|
70
|
+
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
|
|
71
|
+
("Dates", {"fields": ("last_login", "date_joined", "deleted_at")}),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def get_queryset(self, request):
|
|
75
|
+
# Soft delete on ho to all_objects (deleted bhi dikhein), off ho to sirf alive
|
|
76
|
+
manager = (
|
|
77
|
+
self.model.all_objects
|
|
78
|
+
if authnest_settings.SOFT_DELETE
|
|
79
|
+
else self.model.objects
|
|
80
|
+
)
|
|
81
|
+
queryset = manager.all()
|
|
82
|
+
ordering = self.get_ordering(request)
|
|
83
|
+
return queryset.order_by(*ordering) if ordering else queryset
|
|
84
|
+
|
|
85
|
+
def get_list_display(self, request):
|
|
86
|
+
if authnest_settings.SOFT_DELETE:
|
|
87
|
+
return self.list_display
|
|
88
|
+
return [column for column in self.list_display if column != "status"]
|
|
89
|
+
|
|
90
|
+
def get_list_filter(self, request):
|
|
91
|
+
if authnest_settings.SOFT_DELETE:
|
|
92
|
+
return self.list_filter
|
|
93
|
+
return [item for item in self.list_filter if item is not DeletedFilter]
|
|
94
|
+
|
|
95
|
+
def get_actions(self, request):
|
|
96
|
+
actions = super().get_actions(request)
|
|
97
|
+
if not authnest_settings.SOFT_DELETE:
|
|
98
|
+
actions.pop("restore_users", None)
|
|
99
|
+
actions.pop("hard_delete_users", None)
|
|
100
|
+
return actions
|
|
101
|
+
|
|
102
|
+
def get_fieldsets(self, request, obj=None):
|
|
103
|
+
fieldsets = super().get_fieldsets(request, obj)
|
|
104
|
+
if obj is None or authnest_settings.SOFT_DELETE:
|
|
105
|
+
return fieldsets
|
|
106
|
+
return [
|
|
107
|
+
(
|
|
108
|
+
name,
|
|
109
|
+
{
|
|
110
|
+
**options,
|
|
111
|
+
"fields": tuple(f for f in options["fields"] if f != "deleted_at"),
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
for name, options in fieldsets
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
def has_add_permission(self, request):
|
|
118
|
+
# Users registration ya createsuperuser se bante hain, admin me nahi
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
add_form = AdminUserCreationForm
|
|
122
|
+
add_fieldsets = (
|
|
123
|
+
(
|
|
124
|
+
None,
|
|
125
|
+
{
|
|
126
|
+
"classes": ("wide",),
|
|
127
|
+
"fields": ("username", "email", "phone", "password1", "password2"),
|
|
128
|
+
},
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@admin.display(description="User")
|
|
133
|
+
def identifier(self, obj):
|
|
134
|
+
return str(obj)
|
|
135
|
+
|
|
136
|
+
@admin.display(description="Status")
|
|
137
|
+
def status(self, obj):
|
|
138
|
+
return "Deleted" if obj.is_deleted else "Alive"
|
|
139
|
+
|
|
140
|
+
@admin.action(description="Restore selected users")
|
|
141
|
+
def restore_users(self, request, queryset):
|
|
142
|
+
count = queryset.deleted().restore()
|
|
143
|
+
self.message_user(request, f"{count} user restore ho gaye.", messages.SUCCESS)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
class AuthnestConfig(AppConfig):
|
|
4
|
+
name = "authnest"
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
verbose_name = "Authnest"
|
|
7
|
+
|
|
8
|
+
def ready(self):
|
|
9
|
+
from .conf import authnest_settings
|
|
10
|
+
authnest_settings.validate()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from django.contrib.auth import get_user_model
|
|
2
|
+
from django.contrib.auth.backends import ModelBackend
|
|
3
|
+
|
|
4
|
+
from .conf import authnest_settings
|
|
5
|
+
from .utils import normalize_phone, normalize_email
|
|
6
|
+
|
|
7
|
+
class AuthnestBackend(ModelBackend):
|
|
8
|
+
"""Email / phone / username se login. Allowed methods settings se aate hain."""
|
|
9
|
+
|
|
10
|
+
def authenticate(self, request, username = None, password = None, scope="admin", **kwargs):
|
|
11
|
+
|
|
12
|
+
if scope not in ("user", "admin") or not username or password is None:
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
UserModel = get_user_model()
|
|
16
|
+
user = self._find_user(UserModel, username.strip(), scope)
|
|
17
|
+
|
|
18
|
+
if user is None:
|
|
19
|
+
# User na mile tab bhi hash chalao, taaki response time se pata na chale
|
|
20
|
+
UserModel().set_password(password)
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
if user.check_password(password) and self.user_can_authenticate(user):
|
|
24
|
+
return user
|
|
25
|
+
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def _find_user(self, UserModel, identifier, scope):
|
|
29
|
+
if scope == "user":
|
|
30
|
+
allowed = authnest_settings.USER_LOGIN_METHODS
|
|
31
|
+
else:
|
|
32
|
+
allowed = authnest_settings.ADMIN_LOGIN_METHODS
|
|
33
|
+
|
|
34
|
+
lookups = []
|
|
35
|
+
if "email" in allowed and "@" in identifier:
|
|
36
|
+
lookups.append({"email__iexact": normalize_email(identifier)})
|
|
37
|
+
if "phone" in allowed and "@" not in identifier:
|
|
38
|
+
lookups.append({"phone": normalize_phone(identifier)})
|
|
39
|
+
if "username" in allowed:
|
|
40
|
+
lookups.append({"username": identifier})
|
|
41
|
+
|
|
42
|
+
for lookup in lookups:
|
|
43
|
+
user = UserModel._default_manager.filter(**lookup).first()
|
|
44
|
+
if user is not None and user.is_staff == (scope == "admin"):
|
|
45
|
+
return user
|
|
46
|
+
return None
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
3
|
+
|
|
4
|
+
DEFAULTS = {
|
|
5
|
+
"SOFT_DELETE": True,
|
|
6
|
+
"USER_LOGIN_METHODS": ["email"],
|
|
7
|
+
"ADMIN_LOGIN_METHODS": ["username"],
|
|
8
|
+
"REQUIRE_EMAIL_VERIFICATION": True,
|
|
9
|
+
"ENABLE_2FA": False,
|
|
10
|
+
"SEND_EMAILS_ASYNC": False,
|
|
11
|
+
"FRONTEND_URL": "http://localhost:5173",
|
|
12
|
+
"VERIFY_EMAIL_PATH": "/verify-email", # frontend ka route jahan verify link khulega (jaise /verify-email)
|
|
13
|
+
"RESET_PASSWORD_PATH": "/reset-password", # reset password page ka route (jaise /reset-password)
|
|
14
|
+
"EMAIL_VERIFICATION_TIMEOUT": 24 * 3600, # verify link kitne seconds me expire ho (jaise 1 din)
|
|
15
|
+
"PASSWORD_RESET_TIMEOUT": 3600, # reset link ki expiry (jaise 1 ghanta)
|
|
16
|
+
"ACCESS_TOKEN_LIFETIME": 15 * 60, # seconds (15 minute)
|
|
17
|
+
"REFRESH_TOKEN_LIFETIME": 7 * 24 * 3600, # seconds (7 din)
|
|
18
|
+
"REFRESH_TOKEN_IN_COOKIE": True, # True: httpOnly cookie, False: response body
|
|
19
|
+
"REFRESH_TOKEN_ROTATION": True, # har refresh par naya token, purana blacklist
|
|
20
|
+
"REFRESH_COOKIE_NAME": "authnest_refresh",
|
|
21
|
+
"REFRESH_COOKIE_PATH": "/",
|
|
22
|
+
"REFRESH_COOKIE_SECURE": None, # None = auto (DEBUG band ho to Secure)
|
|
23
|
+
"REFRESH_COOKIE_SAMESITE": "Lax",
|
|
24
|
+
"PROFILE_PICTURE_MAX_SIZE": 2 * 1024 * 1024, # 2MB
|
|
25
|
+
"CONFIRM_EMAIL_CHANGE_PATH": "/confirm-email-change",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ALLOWED_USER_LOGIN_METHODS = {"email", "phone"}
|
|
29
|
+
ALLOWED_ADMIN_LOGIN_METHODS = {"email", "username"}
|
|
30
|
+
|
|
31
|
+
class AuthnestSettings:
|
|
32
|
+
def __getattr__(self, name):
|
|
33
|
+
# settings.py me Authnest na ho to khali dict (lazy: access ke time padha)
|
|
34
|
+
user_settings = getattr(settings, "AUTHNEST", {})
|
|
35
|
+
|
|
36
|
+
if name in user_settings:
|
|
37
|
+
return user_settings[name]
|
|
38
|
+
if name in DEFAULTS:
|
|
39
|
+
return DEFAULTS[name]
|
|
40
|
+
raise AttributeError(f"Invalid Authnest setting: '{name}'")
|
|
41
|
+
|
|
42
|
+
def validate(self):
|
|
43
|
+
self.validate_methods("USER_LOGIN_METHODS", ALLOWED_USER_LOGIN_METHODS)
|
|
44
|
+
self.validate_methods("ADMIN_LOGIN_METHODS", ALLOWED_ADMIN_LOGIN_METHODS)
|
|
45
|
+
|
|
46
|
+
if not isinstance(self.SOFT_DELETE, bool):
|
|
47
|
+
raise ImproperlyConfigured(
|
|
48
|
+
"AUTHNEST['SOFT_DELETE'] must be True or False."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
for name in ("ACCESS_TOKEN_LIFETIME", "REFRESH_TOKEN_LIFETIME"):
|
|
52
|
+
value = getattr(self, name)
|
|
53
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
54
|
+
raise ImproperlyConfigured(
|
|
55
|
+
f"AUTHNEST['{name}'] must be a positive integer (seconds)."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
for name in ("REFRESH_TOKEN_IN_COOKIE", "REFRESH_TOKEN_ROTATION"):
|
|
59
|
+
if not isinstance(getattr(self, name), bool):
|
|
60
|
+
raise ImproperlyConfigured(
|
|
61
|
+
f"AUTHNEST['{name}'] must be True or False."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if self.REFRESH_COOKIE_SAMESITE not in ("Lax", "Strict", "None"):
|
|
65
|
+
raise ImproperlyConfigured(
|
|
66
|
+
"AUTHNEST['REFRESH_COOKIE_SAMESITE'] must be 'Lax', 'Strict', or 'None'."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if "rest_framework_simplejwt.token_blacklist" not in settings.INSTALLED_APPS:
|
|
70
|
+
raise ImproperlyConfigured(
|
|
71
|
+
"Add 'rest_framework_simplejwt.token_blacklist' to INSTALLED_APPS "
|
|
72
|
+
"(Authnest needs it for logout and refresh token rotation)."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
size = self.PROFILE_PICTURE_MAX_SIZE
|
|
76
|
+
if isinstance(size, bool) or not (isinstance(size, int)) or size <= 0:
|
|
77
|
+
raise ImproperlyConfigured(
|
|
78
|
+
f"AUTHNEST['PROFILE_PICTURE_MAX_SIZE'] must be a positive integer (bytes)."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def validate_methods(self, setting_name, allowed):
|
|
82
|
+
methods = getattr(self, setting_name)
|
|
83
|
+
|
|
84
|
+
if not isinstance(methods, (list, tuple, set)) or not methods:
|
|
85
|
+
raise ImproperlyConfigured(
|
|
86
|
+
f"AUTHNEST['{setting_name}'] must be a non-empty list. "
|
|
87
|
+
f"Allowed values: {sorted(allowed)}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
invalid = set(methods) - allowed
|
|
91
|
+
if invalid:
|
|
92
|
+
raise ImproperlyConfigured(
|
|
93
|
+
f"AUTHNEST['{setting_name}'] has invalid values {sorted(invalid)}. "
|
|
94
|
+
f"Allowed values: {sorted(allowed)}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
authnest_settings = AuthnestSettings()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from rest_framework import status
|
|
2
|
+
from rest_framework.exceptions import APIException
|
|
3
|
+
|
|
4
|
+
class InvalidCredentials(APIException):
|
|
5
|
+
status_code = status.HTTP_401_UNAUTHORIZED
|
|
6
|
+
default_detail = {
|
|
7
|
+
"detail": "Login details galat hain",
|
|
8
|
+
"code": "invalid_credentials",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class EmailNotVerified(APIException):
|
|
12
|
+
status_code = status.HTTP_403_FORBIDDEN
|
|
13
|
+
default_detail = {
|
|
14
|
+
"detail": "Pehle apna email verify karein.",
|
|
15
|
+
"code": "email_not_verified",
|
|
16
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
from django.contrib.auth.models import BaseUserManager
|
|
4
|
+
from django.db import models
|
|
5
|
+
from django.utils import timezone
|
|
6
|
+
from .utils import normalize_email, normalize_phone
|
|
7
|
+
from .conf import authnest_settings
|
|
8
|
+
|
|
9
|
+
class UserQuerySet(models.QuerySet):
|
|
10
|
+
def alive(self):
|
|
11
|
+
return self.filter(deleted_at__isnull=True)
|
|
12
|
+
|
|
13
|
+
def deleted(self):
|
|
14
|
+
return self.filter(deleted_at__isnull=False)
|
|
15
|
+
|
|
16
|
+
def delete(self):
|
|
17
|
+
if not authnest_settings.SOFT_DELETE:
|
|
18
|
+
return self.hard_delete()
|
|
19
|
+
|
|
20
|
+
# Jo rows pehle se soft-deleted hain unke pk abhi nikalo (update se pehle)
|
|
21
|
+
already_deleted_pks = list(self.deleted().values_list("pk", flat=True))
|
|
22
|
+
|
|
23
|
+
# Alive rows soft delete
|
|
24
|
+
soft_count = self.alive().update(deleted_at=timezone.now(), is_active=False)
|
|
25
|
+
|
|
26
|
+
# Pehle se soft-deleted rows asli me delete
|
|
27
|
+
total, per_model = 0, {}
|
|
28
|
+
if already_deleted_pks:
|
|
29
|
+
total, per_model = self.model.all_objects.filter(
|
|
30
|
+
pk__in=already_deleted_pks
|
|
31
|
+
).hard_delete()
|
|
32
|
+
|
|
33
|
+
per_model = dict(per_model)
|
|
34
|
+
if soft_count:
|
|
35
|
+
label = self.model._meta.label
|
|
36
|
+
per_model[label] = per_model.get(label, 0) + soft_count
|
|
37
|
+
return total + soft_count, per_model
|
|
38
|
+
delete.alters_data = True
|
|
39
|
+
delete.queryset_only = True
|
|
40
|
+
|
|
41
|
+
def hard_delete(self):
|
|
42
|
+
return super().delete()
|
|
43
|
+
hard_delete.alters_data = True
|
|
44
|
+
hard_delete.queryset_only = True
|
|
45
|
+
|
|
46
|
+
def restore(self):
|
|
47
|
+
return self.update(deleted_at=None, is_active=True)
|
|
48
|
+
restore.queryset_only = True
|
|
49
|
+
restore.alters_data = True
|
|
50
|
+
|
|
51
|
+
class UserManager(BaseUserManager.from_queryset(UserQuerySet)):
|
|
52
|
+
"""objects: sirf alive users. Normal app code isi se kaam karega."""
|
|
53
|
+
|
|
54
|
+
use_in_migrations = True
|
|
55
|
+
|
|
56
|
+
def get_queryset(self):
|
|
57
|
+
return super().get_queryset().alive()
|
|
58
|
+
|
|
59
|
+
# ---------- helpers ----------
|
|
60
|
+
def _generate_username(self):
|
|
61
|
+
while True:
|
|
62
|
+
username = f"user_{uuid.uuid4().hex[:12]}"
|
|
63
|
+
if not self.model._base_manager.filter(username=username).exists():
|
|
64
|
+
return username
|
|
65
|
+
|
|
66
|
+
# ---------- creation ----------
|
|
67
|
+
def create_user(self, email=None, phone=None, password=None, username=None, **extra_fields):
|
|
68
|
+
email = normalize_email(email)
|
|
69
|
+
phone = normalize_phone(phone)
|
|
70
|
+
if not email and not phone:
|
|
71
|
+
raise ValueError("Email ya phone mein se kam se kam ek zaroori hai")
|
|
72
|
+
|
|
73
|
+
extra_fields.setdefault("is_staff", False)
|
|
74
|
+
extra_fields.setdefault("is_superuser", False)
|
|
75
|
+
|
|
76
|
+
user = self.model(
|
|
77
|
+
email=email,
|
|
78
|
+
phone=phone,
|
|
79
|
+
username=username or self._generate_username(),
|
|
80
|
+
**extra_fields,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
user.set_password(password)
|
|
84
|
+
user.save(using=self._db)
|
|
85
|
+
return user
|
|
86
|
+
|
|
87
|
+
def create_superuser(self, username, email=None, password=None, **extra_fields):
|
|
88
|
+
extra_fields.setdefault("is_staff", True)
|
|
89
|
+
extra_fields.setdefault("is_superuser", True)
|
|
90
|
+
extra_fields.setdefault("is_email_verified", True)
|
|
91
|
+
|
|
92
|
+
if extra_fields["is_staff"] is not True:
|
|
93
|
+
raise ValueError("Superuser ke liye is_staff=True zaroori hai.")
|
|
94
|
+
if extra_fields["is_superuser"] is not True:
|
|
95
|
+
raise ValueError("Superuser ke liye is_superuser=True zaroori hai.")
|
|
96
|
+
|
|
97
|
+
return self.create_user(email=email, password=password, username=username, **extra_fields)
|
|
98
|
+
|
|
99
|
+
class AllUsersManager(models.Manager.from_queryset(UserQuerySet)):
|
|
100
|
+
"""all_objects: deleted users bhi. Registration check, restore aur admin ke liye."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Generated by Django 6.1.2 on 2026-10-31 06:34
|
|
2
|
+
|
|
3
|
+
import authnest.managers
|
|
4
|
+
import django.core.validators
|
|
5
|
+
import django.utils.timezone
|
|
6
|
+
from django.db import migrations, models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
|
|
11
|
+
initial = True
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
('auth', '0012_alter_user_first_name_max_length'),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
operations = [
|
|
18
|
+
migrations.CreateModel(
|
|
19
|
+
name='User',
|
|
20
|
+
fields=[
|
|
21
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
22
|
+
('password', models.CharField(max_length=128, verbose_name='password')),
|
|
23
|
+
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
|
24
|
+
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
|
25
|
+
('email', models.EmailField(blank=True, max_length=255, null=True, unique=True)),
|
|
26
|
+
('phone', models.CharField(blank=True, max_length=16, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Phone number must be international format, like +919876543210', regex='^\\+[1-9]\\d{7,14}$')])),
|
|
27
|
+
('username', models.CharField(blank=True, max_length=150, null=True, unique=True)),
|
|
28
|
+
('first_name', models.CharField(blank=True, max_length=150)),
|
|
29
|
+
('last_name', models.CharField(blank=True, max_length=150)),
|
|
30
|
+
('dob', models.DateField(blank=True, null=True)),
|
|
31
|
+
('profile_picture', models.ImageField(blank=True, null=True, upload_to='authnest/profiles/')),
|
|
32
|
+
('is_active', models.BooleanField(default=True)),
|
|
33
|
+
('is_staff', models.BooleanField(default=False)),
|
|
34
|
+
('is_email_verified', models.BooleanField(default=False)),
|
|
35
|
+
('is_phone_verified', models.BooleanField(default=False)),
|
|
36
|
+
('date_joined', models.DateTimeField(default=django.utils.timezone.now)),
|
|
37
|
+
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
|
38
|
+
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
|
39
|
+
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
|
40
|
+
],
|
|
41
|
+
options={
|
|
42
|
+
'verbose_name': 'user',
|
|
43
|
+
'verbose_name_plural': 'users',
|
|
44
|
+
'constraints': [models.CheckConstraint(condition=models.Q(('email__isnull', False), ('phone__isnull', False), _connector='OR'), name='authnest_user_email_or_phone_required')],
|
|
45
|
+
},
|
|
46
|
+
managers=[
|
|
47
|
+
('objects', authnest.managers.UserManager()),
|
|
48
|
+
],
|
|
49
|
+
),
|
|
50
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
|
|
3
|
+
from django.core.validators import RegexValidator
|
|
4
|
+
from django.db.models import Q
|
|
5
|
+
from django.utils import timezone
|
|
6
|
+
from .conf import authnest_settings
|
|
7
|
+
from .managers import AllUsersManager, UserManager
|
|
8
|
+
|
|
9
|
+
phone_validator = RegexValidator(
|
|
10
|
+
regex=r"^\+[1-9]\d{7,14}$",
|
|
11
|
+
message="Phone number must be international format, like +919876543210"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
class User(AbstractBaseUser, PermissionsMixin):
|
|
15
|
+
# --- Identity (login ke liye) ---
|
|
16
|
+
email = models.EmailField(
|
|
17
|
+
max_length=255,
|
|
18
|
+
unique=True,
|
|
19
|
+
null=True,
|
|
20
|
+
blank=True
|
|
21
|
+
)
|
|
22
|
+
phone = models.CharField(
|
|
23
|
+
max_length=16,
|
|
24
|
+
unique=True,
|
|
25
|
+
null=True,
|
|
26
|
+
blank=True,
|
|
27
|
+
validators=[phone_validator]
|
|
28
|
+
)
|
|
29
|
+
username = models.CharField(
|
|
30
|
+
max_length=150,
|
|
31
|
+
unique=True,
|
|
32
|
+
null=True,
|
|
33
|
+
blank=True
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# --- Profile ---
|
|
37
|
+
first_name = models.CharField(max_length=150, blank=True)
|
|
38
|
+
last_name = models.CharField(max_length=150, blank=True)
|
|
39
|
+
dob = models.DateField(null=True, blank=True)
|
|
40
|
+
profile_picture = models.ImageField(
|
|
41
|
+
upload_to="authnest/profiles/",
|
|
42
|
+
null=True,
|
|
43
|
+
blank=True
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# --- Status ---
|
|
47
|
+
is_active = models.BooleanField(default=True)
|
|
48
|
+
is_staff = models.BooleanField(default=False)
|
|
49
|
+
is_email_verified = models.BooleanField(default=False)
|
|
50
|
+
is_phone_verified = models.BooleanField(default=False)
|
|
51
|
+
date_joined = models.DateTimeField(default=timezone.now)
|
|
52
|
+
|
|
53
|
+
# --- Soft delete ---
|
|
54
|
+
deleted_at = models.DateTimeField(null=True, blank=True)
|
|
55
|
+
|
|
56
|
+
objects = UserManager()
|
|
57
|
+
all_objects = AllUsersManager()
|
|
58
|
+
|
|
59
|
+
USERNAME_FIELD = "username"
|
|
60
|
+
EMAIL_FIELD = "email"
|
|
61
|
+
REQUIRED_FIELDS = ["email"]
|
|
62
|
+
|
|
63
|
+
class Meta:
|
|
64
|
+
verbose_name = "user"
|
|
65
|
+
verbose_name_plural = "users"
|
|
66
|
+
constraints = [
|
|
67
|
+
models.CheckConstraint(
|
|
68
|
+
condition=Q(email__isnull=False) | Q(phone__isnull=False),
|
|
69
|
+
name="authnest_user_email_or_phone_required"
|
|
70
|
+
)
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
def __str__(self):
|
|
74
|
+
return self.email or self.phone or self.username or f"User {self.pk}"
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def is_deleted(self):
|
|
78
|
+
return self.deleted_at is not None
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def full_name(self):
|
|
82
|
+
return f"{self.first_name} {self.last_name}".strip()
|
|
83
|
+
|
|
84
|
+
def delete(self, using=None, keep_parents=False):
|
|
85
|
+
if not authnest_settings.SOFT_DELETE or self.is_deleted:
|
|
86
|
+
return self.hard_delete(using=using, keep_parents=keep_parents)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
self.deleted_at = timezone.now()
|
|
90
|
+
self.is_active = False
|
|
91
|
+
self.save(update_fields=["deleted_at", "is_active"])
|
|
92
|
+
|
|
93
|
+
def restore(self):
|
|
94
|
+
self.deleted_at = None
|
|
95
|
+
self.is_active = True
|
|
96
|
+
self.save(update_fields=["deleted_at", "is_active"])
|
|
97
|
+
|
|
98
|
+
def hard_delete(self, using=None, keep_parents=False):
|
|
99
|
+
# Asli delete, jo Django ka original delete() chalata hai
|
|
100
|
+
return super().delete(using=using, keep_parents=keep_parents)
|