humanoid-login 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.
- humanoid_login-0.1.0/.gitignore +10 -0
- humanoid_login-0.1.0/LICENSE +21 -0
- humanoid_login-0.1.0/MANIFEST.in +5 -0
- humanoid_login-0.1.0/PKG-INFO +218 -0
- humanoid_login-0.1.0/README.md +177 -0
- humanoid_login-0.1.0/humanoid_login/__init__.py +10 -0
- humanoid_login-0.1.0/humanoid_login/apps.py +13 -0
- humanoid_login-0.1.0/humanoid_login/authentication.py +25 -0
- humanoid_login-0.1.0/humanoid_login/exceptions.py +21 -0
- humanoid_login-0.1.0/humanoid_login/permissions.py +7 -0
- humanoid_login-0.1.0/humanoid_login/py.typed +1 -0
- humanoid_login-0.1.0/humanoid_login/serializers.py +27 -0
- humanoid_login-0.1.0/humanoid_login/services.py +152 -0
- humanoid_login-0.1.0/humanoid_login/settings.py +61 -0
- humanoid_login-0.1.0/humanoid_login/urls.py +16 -0
- humanoid_login-0.1.0/humanoid_login/utils.py +110 -0
- humanoid_login-0.1.0/humanoid_login/views.py +60 -0
- humanoid_login-0.1.0/pyproject.toml +89 -0
- humanoid_login-0.1.0/tests/__init__.py +1 -0
- humanoid_login-0.1.0/tests/conftest.py +28 -0
- humanoid_login-0.1.0/tests/settings.py +44 -0
- humanoid_login-0.1.0/tests/test_auth_flow.py +208 -0
- humanoid_login-0.1.0/tests/urls.py +9 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fardin Ibrahimi
|
|
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,218 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: humanoid-login
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Django REST Framework JWT authentication with HttpOnly cookies.
|
|
5
|
+
Project-URL: Homepage, https://github.com/humanoid-ai/humanoid-login
|
|
6
|
+
Project-URL: Documentation, https://github.com/humanoid-ai/humanoid-login#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/humanoid-ai/humanoid-login
|
|
8
|
+
Project-URL: Issues, https://github.com/humanoid-ai/humanoid-login/issues
|
|
9
|
+
Author: Fardin Ibrahimi
|
|
10
|
+
Maintainer: Fardin Ibrahimi
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: authentication,cookies,django,django-rest-framework,httponly,jwt
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Web Environment
|
|
16
|
+
Classifier: Framework :: Django
|
|
17
|
+
Classifier: Framework :: Django :: 5
|
|
18
|
+
Classifier: Framework :: Django :: 5.0
|
|
19
|
+
Classifier: Framework :: Django :: 5.1
|
|
20
|
+
Classifier: Framework :: Django :: 5.2
|
|
21
|
+
Classifier: Intended Audience :: Developers
|
|
22
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
23
|
+
Classifier: Operating System :: OS Independent
|
|
24
|
+
Classifier: Programming Language :: Python
|
|
25
|
+
Classifier: Programming Language :: Python :: 3
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
29
|
+
Classifier: Typing :: Typed
|
|
30
|
+
Requires-Python: >=3.11
|
|
31
|
+
Requires-Dist: django>=5.0
|
|
32
|
+
Requires-Dist: djangorestframework-simplejwt>=5.3
|
|
33
|
+
Requires-Dist: djangorestframework>=3.15
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest-django>=4.8; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
38
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
39
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
|
|
42
|
+
# humanoid_login
|
|
43
|
+
|
|
44
|
+
**humanoid_login** provides production-ready JWT authentication for Django REST Framework using secure HttpOnly cookies. It wraps `djangorestframework-simplejwt` with ready-made login, logout, refresh, and current-user endpoints so applications can ship cookie-based authentication without duplicating boilerplate.
|
|
45
|
+
|
|
46
|
+
Developed and maintained by **Fardin Ibrahimi**, CEO of **Humanoid**.
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install humanoid-login
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Quick Start
|
|
55
|
+
|
|
56
|
+
Add the app and authentication class:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
INSTALLED_APPS = [
|
|
60
|
+
"django.contrib.auth",
|
|
61
|
+
"django.contrib.contenttypes",
|
|
62
|
+
"rest_framework",
|
|
63
|
+
"humanoid_login",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
REST_FRAMEWORK = {
|
|
67
|
+
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
68
|
+
"humanoid_login.authentication.CookieJWTAuthentication",
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Include the URLs:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from django.urls import include, path
|
|
77
|
+
|
|
78
|
+
urlpatterns = [
|
|
79
|
+
path("", include("humanoid_login.urls")),
|
|
80
|
+
]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Or import views directly:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from humanoid_login.views import LoginView
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## API Endpoints
|
|
90
|
+
|
|
91
|
+
| Method | Path | Description |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| `POST` | `/login/` | Authenticates credentials, returns user data, and sets access and refresh cookies. |
|
|
94
|
+
| `POST` | `/logout/` | Deletes cookies and blacklists the refresh token when SimpleJWT blacklist support is enabled. |
|
|
95
|
+
| `POST` | `/refresh/` | Reads the refresh cookie and issues a new access cookie. |
|
|
96
|
+
| `GET` | `/me/` | Returns the authenticated user's compact profile. |
|
|
97
|
+
|
|
98
|
+
## Login Example
|
|
99
|
+
|
|
100
|
+
```http
|
|
101
|
+
POST /login/
|
|
102
|
+
Content-Type: application/json
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
"email": "user@example.com",
|
|
106
|
+
"password": "correct-password"
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Successful responses set HttpOnly cookies and return:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"detail": "Login successful.",
|
|
115
|
+
"user": {
|
|
116
|
+
"id": 1,
|
|
117
|
+
"email": "user@example.com",
|
|
118
|
+
"name": "John Doe",
|
|
119
|
+
"role": "admin"
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Configuration
|
|
125
|
+
|
|
126
|
+
Override defaults in Django settings:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from datetime import timedelta
|
|
130
|
+
|
|
131
|
+
HUMANOID_LOGIN = {
|
|
132
|
+
"ACCESS_COOKIE": "access_token",
|
|
133
|
+
"REFRESH_COOKIE": "refresh_token",
|
|
134
|
+
"COOKIE_HTTPONLY": True,
|
|
135
|
+
"COOKIE_SECURE": True,
|
|
136
|
+
"COOKIE_SAMESITE": "Lax",
|
|
137
|
+
"COOKIE_PATH": "/",
|
|
138
|
+
"COOKIE_DOMAIN": None,
|
|
139
|
+
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
|
|
140
|
+
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
|
|
141
|
+
"ROTATE_REFRESH_TOKENS": False,
|
|
142
|
+
"BLACKLIST_AFTER_ROTATION": True,
|
|
143
|
+
"USER_ROLE_ATTRIBUTE": "role",
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Recommended Production Settings
|
|
148
|
+
|
|
149
|
+
Use HTTPS and secure cookies in production:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
HUMANOID_LOGIN = {
|
|
153
|
+
"COOKIE_SECURE": True,
|
|
154
|
+
"COOKIE_SAMESITE": "Lax",
|
|
155
|
+
"COOKIE_HTTPONLY": True,
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
If your frontend and API are on different sites, configure CORS and CSRF deliberately and choose `COOKIE_SAMESITE="None"` only with `COOKIE_SECURE=True`.
|
|
160
|
+
|
|
161
|
+
## Authentication Flow
|
|
162
|
+
|
|
163
|
+
1. `LoginView` validates email and password through `LoginSerializer`.
|
|
164
|
+
2. `AuthService` authenticates with Django's configured authentication backends.
|
|
165
|
+
3. SimpleJWT access and refresh tokens are generated.
|
|
166
|
+
4. Tokens are stored in configured HttpOnly cookies.
|
|
167
|
+
5. `CookieJWTAuthentication` reads the access cookie and validates it for DRF permissions.
|
|
168
|
+
6. `RefreshView` reads the refresh cookie and issues a new access cookie.
|
|
169
|
+
7. `LogoutView` removes cookies and blacklists refresh tokens when the blacklist app is installed.
|
|
170
|
+
|
|
171
|
+
## Security Notes
|
|
172
|
+
|
|
173
|
+
- Access and refresh tokens are never returned in response bodies.
|
|
174
|
+
- Cookies default to `HttpOnly=True`.
|
|
175
|
+
- Set `COOKIE_SECURE=True` in production.
|
|
176
|
+
- Use short access-token lifetimes and rotate refresh tokens where appropriate.
|
|
177
|
+
- Consider enabling SimpleJWT's blacklist app:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
INSTALLED_APPS = [
|
|
181
|
+
"rest_framework_simplejwt.token_blacklist",
|
|
182
|
+
]
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Run migrations after enabling blacklist support:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
python manage.py migrate
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Testing
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
pip install -e ".[dev]"
|
|
195
|
+
pytest
|
|
196
|
+
python -m build
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Contributing
|
|
200
|
+
|
|
201
|
+
Contributions are welcome. Please open an issue for larger changes, include tests for new behavior, and keep authentication changes small, explicit, and documented.
|
|
202
|
+
|
|
203
|
+
## Changelog
|
|
204
|
+
|
|
205
|
+
### 0.1.0
|
|
206
|
+
|
|
207
|
+
- Initial public release.
|
|
208
|
+
- Cookie-backed JWT login, logout, refresh, and current-user endpoints.
|
|
209
|
+
- Typed package marker and pytest coverage.
|
|
210
|
+
|
|
211
|
+
## About the Author
|
|
212
|
+
|
|
213
|
+
**humanoid_login** is an open-source package developed and maintained by **Fardin Ibrahimi**, CEO of **Humanoid**, with the goal of making secure JWT cookie authentication effortless for Django REST Framework developers.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
Documentation maintained by **Fardin Ibrahimi**, CEO of **Humanoid**.
|
|
218
|
+
# humanoid-login
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# humanoid_login
|
|
2
|
+
|
|
3
|
+
**humanoid_login** provides production-ready JWT authentication for Django REST Framework using secure HttpOnly cookies. It wraps `djangorestframework-simplejwt` with ready-made login, logout, refresh, and current-user endpoints so applications can ship cookie-based authentication without duplicating boilerplate.
|
|
4
|
+
|
|
5
|
+
Developed and maintained by **Fardin Ibrahimi**, CEO of **Humanoid**.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install humanoid-login
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
Add the app and authentication class:
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
INSTALLED_APPS = [
|
|
19
|
+
"django.contrib.auth",
|
|
20
|
+
"django.contrib.contenttypes",
|
|
21
|
+
"rest_framework",
|
|
22
|
+
"humanoid_login",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
REST_FRAMEWORK = {
|
|
26
|
+
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
27
|
+
"humanoid_login.authentication.CookieJWTAuthentication",
|
|
28
|
+
],
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Include the URLs:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from django.urls import include, path
|
|
36
|
+
|
|
37
|
+
urlpatterns = [
|
|
38
|
+
path("", include("humanoid_login.urls")),
|
|
39
|
+
]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or import views directly:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from humanoid_login.views import LoginView
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## API Endpoints
|
|
49
|
+
|
|
50
|
+
| Method | Path | Description |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `POST` | `/login/` | Authenticates credentials, returns user data, and sets access and refresh cookies. |
|
|
53
|
+
| `POST` | `/logout/` | Deletes cookies and blacklists the refresh token when SimpleJWT blacklist support is enabled. |
|
|
54
|
+
| `POST` | `/refresh/` | Reads the refresh cookie and issues a new access cookie. |
|
|
55
|
+
| `GET` | `/me/` | Returns the authenticated user's compact profile. |
|
|
56
|
+
|
|
57
|
+
## Login Example
|
|
58
|
+
|
|
59
|
+
```http
|
|
60
|
+
POST /login/
|
|
61
|
+
Content-Type: application/json
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
"email": "user@example.com",
|
|
65
|
+
"password": "correct-password"
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Successful responses set HttpOnly cookies and return:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"detail": "Login successful.",
|
|
74
|
+
"user": {
|
|
75
|
+
"id": 1,
|
|
76
|
+
"email": "user@example.com",
|
|
77
|
+
"name": "John Doe",
|
|
78
|
+
"role": "admin"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Configuration
|
|
84
|
+
|
|
85
|
+
Override defaults in Django settings:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from datetime import timedelta
|
|
89
|
+
|
|
90
|
+
HUMANOID_LOGIN = {
|
|
91
|
+
"ACCESS_COOKIE": "access_token",
|
|
92
|
+
"REFRESH_COOKIE": "refresh_token",
|
|
93
|
+
"COOKIE_HTTPONLY": True,
|
|
94
|
+
"COOKIE_SECURE": True,
|
|
95
|
+
"COOKIE_SAMESITE": "Lax",
|
|
96
|
+
"COOKIE_PATH": "/",
|
|
97
|
+
"COOKIE_DOMAIN": None,
|
|
98
|
+
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
|
|
99
|
+
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
|
|
100
|
+
"ROTATE_REFRESH_TOKENS": False,
|
|
101
|
+
"BLACKLIST_AFTER_ROTATION": True,
|
|
102
|
+
"USER_ROLE_ATTRIBUTE": "role",
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Recommended Production Settings
|
|
107
|
+
|
|
108
|
+
Use HTTPS and secure cookies in production:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
HUMANOID_LOGIN = {
|
|
112
|
+
"COOKIE_SECURE": True,
|
|
113
|
+
"COOKIE_SAMESITE": "Lax",
|
|
114
|
+
"COOKIE_HTTPONLY": True,
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
If your frontend and API are on different sites, configure CORS and CSRF deliberately and choose `COOKIE_SAMESITE="None"` only with `COOKIE_SECURE=True`.
|
|
119
|
+
|
|
120
|
+
## Authentication Flow
|
|
121
|
+
|
|
122
|
+
1. `LoginView` validates email and password through `LoginSerializer`.
|
|
123
|
+
2. `AuthService` authenticates with Django's configured authentication backends.
|
|
124
|
+
3. SimpleJWT access and refresh tokens are generated.
|
|
125
|
+
4. Tokens are stored in configured HttpOnly cookies.
|
|
126
|
+
5. `CookieJWTAuthentication` reads the access cookie and validates it for DRF permissions.
|
|
127
|
+
6. `RefreshView` reads the refresh cookie and issues a new access cookie.
|
|
128
|
+
7. `LogoutView` removes cookies and blacklists refresh tokens when the blacklist app is installed.
|
|
129
|
+
|
|
130
|
+
## Security Notes
|
|
131
|
+
|
|
132
|
+
- Access and refresh tokens are never returned in response bodies.
|
|
133
|
+
- Cookies default to `HttpOnly=True`.
|
|
134
|
+
- Set `COOKIE_SECURE=True` in production.
|
|
135
|
+
- Use short access-token lifetimes and rotate refresh tokens where appropriate.
|
|
136
|
+
- Consider enabling SimpleJWT's blacklist app:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
INSTALLED_APPS = [
|
|
140
|
+
"rest_framework_simplejwt.token_blacklist",
|
|
141
|
+
]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Run migrations after enabling blacklist support:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
python manage.py migrate
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Testing
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pip install -e ".[dev]"
|
|
154
|
+
pytest
|
|
155
|
+
python -m build
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Contributing
|
|
159
|
+
|
|
160
|
+
Contributions are welcome. Please open an issue for larger changes, include tests for new behavior, and keep authentication changes small, explicit, and documented.
|
|
161
|
+
|
|
162
|
+
## Changelog
|
|
163
|
+
|
|
164
|
+
### 0.1.0
|
|
165
|
+
|
|
166
|
+
- Initial public release.
|
|
167
|
+
- Cookie-backed JWT login, logout, refresh, and current-user endpoints.
|
|
168
|
+
- Typed package marker and pytest coverage.
|
|
169
|
+
|
|
170
|
+
## About the Author
|
|
171
|
+
|
|
172
|
+
**humanoid_login** is an open-source package developed and maintained by **Fardin Ibrahimi**, CEO of **Humanoid**, with the goal of making secure JWT cookie authentication effortless for Django REST Framework developers.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
Documentation maintained by **Fardin Ibrahimi**, CEO of **Humanoid**.
|
|
177
|
+
# humanoid-login
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""JWT authentication for Django REST Framework using HttpOnly cookies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__author__ = "Fardin Ibrahimi"
|
|
7
|
+
__maintainer__ = "Fardin Ibrahimi"
|
|
8
|
+
__company__ = "Humanoid"
|
|
9
|
+
|
|
10
|
+
default_app_config = "humanoid_login.apps.HumanoidLoginConfig"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Django application configuration for humanoid_login."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.apps import AppConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HumanoidLoginConfig(AppConfig):
|
|
9
|
+
"""Application metadata used by Django."""
|
|
10
|
+
|
|
11
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
12
|
+
name = "humanoid_login"
|
|
13
|
+
verbose_name = "Humanoid Login"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Cookie-backed JWT authentication for Django REST Framework."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rest_framework.request import Request
|
|
8
|
+
from rest_framework_simplejwt.authentication import JWTAuthentication
|
|
9
|
+
|
|
10
|
+
from .settings import api_settings
|
|
11
|
+
from .utils import get_request_cookie
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CookieJWTAuthentication(JWTAuthentication):
|
|
15
|
+
"""Authenticate requests using the configured access-token cookie."""
|
|
16
|
+
|
|
17
|
+
def authenticate(self, request: Request) -> tuple[Any, Any] | None:
|
|
18
|
+
"""Return the authenticated user and validated token, if a cookie exists."""
|
|
19
|
+
|
|
20
|
+
raw_token = get_request_cookie(request, api_settings.ACCESS_COOKIE)
|
|
21
|
+
if raw_token is None:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
validated_token = self.get_validated_token(raw_token)
|
|
25
|
+
return self.get_user(validated_token), validated_token
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Package-specific exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rest_framework.exceptions import APIException
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HumanoidLoginError(APIException):
|
|
9
|
+
"""Base exception for package-specific API failures."""
|
|
10
|
+
|
|
11
|
+
status_code = 400
|
|
12
|
+
default_detail = "Authentication request could not be completed."
|
|
13
|
+
default_code = "humanoid_login_error"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MissingRefreshToken(HumanoidLoginError):
|
|
17
|
+
"""Raised when a refresh cookie is required but missing."""
|
|
18
|
+
|
|
19
|
+
status_code = 401
|
|
20
|
+
default_detail = "Refresh token cookie is missing."
|
|
21
|
+
default_code = "missing_refresh_token"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Serializers used by the public authentication views."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rest_framework import serializers
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LoginSerializer(serializers.Serializer[dict[str, str]]):
|
|
11
|
+
"""Validate and normalize login credentials."""
|
|
12
|
+
|
|
13
|
+
email = serializers.EmailField(required=True, trim_whitespace=True)
|
|
14
|
+
password = serializers.CharField(
|
|
15
|
+
required=True,
|
|
16
|
+
trim_whitespace=False,
|
|
17
|
+
write_only=True,
|
|
18
|
+
style={"input_type": "password"},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def validate(self, attrs: dict[str, Any]) -> dict[str, str]:
|
|
22
|
+
"""Return credentials in a predictable shape for the service layer."""
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
"email": str(attrs["email"]).strip().lower(),
|
|
26
|
+
"password": str(attrs["password"]),
|
|
27
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Service layer for cookie-based JWT authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.contrib.auth import authenticate, get_user_model
|
|
8
|
+
from django.contrib.auth.models import AbstractBaseUser
|
|
9
|
+
from django.utils.translation import gettext_lazy as _
|
|
10
|
+
from rest_framework import status
|
|
11
|
+
from rest_framework.exceptions import AuthenticationFailed
|
|
12
|
+
from rest_framework.request import Request
|
|
13
|
+
from rest_framework.response import Response
|
|
14
|
+
from rest_framework_simplejwt.exceptions import TokenError
|
|
15
|
+
from rest_framework_simplejwt.tokens import RefreshToken
|
|
16
|
+
|
|
17
|
+
from .exceptions import MissingRefreshToken
|
|
18
|
+
from .serializers import LoginSerializer
|
|
19
|
+
from .settings import api_settings
|
|
20
|
+
from .utils import delete_auth_cookies, get_request_cookie, serialize_user, set_auth_cookies
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthService:
|
|
24
|
+
"""Business logic for login, logout, refresh, and user responses."""
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def login(cls, request: Request) -> Response:
|
|
28
|
+
"""Validate credentials, authenticate the user, and set auth cookies."""
|
|
29
|
+
|
|
30
|
+
serializer = LoginSerializer(data=request.data)
|
|
31
|
+
serializer.is_valid(raise_exception=True)
|
|
32
|
+
user = cls.authenticate(request, serializer.validated_data)
|
|
33
|
+
access, refresh = cls.generate_tokens(user)
|
|
34
|
+
|
|
35
|
+
response = Response(
|
|
36
|
+
{
|
|
37
|
+
"detail": "Login successful.",
|
|
38
|
+
"user": serialize_user(user),
|
|
39
|
+
},
|
|
40
|
+
status=status.HTTP_200_OK,
|
|
41
|
+
)
|
|
42
|
+
set_auth_cookies(response, access, refresh)
|
|
43
|
+
return response
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def authenticate(request: Request, credentials: dict[str, str]) -> AbstractBaseUser:
|
|
47
|
+
"""Authenticate credentials against Django's configured auth backends."""
|
|
48
|
+
|
|
49
|
+
email = credentials["email"]
|
|
50
|
+
password = credentials["password"]
|
|
51
|
+
UserModel = get_user_model()
|
|
52
|
+
username_field = UserModel.USERNAME_FIELD
|
|
53
|
+
|
|
54
|
+
user = authenticate(
|
|
55
|
+
request=request,
|
|
56
|
+
**{username_field: email, "password": password},
|
|
57
|
+
)
|
|
58
|
+
if user is None and username_field == "username":
|
|
59
|
+
username = AuthService.get_username_for_email(email)
|
|
60
|
+
if username:
|
|
61
|
+
user = authenticate(request=request, username=username, password=password)
|
|
62
|
+
elif user is None:
|
|
63
|
+
user = authenticate(request=request, username=email, password=password)
|
|
64
|
+
if user is None:
|
|
65
|
+
raise AuthenticationFailed(
|
|
66
|
+
_("Unable to log in with the provided credentials."),
|
|
67
|
+
code="invalid_credentials",
|
|
68
|
+
)
|
|
69
|
+
if not user.is_active:
|
|
70
|
+
raise AuthenticationFailed(_("User account is disabled."), code="inactive_user")
|
|
71
|
+
return user
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def get_username_for_email(email: str) -> str | None:
|
|
75
|
+
"""Resolve Django's default username field from a unique email address."""
|
|
76
|
+
|
|
77
|
+
UserModel = get_user_model()
|
|
78
|
+
try:
|
|
79
|
+
user = UserModel._default_manager.get(email__iexact=email)
|
|
80
|
+
except (UserModel.DoesNotExist, UserModel.MultipleObjectsReturned):
|
|
81
|
+
return None
|
|
82
|
+
return str(user.get_username())
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def generate_tokens(user: AbstractBaseUser) -> tuple[Any, RefreshToken]:
|
|
86
|
+
"""Create access and refresh tokens for a user."""
|
|
87
|
+
|
|
88
|
+
refresh = RefreshToken.for_user(user)
|
|
89
|
+
refresh.set_exp(lifetime=api_settings.REFRESH_TOKEN_LIFETIME)
|
|
90
|
+
access = refresh.access_token
|
|
91
|
+
access.set_exp(lifetime=api_settings.ACCESS_TOKEN_LIFETIME)
|
|
92
|
+
return access, refresh
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def logout(cls, request: Request) -> Response:
|
|
96
|
+
"""Delete authentication cookies and blacklist refresh token when possible."""
|
|
97
|
+
|
|
98
|
+
refresh_token = get_request_cookie(request, api_settings.REFRESH_COOKIE)
|
|
99
|
+
if refresh_token:
|
|
100
|
+
cls.blacklist_refresh_token(refresh_token)
|
|
101
|
+
|
|
102
|
+
response = Response({"detail": "Logout successful."}, status=status.HTTP_200_OK)
|
|
103
|
+
delete_auth_cookies(response)
|
|
104
|
+
return response
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def blacklist_refresh_token(refresh_token: str) -> None:
|
|
108
|
+
"""Blacklist a refresh token when SimpleJWT's blacklist app is installed."""
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
token = RefreshToken(refresh_token)
|
|
112
|
+
blacklist = getattr(token, "blacklist", None)
|
|
113
|
+
if callable(blacklist):
|
|
114
|
+
blacklist()
|
|
115
|
+
except TokenError:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def refresh(cls, request: Request) -> Response:
|
|
120
|
+
"""Issue a new access cookie from the configured refresh-token cookie."""
|
|
121
|
+
|
|
122
|
+
refresh_token = get_request_cookie(request, api_settings.REFRESH_COOKIE)
|
|
123
|
+
if not refresh_token:
|
|
124
|
+
raise MissingRefreshToken()
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
refresh = RefreshToken(refresh_token)
|
|
128
|
+
except TokenError as exc:
|
|
129
|
+
raise AuthenticationFailed(_("Refresh token is invalid or expired.")) from exc
|
|
130
|
+
|
|
131
|
+
access = refresh.access_token
|
|
132
|
+
access.set_exp(lifetime=api_settings.ACCESS_TOKEN_LIFETIME)
|
|
133
|
+
rotated_refresh = (
|
|
134
|
+
cls.rotate_refresh_token(refresh) if api_settings.ROTATE_REFRESH_TOKENS else None
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
response = Response({"detail": "Token refreshed."}, status=status.HTTP_200_OK)
|
|
138
|
+
set_auth_cookies(response, access, rotated_refresh)
|
|
139
|
+
return response
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def rotate_refresh_token(refresh: RefreshToken) -> RefreshToken:
|
|
143
|
+
"""Rotate a refresh token using the same behavior as SimpleJWT serializers."""
|
|
144
|
+
|
|
145
|
+
if api_settings.BLACKLIST_AFTER_ROTATION:
|
|
146
|
+
blacklist = getattr(refresh, "blacklist", None)
|
|
147
|
+
if callable(blacklist):
|
|
148
|
+
blacklist()
|
|
149
|
+
refresh.set_jti()
|
|
150
|
+
refresh.set_iat()
|
|
151
|
+
refresh.set_exp(lifetime=api_settings.REFRESH_TOKEN_LIFETIME)
|
|
152
|
+
return refresh
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Runtime settings for humanoid_login.
|
|
2
|
+
|
|
3
|
+
Configuration is read from ``settings.HUMANOID_LOGIN`` and merged with secure,
|
|
4
|
+
documented defaults. Values are resolved lazily so Django tests and projects can
|
|
5
|
+
override settings at runtime.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
from typing import Any, Final
|
|
12
|
+
|
|
13
|
+
from django.conf import settings as django_settings
|
|
14
|
+
|
|
15
|
+
DEFAULTS: Final[dict[str, Any]] = {
|
|
16
|
+
"ACCESS_COOKIE": "access_token",
|
|
17
|
+
"REFRESH_COOKIE": "refresh_token",
|
|
18
|
+
"COOKIE_HTTPONLY": True,
|
|
19
|
+
"COOKIE_SECURE": False,
|
|
20
|
+
"COOKIE_SAMESITE": "Lax",
|
|
21
|
+
"COOKIE_PATH": "/",
|
|
22
|
+
"COOKIE_DOMAIN": None,
|
|
23
|
+
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
|
|
24
|
+
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
|
|
25
|
+
"ROTATE_REFRESH_TOKENS": False,
|
|
26
|
+
"BLACKLIST_AFTER_ROTATION": True,
|
|
27
|
+
"USER_ROLE_ATTRIBUTE": "role",
|
|
28
|
+
"USER_NAME_ATTRIBUTES": ("get_full_name", "name", "first_name", "username"),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HumanoidLoginSettings:
|
|
33
|
+
"""Typed accessor for package settings."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, user_settings: dict[str, Any] | None = None) -> None:
|
|
36
|
+
self._user_settings = user_settings
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def user_settings(self) -> dict[str, Any]:
|
|
40
|
+
"""Return the current ``HUMANOID_LOGIN`` Django setting."""
|
|
41
|
+
|
|
42
|
+
if self._user_settings is not None:
|
|
43
|
+
return self._user_settings
|
|
44
|
+
value = getattr(django_settings, "HUMANOID_LOGIN", {})
|
|
45
|
+
if value is None:
|
|
46
|
+
return {}
|
|
47
|
+
if not isinstance(value, dict):
|
|
48
|
+
msg = "HUMANOID_LOGIN must be a dictionary."
|
|
49
|
+
raise TypeError(msg)
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
def __getattr__(self, attr: str) -> Any:
|
|
53
|
+
"""Resolve known settings with defaults."""
|
|
54
|
+
|
|
55
|
+
if attr not in DEFAULTS:
|
|
56
|
+
msg = f"Invalid humanoid_login setting: {attr}"
|
|
57
|
+
raise AttributeError(msg)
|
|
58
|
+
return self.user_settings.get(attr, DEFAULTS[attr])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
api_settings = HumanoidLoginSettings()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""URL routes provided by humanoid_login."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.urls import path
|
|
6
|
+
|
|
7
|
+
from .views import LoginView, LogoutView, MeView, RefreshView
|
|
8
|
+
|
|
9
|
+
app_name = "humanoid_login"
|
|
10
|
+
|
|
11
|
+
urlpatterns = [
|
|
12
|
+
path("login/", LoginView.as_view(), name="login"),
|
|
13
|
+
path("logout/", LogoutView.as_view(), name="logout"),
|
|
14
|
+
path("refresh/", RefreshView.as_view(), name="refresh"),
|
|
15
|
+
path("me/", MeView.as_view(), name="me"),
|
|
16
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Utility helpers for cookies, tokens, and user serialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.contrib.auth import get_user_model
|
|
9
|
+
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
|
|
10
|
+
from django.http import HttpRequest
|
|
11
|
+
from django.utils.encoding import force_str
|
|
12
|
+
from rest_framework.response import Response
|
|
13
|
+
from rest_framework_simplejwt.tokens import Token
|
|
14
|
+
|
|
15
|
+
from .settings import api_settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def lifetime_to_max_age(lifetime: timedelta | None) -> int | None:
|
|
19
|
+
"""Convert a token lifetime to a cookie ``max_age`` value."""
|
|
20
|
+
|
|
21
|
+
if lifetime is None:
|
|
22
|
+
return None
|
|
23
|
+
return max(0, int(lifetime.total_seconds()))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def set_cookie(
|
|
27
|
+
response: Response,
|
|
28
|
+
name: str,
|
|
29
|
+
value: str,
|
|
30
|
+
max_age: int | None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Set a package-authentication cookie on a response."""
|
|
33
|
+
|
|
34
|
+
response.set_cookie(
|
|
35
|
+
key=name,
|
|
36
|
+
value=value,
|
|
37
|
+
max_age=max_age,
|
|
38
|
+
httponly=api_settings.COOKIE_HTTPONLY,
|
|
39
|
+
secure=api_settings.COOKIE_SECURE,
|
|
40
|
+
samesite=api_settings.COOKIE_SAMESITE,
|
|
41
|
+
path=api_settings.COOKIE_PATH,
|
|
42
|
+
domain=api_settings.COOKIE_DOMAIN,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def set_auth_cookies(response: Response, access: Token, refresh: Token | None) -> None:
|
|
47
|
+
"""Attach access and optional refresh cookies to a response."""
|
|
48
|
+
|
|
49
|
+
set_cookie(
|
|
50
|
+
response,
|
|
51
|
+
api_settings.ACCESS_COOKIE,
|
|
52
|
+
str(access),
|
|
53
|
+
lifetime_to_max_age(api_settings.ACCESS_TOKEN_LIFETIME),
|
|
54
|
+
)
|
|
55
|
+
if refresh is not None:
|
|
56
|
+
set_cookie(
|
|
57
|
+
response,
|
|
58
|
+
api_settings.REFRESH_COOKIE,
|
|
59
|
+
str(refresh),
|
|
60
|
+
lifetime_to_max_age(api_settings.REFRESH_TOKEN_LIFETIME),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def delete_auth_cookies(response: Response) -> None:
|
|
65
|
+
"""Delete access and refresh cookies using the configured cookie scope."""
|
|
66
|
+
|
|
67
|
+
for cookie_name in (api_settings.ACCESS_COOKIE, api_settings.REFRESH_COOKIE):
|
|
68
|
+
response.delete_cookie(
|
|
69
|
+
key=cookie_name,
|
|
70
|
+
path=api_settings.COOKIE_PATH,
|
|
71
|
+
domain=api_settings.COOKIE_DOMAIN,
|
|
72
|
+
samesite=api_settings.COOKIE_SAMESITE,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_request_cookie(request: HttpRequest, cookie_name: str) -> str | None:
|
|
77
|
+
"""Read a cookie value from a Django or DRF request."""
|
|
78
|
+
|
|
79
|
+
value = request.COOKIES.get(cookie_name)
|
|
80
|
+
return force_str(value) if value else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_user_display_name(user: AbstractBaseUser) -> str:
|
|
84
|
+
"""Return a friendly display name for a user."""
|
|
85
|
+
|
|
86
|
+
for attribute in api_settings.USER_NAME_ATTRIBUTES:
|
|
87
|
+
value = getattr(user, attribute, None)
|
|
88
|
+
if callable(value):
|
|
89
|
+
value = value()
|
|
90
|
+
if value:
|
|
91
|
+
return str(value)
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def serialize_user(user: AbstractBaseUser | AnonymousUser) -> dict[str, Any]:
|
|
96
|
+
"""Serialize a user object for authentication responses."""
|
|
97
|
+
|
|
98
|
+
if not user or isinstance(user, AnonymousUser):
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
email = getattr(user, "email", "") or ""
|
|
102
|
+
role = getattr(user, api_settings.USER_ROLE_ATTRIBUTE, None)
|
|
103
|
+
user_id = getattr(user, get_user_model()._meta.pk.attname)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
"id": user_id,
|
|
107
|
+
"email": str(email),
|
|
108
|
+
"name": get_user_display_name(user),
|
|
109
|
+
"role": role,
|
|
110
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Public Django REST Framework views exposed by humanoid_login."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from rest_framework.permissions import IsAuthenticated
|
|
8
|
+
from rest_framework.request import Request
|
|
9
|
+
from rest_framework.response import Response
|
|
10
|
+
from rest_framework.views import APIView
|
|
11
|
+
|
|
12
|
+
from .authentication import CookieJWTAuthentication
|
|
13
|
+
from .services import AuthService
|
|
14
|
+
from .utils import serialize_user
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LoginView(APIView):
|
|
18
|
+
"""Authenticate a user and set HttpOnly JWT cookies."""
|
|
19
|
+
|
|
20
|
+
permission_classes: ClassVar[list[type]] = []
|
|
21
|
+
|
|
22
|
+
def post(self, request: Request) -> Response:
|
|
23
|
+
"""Handle login requests."""
|
|
24
|
+
|
|
25
|
+
return AuthService.login(request)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LogoutView(APIView):
|
|
29
|
+
"""Clear JWT cookies and blacklist refresh tokens when available."""
|
|
30
|
+
|
|
31
|
+
authentication_classes: ClassVar[list[type]] = [CookieJWTAuthentication]
|
|
32
|
+
permission_classes: ClassVar[list[type]] = []
|
|
33
|
+
|
|
34
|
+
def post(self, request: Request) -> Response:
|
|
35
|
+
"""Handle logout requests."""
|
|
36
|
+
|
|
37
|
+
return AuthService.logout(request)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RefreshView(APIView):
|
|
41
|
+
"""Refresh access cookies using the refresh-token cookie."""
|
|
42
|
+
|
|
43
|
+
permission_classes: ClassVar[list[type]] = []
|
|
44
|
+
|
|
45
|
+
def post(self, request: Request) -> Response:
|
|
46
|
+
"""Handle token refresh requests."""
|
|
47
|
+
|
|
48
|
+
return AuthService.refresh(request)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MeView(APIView):
|
|
52
|
+
"""Return the currently authenticated user."""
|
|
53
|
+
|
|
54
|
+
authentication_classes: ClassVar[list[type]] = [CookieJWTAuthentication]
|
|
55
|
+
permission_classes: ClassVar[list[type]] = [IsAuthenticated]
|
|
56
|
+
|
|
57
|
+
def get(self, request: Request) -> Response:
|
|
58
|
+
"""Return a compact user profile."""
|
|
59
|
+
|
|
60
|
+
return Response(serialize_user(request.user))
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "humanoid-login"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Django REST Framework JWT authentication with HttpOnly cookies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Fardin Ibrahimi" }
|
|
14
|
+
]
|
|
15
|
+
maintainers = [
|
|
16
|
+
{ name = "Fardin Ibrahimi" }
|
|
17
|
+
]
|
|
18
|
+
keywords = [
|
|
19
|
+
"django",
|
|
20
|
+
"django-rest-framework",
|
|
21
|
+
"jwt",
|
|
22
|
+
"httponly",
|
|
23
|
+
"cookies",
|
|
24
|
+
"authentication",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 4 - Beta",
|
|
28
|
+
"Environment :: Web Environment",
|
|
29
|
+
"Framework :: Django",
|
|
30
|
+
"Framework :: Django :: 5",
|
|
31
|
+
"Framework :: Django :: 5.0",
|
|
32
|
+
"Framework :: Django :: 5.1",
|
|
33
|
+
"Framework :: Django :: 5.2",
|
|
34
|
+
"Intended Audience :: Developers",
|
|
35
|
+
"License :: OSI Approved :: MIT License",
|
|
36
|
+
"Operating System :: OS Independent",
|
|
37
|
+
"Programming Language :: Python",
|
|
38
|
+
"Programming Language :: Python :: 3",
|
|
39
|
+
"Programming Language :: Python :: 3.11",
|
|
40
|
+
"Programming Language :: Python :: 3.12",
|
|
41
|
+
"Programming Language :: Python :: 3.13",
|
|
42
|
+
"Typing :: Typed",
|
|
43
|
+
]
|
|
44
|
+
dependencies = [
|
|
45
|
+
"Django>=5.0",
|
|
46
|
+
"djangorestframework>=3.15",
|
|
47
|
+
"djangorestframework-simplejwt>=5.3",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[project.optional-dependencies]
|
|
51
|
+
dev = [
|
|
52
|
+
"build>=1.2",
|
|
53
|
+
"pytest>=8.0",
|
|
54
|
+
"pytest-django>=4.8",
|
|
55
|
+
"ruff>=0.6",
|
|
56
|
+
"twine>=5.0",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[project.urls]
|
|
60
|
+
Homepage = "https://github.com/humanoid-ai/humanoid-login"
|
|
61
|
+
Documentation = "https://github.com/humanoid-ai/humanoid-login#readme"
|
|
62
|
+
Repository = "https://github.com/humanoid-ai/humanoid-login"
|
|
63
|
+
Issues = "https://github.com/humanoid-ai/humanoid-login/issues"
|
|
64
|
+
|
|
65
|
+
[tool.hatch.build.targets.sdist]
|
|
66
|
+
include = [
|
|
67
|
+
"/humanoid_login",
|
|
68
|
+
"/tests",
|
|
69
|
+
"/README.md",
|
|
70
|
+
"/LICENSE",
|
|
71
|
+
"/MANIFEST.in",
|
|
72
|
+
"/pyproject.toml",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
[tool.hatch.build.targets.wheel]
|
|
76
|
+
packages = ["humanoid_login"]
|
|
77
|
+
|
|
78
|
+
[tool.pytest.ini_options]
|
|
79
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
80
|
+
python_files = ["test_*.py"]
|
|
81
|
+
testpaths = ["tests"]
|
|
82
|
+
addopts = "-ra"
|
|
83
|
+
|
|
84
|
+
[tool.ruff]
|
|
85
|
+
line-length = 100
|
|
86
|
+
target-version = "py311"
|
|
87
|
+
|
|
88
|
+
[tool.ruff.lint]
|
|
89
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Pytest fixtures for humanoid_login."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from django.contrib.auth import get_user_model
|
|
7
|
+
from rest_framework.test import APIClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def api_client() -> APIClient:
|
|
12
|
+
"""Return a DRF API client."""
|
|
13
|
+
|
|
14
|
+
return APIClient()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def user(db):
|
|
19
|
+
"""Create a reusable active user."""
|
|
20
|
+
|
|
21
|
+
UserModel = get_user_model()
|
|
22
|
+
return UserModel.objects.create_user(
|
|
23
|
+
username="user@example.com",
|
|
24
|
+
email="user@example.com",
|
|
25
|
+
password="correct-password",
|
|
26
|
+
first_name="John",
|
|
27
|
+
last_name="Doe",
|
|
28
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Django settings used by the humanoid_login test suite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
SECRET_KEY = "humanoid-login-tests-secret-key-with-enough-entropy"
|
|
6
|
+
DEBUG = True
|
|
7
|
+
ROOT_URLCONF = "tests.urls"
|
|
8
|
+
USE_TZ = True
|
|
9
|
+
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
10
|
+
|
|
11
|
+
INSTALLED_APPS = [
|
|
12
|
+
"django.contrib.auth",
|
|
13
|
+
"django.contrib.contenttypes",
|
|
14
|
+
"rest_framework",
|
|
15
|
+
"rest_framework_simplejwt.token_blacklist",
|
|
16
|
+
"humanoid_login",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
DATABASES = {
|
|
20
|
+
"default": {
|
|
21
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
22
|
+
"NAME": ":memory:",
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
MIDDLEWARE: list[str] = []
|
|
27
|
+
|
|
28
|
+
PASSWORD_HASHERS = [
|
|
29
|
+
"django.contrib.auth.hashers.MD5PasswordHasher",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
AUTHENTICATION_BACKENDS = [
|
|
33
|
+
"django.contrib.auth.backends.ModelBackend",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
REST_FRAMEWORK = {
|
|
37
|
+
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
38
|
+
"humanoid_login.authentication.CookieJWTAuthentication",
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
HUMANOID_LOGIN = {
|
|
43
|
+
"COOKIE_SECURE": False,
|
|
44
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Integration tests for cookie-based JWT authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from django.test import override_settings
|
|
9
|
+
from rest_framework import status
|
|
10
|
+
from rest_framework_simplejwt.tokens import RefreshToken
|
|
11
|
+
|
|
12
|
+
from humanoid_login.authentication import CookieJWTAuthentication
|
|
13
|
+
|
|
14
|
+
pytestmark = pytest.mark.django_db
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_login_success_returns_user_and_sets_http_only_cookies(api_client, user) -> None:
|
|
18
|
+
"""A valid login returns user data and creates both auth cookies."""
|
|
19
|
+
|
|
20
|
+
response = api_client.post(
|
|
21
|
+
"/login/",
|
|
22
|
+
{"email": user.email, "password": "correct-password"},
|
|
23
|
+
format="json",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
assert response.status_code == status.HTTP_200_OK
|
|
27
|
+
assert response.data["detail"] == "Login successful."
|
|
28
|
+
assert response.data["user"] == {
|
|
29
|
+
"id": user.id,
|
|
30
|
+
"email": "user@example.com",
|
|
31
|
+
"name": "John Doe",
|
|
32
|
+
"role": None,
|
|
33
|
+
}
|
|
34
|
+
assert "access_token" in response.cookies
|
|
35
|
+
assert "refresh_token" in response.cookies
|
|
36
|
+
assert response.cookies["access_token"]["httponly"] is True
|
|
37
|
+
assert response.cookies["refresh_token"]["httponly"] is True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_login_failure_rejects_invalid_credentials(api_client, user) -> None:
|
|
41
|
+
"""Invalid credentials return an authentication failure."""
|
|
42
|
+
|
|
43
|
+
response = api_client.post(
|
|
44
|
+
"/login/",
|
|
45
|
+
{"email": user.email, "password": "wrong-password"},
|
|
46
|
+
format="json",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
|
50
|
+
assert "access_token" not in response.cookies
|
|
51
|
+
assert "refresh_token" not in response.cookies
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_login_supports_email_when_username_differs(api_client, django_user_model) -> None:
|
|
55
|
+
"""Projects using Django's default username field can still log in by email."""
|
|
56
|
+
|
|
57
|
+
user = django_user_model.objects.create_user(
|
|
58
|
+
username="john",
|
|
59
|
+
email="john@example.com",
|
|
60
|
+
password="correct-password",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
response = api_client.post(
|
|
64
|
+
"/login/",
|
|
65
|
+
{"email": user.email, "password": "correct-password"},
|
|
66
|
+
format="json",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
assert response.status_code == status.HTTP_200_OK
|
|
70
|
+
assert response.data["user"]["email"] == "john@example.com"
|
|
71
|
+
assert "access_token" in response.cookies
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_cookie_jwt_authentication_allows_drf_permissions(api_client, user) -> None:
|
|
75
|
+
"""The /me/ endpoint authenticates with only the access-token cookie."""
|
|
76
|
+
|
|
77
|
+
login_response = api_client.post(
|
|
78
|
+
"/login/",
|
|
79
|
+
{"email": user.email, "password": "correct-password"},
|
|
80
|
+
format="json",
|
|
81
|
+
)
|
|
82
|
+
api_client.cookies["access_token"] = login_response.cookies["access_token"].value
|
|
83
|
+
|
|
84
|
+
response = api_client.get("/me/")
|
|
85
|
+
|
|
86
|
+
assert response.status_code == status.HTTP_200_OK
|
|
87
|
+
assert response.data["email"] == user.email
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_authentication_class_returns_none_when_cookie_missing(api_client) -> None:
|
|
91
|
+
"""Requests without access cookies are ignored by the package authenticator."""
|
|
92
|
+
|
|
93
|
+
request = api_client.get("/me/").wsgi_request
|
|
94
|
+
|
|
95
|
+
assert CookieJWTAuthentication().authenticate(request) is None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_logout_deletes_cookies(api_client, user) -> None:
|
|
99
|
+
"""Logout clears both configured authentication cookies."""
|
|
100
|
+
|
|
101
|
+
login_response = api_client.post(
|
|
102
|
+
"/login/",
|
|
103
|
+
{"email": user.email, "password": "correct-password"},
|
|
104
|
+
format="json",
|
|
105
|
+
)
|
|
106
|
+
api_client.cookies["access_token"] = login_response.cookies["access_token"].value
|
|
107
|
+
api_client.cookies["refresh_token"] = login_response.cookies["refresh_token"].value
|
|
108
|
+
|
|
109
|
+
response = api_client.post("/logout/")
|
|
110
|
+
|
|
111
|
+
assert response.status_code == status.HTTP_200_OK
|
|
112
|
+
assert response.cookies["access_token"]["max-age"] == 0
|
|
113
|
+
assert response.cookies["refresh_token"]["max-age"] == 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_refresh_issues_new_access_cookie(api_client, user) -> None:
|
|
117
|
+
"""Refresh reads the refresh cookie and sets a new access cookie."""
|
|
118
|
+
|
|
119
|
+
login_response = api_client.post(
|
|
120
|
+
"/login/",
|
|
121
|
+
{"email": user.email, "password": "correct-password"},
|
|
122
|
+
format="json",
|
|
123
|
+
)
|
|
124
|
+
api_client.cookies["refresh_token"] = login_response.cookies["refresh_token"].value
|
|
125
|
+
api_client.cookies.pop("access_token", None)
|
|
126
|
+
|
|
127
|
+
response = api_client.post("/refresh/")
|
|
128
|
+
|
|
129
|
+
assert response.status_code == status.HTTP_200_OK
|
|
130
|
+
assert "access_token" in response.cookies
|
|
131
|
+
assert "refresh_token" not in response.cookies
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_refresh_rotates_refresh_cookie_when_enabled(api_client, user) -> None:
|
|
135
|
+
"""Refresh rotation updates the refresh cookie when configured."""
|
|
136
|
+
|
|
137
|
+
login_response = api_client.post(
|
|
138
|
+
"/login/",
|
|
139
|
+
{"email": user.email, "password": "correct-password"},
|
|
140
|
+
format="json",
|
|
141
|
+
)
|
|
142
|
+
api_client.cookies["refresh_token"] = login_response.cookies["refresh_token"].value
|
|
143
|
+
|
|
144
|
+
with override_settings(HUMANOID_LOGIN={"ROTATE_REFRESH_TOKENS": True}):
|
|
145
|
+
response = api_client.post("/refresh/")
|
|
146
|
+
|
|
147
|
+
assert response.status_code == status.HTTP_200_OK
|
|
148
|
+
assert "access_token" in response.cookies
|
|
149
|
+
assert "refresh_token" in response.cookies
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_expired_access_token_is_rejected(api_client, user) -> None:
|
|
153
|
+
"""Expired access tokens fail authentication."""
|
|
154
|
+
|
|
155
|
+
refresh = RefreshToken.for_user(user)
|
|
156
|
+
access = refresh.access_token
|
|
157
|
+
access.set_exp(lifetime=timedelta(seconds=-1))
|
|
158
|
+
api_client.cookies["access_token"] = str(access)
|
|
159
|
+
|
|
160
|
+
response = api_client.get("/me/")
|
|
161
|
+
|
|
162
|
+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_invalid_access_token_is_rejected(api_client) -> None:
|
|
166
|
+
"""Malformed access tokens fail authentication."""
|
|
167
|
+
|
|
168
|
+
api_client.cookies["access_token"] = "not-a-token"
|
|
169
|
+
|
|
170
|
+
response = api_client.get("/me/")
|
|
171
|
+
|
|
172
|
+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_missing_refresh_cookie_is_rejected(api_client) -> None:
|
|
176
|
+
"""Refresh requires a configured refresh cookie."""
|
|
177
|
+
|
|
178
|
+
response = api_client.post("/refresh/")
|
|
179
|
+
|
|
180
|
+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_configuration_overrides_cookie_names_and_attributes(api_client, user) -> None:
|
|
184
|
+
"""Django settings override cookie names, attributes, and lifetimes."""
|
|
185
|
+
|
|
186
|
+
with override_settings(
|
|
187
|
+
HUMANOID_LOGIN={
|
|
188
|
+
"ACCESS_COOKIE": "humanoid_access",
|
|
189
|
+
"REFRESH_COOKIE": "humanoid_refresh",
|
|
190
|
+
"COOKIE_SECURE": True,
|
|
191
|
+
"COOKIE_SAMESITE": "Strict",
|
|
192
|
+
"ACCESS_TOKEN_LIFETIME": timedelta(seconds=60),
|
|
193
|
+
"REFRESH_TOKEN_LIFETIME": timedelta(seconds=120),
|
|
194
|
+
}
|
|
195
|
+
):
|
|
196
|
+
response = api_client.post(
|
|
197
|
+
"/login/",
|
|
198
|
+
{"email": user.email, "password": "correct-password"},
|
|
199
|
+
format="json",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
assert response.status_code == status.HTTP_200_OK
|
|
203
|
+
assert "humanoid_access" in response.cookies
|
|
204
|
+
assert "humanoid_refresh" in response.cookies
|
|
205
|
+
assert response.cookies["humanoid_access"]["secure"] is True
|
|
206
|
+
assert response.cookies["humanoid_access"]["samesite"] == "Strict"
|
|
207
|
+
assert response.cookies["humanoid_access"]["max-age"] == 60
|
|
208
|
+
assert response.cookies["humanoid_refresh"]["max-age"] == 120
|