Zibanu Django Auth — zibanu.django.auth
Authentication and authorization package for Django and Django REST Framework projects, based on SimpleJWT sliding tokens, email login, user profiles, group privilege levels and configurable application menus.
Read this in other languages: Español.
Table of Contents
- Overview
- Features
- Installation
- Quick Start
- Usage
- REST API Endpoints
- API Reference
- Configuration
- Testing
- Requirements
- Contributing
- License
Key Features
- JWT Token Authentication — SimpleJWT integration with token refresh and blacklist support
- User Management — Complete CRUD operations for users with email/username authentication
- User Profiles — Extended user information including timezone, theme, language, and custom settings
- Group-Based Access Control — Permission management with group levels and hierarchical access
- Application Management — Multi-tenant application support with user-to-application associations
- Menu Management — Dynamic menu generation with permission-aware filtering
- Password Management — Secure password change/reset flows with email notifications
- User Levels — Hierarchical user access levels (Super User, Staff, Operator)
- Session Control — Single/multiple login support with configurable session management
- Avatar Support — User profile pictures with image validation
Overview
zibanu.django.auth is an optional package of the Zibanu Django framework. It extends django.contrib.auth and djangorestframework-simplejwt with a complete authentication and authorization layer, exposed as REST services and configured entirely through ZB_AUTH_* Django settings.
The package provides:
- An authentication backend (
ZbAuthBackend) that accepts either the username or the email address as the login credential, with timing-attack mitigation for non-existent accounts. - SimpleJWT sliding token serializers that embed a serialized user payload inside the token and enforce a single-session policy per HTTP origin.
- 17 REST endpoints covering the whole user lifecycle: login, logout, refresh, password change and recovery, user CRUD, profile management, group and permission listings and the application menu tree.
- A privilege level model (
GroupLevel+GroupLevelEnum) that restricts which users and groups an authenticated user can see or manage. - Application menus (
Application,DefaultMenu) served as a permission-aware tree, resolved in a single query, reachable by anonymous callers for itsis_publicitems. - Signals and receivers to audit authentication events and to maintain the password-request and failed-login lock counters stored in
UserControl.
Package version: 3.0.0-dev.0 (see zibanu/django/auth/__init__.py).
Features
- Login by email with SimpleJWT sliding tokens and a user payload embedded in the token claims.
- Single-session control per client application (HTTP origin), with an explicit
closeflag to terminate previous sessions. - Logout with token blacklisting and session cache cleanup.
- Password change with two configurable policies: default and secure (length + character class rules).
- Two-step password recovery: verification code by email, then a generated password, throttled by lock counters.
- Failed-login lockout: after
ZB_AUTH_LOGIN_FAILED_COUNTconsecutive failed attempts the account is locked forZB_AUTH_LOGIN_FAILED_LOCK_TIMEminutes (UserControl.is_login_blocked), enforced at login and cleared automatically on the next successful login. - Full user CRUD with nested profile, groups (
roles) and individual permissions, guarded by Django permissions. - Level-based visibility: a user never sees, creates, promotes or deletes an account more privileged than itself.
- Application menu open to anonymous callers: an application with no declared members is open to every authenticated user, one with a member list is restricted to it, and a disabled application is invisible to everyone (
404); anonymous callers always receive only theis_publicitems. - User profile with timezone, theme, language, avatar (multipart or base64), message timeout and session/password flags.
- Configurable application menu tree with per-group visibility and per-entity CRUD permissions.
- Django admin integration: group level editing, application management and inline menu editing.
- Auditing signals, integrated with the optional
zibanu.django.loggingpackage when installed. - Spanish locale bundled (
locale/es/LC_MESSAGES).
Installation
Follow the standard procedure for optional Zibanu packages.
1. Install the package
From the project installers directory:
pip install ./installers/zibanu-django-auth/
Or from PyPI:
pip install zibanu-django-auth
2. Add it to INSTALLED_APPS
In mainApp/components/base.py:
INSTALLED_APPS = [
# ... existing apps ...
"rest_framework",
"rest_framework_simplejwt",
"rest_framework_simplejwt.token_blacklist",
"zibanu.django",
"zibanu.django.auth",
]
The application label is zb_auth; its permissions are registered under that label (for example zb_auth.add_userprofile).
3. Configure the authentication backend
AUTHENTICATION_BACKENDS = ["zibanu.django.auth.lib.backends.ZbAuthBackend"]
4. Configure SimpleJWT
The package relies on sliding tokens. Register the package serializers so login and refresh use email authentication and the embedded user payload:
from datetime import timedelta
SIMPLE_JWT = {
"UPDATE_LAST_LOGIN": True,
"ALGORITHM": "HS512",
"SIGNING_KEY": SECRET_KEY,
"AUTH_HEADER_TYPES": ("JWT",),
"USER_ID_FIELD": "id",
"AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.SlidingToken",),
"SLIDING_TOKEN_LIFETIME": timedelta(hours=1),
"SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(hours=1),
"SLIDING_TOKEN_OBTAIN_SERIALIZER": "zibanu.django.auth.api.serializers.EmailTokenObtainSlidingSerializer",
"SLIDING_TOKEN_REFRESH_SERIALIZER": "zibanu.django.auth.api.serializers.EmailTokenRefreshSlidingSerializer",
"ROTATE_REFRESH_TOKEN": True,
"BLACKLIST_AFTER_ROTATION": True,
}
5. Publish the URLs
In mainApp/urls.py:
from django.urls import include, path
urlpatterns = [
path("auth/", include("zibanu.django.auth.urls")),
]
6. Run the migrations
python manage.py migrate
The package ships 15 migrations creating the zb_auth_user_profile, zb_auth_group_level, zb_auth_user_control, application and menu tables.
7. Configure the optional settings
Any ZB_AUTH_* setting can be declared in a component file under mainApp/components/. Values not declared by the project take the defaults applied by ZbDjangoAuth.ready() — see Configuration.
UserProfile
Quick Start
Authenticate a user and call a protected endpoint:
import requests
BASE_URL = "https://myserver.com/auth"
headers = {"Content-Type": "application/json", "Accept": "application/json"}
# 1. Login: obtain a sliding token
response = requests.post(
f"{BASE_URL}/login/",
json={"email": "jhon.doe@jdoe.com", "password": "tuMvERweRley"},
headers=headers,
)
token = response.json()["token"]
# 2. Use the token on any protected service
auth_headers = {**headers, "Authorization": f"JWT {token}"}
profile = requests.post(f"{BASE_URL}/user/profile/", headers=auth_headers).json()
# 3. Close the session
requests.post(f"{BASE_URL}/logout/", headers=auth_headers)
All endpoints are served over POST, and the token is sent in the Authorization header using the JWT prefix (as configured in AUTH_HEADER_TYPES).
Usage
Basic usage
Handling the single-session policy
When the user profile has multiple_login disabled, a second login from the same HTTP origin is rejected with 412 Precondition Failed. Send close: true to blacklist the previous token and open a new session:
payload = {"email": "jhon.doe@jdoe.com", "password": "tuMvERweRley", "close": True}
response = requests.post(f"{BASE_URL}/login/", json=payload, headers=headers)
Failed-login lockout
Every failed login attempt increments UserControl.login_error_counter. Once it reaches ZB_AUTH_LOGIN_FAILED_COUNT, the account is locked: further login attempts fail with 401 (login_blocked) regardless of the password, for ZB_AUTH_LOGIN_FAILED_LOCK_TIME minutes (UserControl.is_login_blocked). The lock is released automatically either when the lock time elapses (the next failed attempt then starts a fresh count) or on the next successful login, which resets the counter and clears the lock flags. The check and the counters are maintained by lib/receivers.py (user_login_failed / user_logged_in receivers) and consulted by the login serializer before the password is even checked.
Reading the user payload from the token
The token embeds a user claim built by the serializer configured in ZB_AUTH_USER_TOKEN_SERIALIZER (by default UserTokenSerializer, exposing username, is_staff, is_superuser, level, full_name and last_login), so the front-end does not need an extra call after login.
Recovering a password
# Step 1: request the verification code (public endpoint)
requests.post(f"{BASE_URL}/request-password/", json={"email": "jhon.doe@jdoe.com"}, headers=headers)
# Step 2: send the received code to receive a new password by email
requests.post(
f"{BASE_URL}/request-password/",
json={"email": "jhon.doe@jdoe.com", "code": "483920"},
headers=headers,
)
Advanced usage
Customizing the token payload
Create a serializer and register it in the settings:
# myapp/serializers.py
from zibanu.django.auth.api.serializers import UserTokenSerializer
class MyTokenSerializer(UserTokenSerializer):
class Meta(UserTokenSerializer.Meta):
fields = UserTokenSerializer.Meta.fields + ("email",)
# settings
ZB_AUTH_USER_TOKEN_SERIALIZER = "myapp.serializers.MyTokenSerializer"
Consuming the authentication signals
from django.dispatch import receiver
from zibanu.django.auth.lib.signals import on_user_created
@receiver(on_user_created)
def notify_user_created(sender, **kwargs):
user_data = kwargs.get("user") # serialized user payload
request = kwargs.get("request") # request that triggered the creation
...
Working with privilege levels
from zibanu.django.auth.lib import GroupLevelEnum
from zibanu.django.auth.models import GroupLevel
GroupLevel.objects.filter(group=group).update(level=GroupLevelEnum.ADMIN)
from zibanu.django.auth.lib.utils import get_user
user = get_user(request.user) # resolves a real user from a SimpleJWT TokenUser
user.level # numeric level; the lower, the more privileged
user.get_entity_permissions("app_label.ModelName")
# {"view": True, "add": False, "change": False, "delete": False}
Building an application menu
Register an Application in the Django admin, add its DefaultMenu items as inlines, grant them to the required groups, then request the tree:
requests.post(
f"{BASE_URL}/menu/",
json={"app_id": "1f0c9b7e-6f2a-4f3d-9a0e-2b0c9f7e5d31"},
headers=auth_headers,
)
Each root item is returned with its nested children and the entity_permissions of the authenticated user for the linked entity.
REST API Endpoints
All endpoints are published by zibanu.django.auth.urls and mapped to the POST verb. Assuming they are mounted under auth/:
| # | URL | Service / action | Authentication | Required permissions |
|---|---|---|---|---|
| 1 | menu/ (ZB_AUTH_MENU_URL) |
MenuService.list |
Optional (anonymous gets is_public items only) |
— |
| 2 | login/ |
TokenObtainSlidingView |
Public | — |
| 3 | logout/ |
LogoutUser.logout |
Required | — |
| 4 | refresh/ |
TokenRefreshSlidingView |
Public (valid token in body) | — |
| 5 | change-password/ |
UserService.change_password |
Required | — |
| 6 | request-password/ |
UserService.request_password |
Public (AllowAny) |
— |
| 7 | group/list/ |
GroupService.list |
Required | is_staff or auth.view_group |
| 8 | grouplevel/list/ |
GroupLevelService.list |
Required | — |
| 9 | permission/list/ |
PermissionService.list |
Required | is_staff or auth.view_permission |
| 10 | profile/update/ |
ProfileService.update |
Required | — |
| 11 | user/add/ |
UserService.create |
Required | auth.add_user, zb_auth.add_userprofile |
| 12 | user/avatar/ |
UserService.get_avatar |
Required | — |
| 13 | user/delete/ |
UserService.destroy |
Required | auth.delete_user, zb_auth.delete_userprofile |
| 14 | user/list/ |
UserService.list |
Required | auth.view_user |
| 15 | user/profile/ |
UserService.get_profile |
Required | — |
| 16 | user/retrieve/ |
UserService.retrieve |
Required | auth.view_user |
| 17 | user/update/ |
UserService.update |
Required | auth.change_user, zb_auth.change_userprofile |
1. Menu — POST menu/
Returns the menu tree of an application, filtered by the visibility rules of the caller. Open to anonymous callers by design: an unauthenticated request receives only the is_public items. The relative URL is taken from ZB_AUTH_MENU_URL.
Membership rule: an application that declares no members (Application.users empty) is open to every authenticated caller; one that declares a member list restricts the menu to that list; a disabled application (enabled=False) is invisible to everyone.
Parameters
app_id(required): publicappid(UUID) of the application.
Responses
200: root items, each one with its nestedchildrenand itsentity_permissions(view,add,change,delete).204: no menu item is visible to the caller.400:app_idis missing from the request data.403: the application declares a member list and the authenticated caller is not in it.404: no enabled application owns that identifier (a disabled application also resolves to 404).
2. Login — POST login/
Authenticates a user with the email/password pair and returns a sliding token whose payload embeds the serialized user.
Parameters
email(required): email address used to authenticate.password(required): user password.close(optional): closes the other active sessions of the same origin. Only evaluated when the user profile does not allow multiple login.
Responses
200:{"token": "<jwt>"}.401: invalid credentials, inactive account, non-existent user, user without an associated profile, or the account is temporarily locked after too many failed login attempts (login_blocked, see Failed-login lockout).412: the user already holds an open session for this origin (multiple_login).500: any other non-controlled exception.
import requests
api_url = "https://myserver.com/auth/login/"
headers = {"Content-Type": "application/json", "Accept": "application/json"}
payload = {"email": "jhon.doe@jdoe.com", "password": "tuMvERweRley", "close": True}
response = requests.post(api_url, json=payload, headers=headers)
3. Logout — POST logout/
Blacklists the sliding token carried by the request and removes the session entry from the cache, so a new login is accepted for the same origin.
Responses
200: the session was closed.401: authentication required.500: non-controlled error while blacklisting the token or clearing the cache.
4. Refresh — POST refresh/
Refreshes a sliding token. The incoming token is blacklisted and a brand-new token, with a freshly serialized user payload, is issued and stored in the session cache.
Parameters
token(required): current JWT to be refreshed.
Responses
200:{"token": "<new jwt>"}.401: the token has no associated user, is expired, or the session is no longer cached (invalidated by another login or by a logout).500: the configured user serializer cannot be imported, or any other non-controlled exception.
5. Change password — POST change-password/
Changes the password of the authenticated user. The old password is verified, both passwords must differ, and the new one must satisfy the applicable policy: the secure policy (ZB_AUTH_SECURE_PASSWORD_*) when the profile has secure_password enabled, the default policy (ZB_AUTH_DEFAULT_PASSWORD_*) otherwise. A confirmation email is sent on success.
Parameters
old_password(required): current password.new_password(required): new password to set.
Responses
200: the password was changed.401: authentication required.406: a password is missing, both passwords are equal, the old password does not match, or the new one violates the policy.500: any other non-controlled exception.
import requests
api_url = "https://myserver.com/auth/change-password/"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": "JWT <token>",
}
payload = {"old_password": "Old.Pass1", "new_password": "New.Pass2"}
response = requests.post(api_url, json=payload, headers=headers)
6. Request password — POST request-password/
Public two-step password recovery. Without code, a six-digit verification code is emailed and cached for ZB_AUTH_CODE_EXPIRATION_TIME seconds. With a valid code, a new password is generated according to the profile policy, stored and emailed.
Requests are throttled through UserControl: once ZB_AUTH_REQUEST_PASSWORD_MAX_COUNT requests are performed within the ZB_AUTH_REQUEST_PASSWORD_LAPSE window, the user stays locked for ZB_AUTH_REQUEST_PASSWORD_BLOCK_TIME minutes.
Parameters
email(required): email address of the account.code(optional): verification code previously received by email.
Responses
200: the code or the new password was sent. The same status is returned when the email does not belong to any active account, so the endpoint does not disclose which accounts exist.406:emailis missing, the code is invalid, or the password request is locked for that user.500: any other non-controlled exception.
import requests
api_url = "https://myserver.com/auth/request-password/"
headers = {"Content-Type": "application/json", "Accept": "application/json"}
response = requests.post(api_url, json={"email": "jhon.doe@jdoe.com"}, headers=headers)
7. Group list — POST group/list/
Lists the groups visible to the authenticated user. Groups whose level is more privileged than the user level are removed from the response.
Responses
200: list of{"id", "name", "level"}entries.204: no group is visible to the user.401: authentication required.403: the user is not staff and lacksauth.view_group.
8. Group level list — POST grouplevel/list/
Returns the catalog of privilege levels declared in GroupLevelEnum.
Responses
200:[{"level": 0, "name": "Superuser"}, {"level": 1, "name": "Staff"}, ...].401: authentication required.
9. Permission list — POST permission/list/
Lists the native Django permissions, so a front-end can assign them to a user.
Responses
200: list of{"id", "name"}entries.204: no permission is available.401: authentication required.403: the user is not staff and lacksauth.view_permission.
10. Profile update — POST profile/update/
Updates the profile of the authenticated user, creating it when the user does not have one yet.
Parameters (any writable profile field)
timezone,theme,lang,avatar,messages_timeout,keep_logged_in,app_settings,multiple_login,secure_password.
Responses
200: the profile was updated.401: authentication required.406: the data is not valid, or the user is not allowed to change themultiple_loginflag.500: non-controlled error while saving the profile.
11. Add user — POST user/add/
Creates a user together with its profile, assigns its groups and permissions and sends the welcome email.
Parameters
email(required, unique),first_name,last_name.username/password: optional; generated automatically whenZB_AUTH_AUTO_USERNAME/ZB_AUTH_AUTO_PASSWORDare enabled.profile: nested profile data; defaults to an empty object.roles/permissions: identifiers of the granted groups and permissions. The key names are configurable throughZB_AUTH_ROLES_KEYandZB_AUTH_PERMISSIONS_KEY.is_staff/is_superuser/is_active: only honoured when the author is staff or superuser.
Responses
201: the user was created; the response holds its data.401: authentication required.403: the author lacksauth.add_userorzb_auth.add_userprofile.406: missing or invalid data, or the requested level is more privileged than the author's one.500: non-controlled error while creating the user.
12. User avatar — POST user/avatar/
Returns the avatar of the authenticated user, as a base64 string or a URL depending on ZB_AUTH_AVATAR_BASE64.
Responses
200: avatar representation.204: the user has no profile or no avatar loaded.401: authentication required.500: non-controlled error while reading the avatar.
13. Delete user — POST user/delete/
Deletes a user. A regular user cannot delete a staff or superuser account, no user can delete itself, and no user can delete another user whose privilege level is higher than its own (even a non-staff one, e.g. a more privileged group level).
Parameters
idoremail(one required): identifies the user to delete.
Responses
200: the user was deleted.401: authentication required.403: the author lacksauth.delete_user/zb_auth.delete_userprofile, or the user owns protected child records.404: the user does not exist.406: neitheridnoremailwas supplied, the target is a privileged account, the target has a higher privilege level than the author, or the author is trying to delete itself.500: non-controlled error while deleting the user.
14. User list — POST user/list/
Lists the active users visible to the authenticated user. Users more privileged than the requester are excluded directly in the queryset (never serialized). The response is a plain array; this action is not paginated.
Parameters
order_by(optional): field to sort by, one offirst_name(default),last_name,email,last_loginoris_active, optionally prefixed with-for descending order.
Responses
200: list of users withfull_name,email,last_login,is_staff,is_superuser,id,first_name,last_nameandlevel.204: no user is visible.400:order_bynames a field outside the allowed set.401: authentication required.403: the user lacksauth.view_user.
15. User profile — POST user/profile/
Returns the basic profile of the authenticated user: first_name, last_name, last_login and the extended profile.
Responses
200: profile data.204: there is no profile data to return.401: authentication required.500: non-controlled error while reading the profile.
16. Retrieve user — POST user/retrieve/
Retrieves a single user, including its profile, roles and permissions. The password field is always stripped from the response.
Parameters
- Filter identifying the user, typically
id.
Responses
200: data of the requested user.401: authentication required.403: the user lacksauth.view_user.404: no user matches the given filter.406: the identifier is missing from the request data.
17. Update user — POST user/update/
Updates a user, including its profile, groups and permissions.
Parameters
idoremail(one required): identifies the user to update.first_name,last_name,email.profile: nested profile data, including the avatar.roles/permissions: identifiers replacing the current groups and permissions.is_staff/is_superuser/is_active: only honoured when the author is staff or superuser.
The password key is always ignored; use change-password/ instead.
Responses
200: the user was updated; the response holds its data.401: authentication required.403: the author lacksauth.change_userorzb_auth.change_userprofile.404: the user to update does not exist.406: invalid data, or the resulting level is more privileged than the author's one.500: non-controlled error while updating the user.
API Reference
Models — zibanu.django.auth.models
| Class | Table | Description |
|---|---|---|
User |
(proxy) | Proxy of the configured user model. Adds the level property and get_entity_permissions(). |
UserProfile |
zb_auth_user_profile |
User preferences: timezone, theme, lang, avatar, messages_timeout, keep_logged_in, multiple_login, secure_password, app_settings. Reverse accessor user.profile. |
UserControl |
zb_auth_user_control |
Password-request and failed-login counters, lock flags and timestamps. Reverse accessor user.user_control. |
GroupLevel |
zb_auth_group_level |
Privilege level assigned to a native Django group. Reverse accessor group.group_level. |
Application |
(default) | Client application with name, description, unique appid, authorized users and enabled flag. |
ApplicationProxy |
(proxy) | Proxy of Application used to expose menu management as a separate admin entry. |
DefaultMenu |
(default) | Hierarchical menu item of an application, with tree maintenance on save. Its group_menu many-to-many field (table zb_auth_group_menu) holds the groups the item is granted to. |
User.level — resolves the numeric privilege level: SUPER_USER for superusers, STAFF for staff, otherwise the minimum level among the groups of the user (defaulting to OPERATOR).
User.get_entity_permissions(entity_class) — returns {"view", "add", "change", "delete"} booleans for an entity given as "app_label.ModelName". Returns all False when the format is not valid.
UserProfile.clean() — forces multiple_login to False for regular users when ZB_AUTH_ALLOW_MULTIPLE_LOGIN is disabled, reuses the stored avatar when the uploaded file did not change, and rejects a multiple_login change made by a user that is neither staff/superuser nor holder of zb_auth.change_userprofile_multiple_login.
UserControl.is_request_password_blocked — True while the elapsed time since request_password_blocked_at does not exceed ZB_AUTH_REQUEST_PASSWORD_BLOCK_TIME minutes.
UserControl.is_login_blocked — True while the elapsed time since login_error_blocked_at does not exceed ZB_AUTH_LOGIN_FAILED_LOCK_TIME minutes. Consulted by the login serializer (api/serializers/token.py) before the password check, so a locked account is rejected with 401 (login_blocked) even with a correct password.
DefaultMenu — keeps the tree consistent on save: the level is derived from the parent, the index is recalculated and siblings are reindexed, and both the level and the enabled flag are propagated to the children. clean() enforces a single home item per application and a non-empty route.
Services — zibanu.django.auth.api.services
| Class | Base | Actions |
|---|---|---|
UserService |
ModelViewSet |
list, retrieve, create, update, destroy, get_avatar, get_profile, change_password, request_password |
ProfileService |
ModelViewSet |
update |
GroupService |
ModelViewSet |
list, create |
GroupLevelService |
ViewSet |
list |
PermissionService |
ModelViewSet |
list |
MenuService |
ModelViewSet |
list |
LogoutUser |
ViewSet |
logout |
All viewsets inherit the Zibanu defaults: JWTTokenUserAuthentication and IsAuthenticated. UserService.get_permissions() opens request_password to AllowAny. MenuService uses IsOwnerOrReadOnly.
GroupService.create is available as a viewset action but is not published in urls.py; a new GroupLevel record with the OPERATOR level is created automatically for every new group by the package receivers.
Serializers — zibanu.django.auth.api.serializers
Token serializers
EmailTokenObtainSerializer— authenticates by email, validates the single-session policy and embeds the user payload in the token. Exposes theclose_sessionsproperty.EmailTokenObtainSlidingSerializer— issues aSlidingToken, caches it when the single-session policy applies and dispatchesuser_logged_in.EmailTokenRefreshSlidingSerializer— validates the refresh against the cached session token, blacklists the old token and issues a new one with a refreshed user payload.get_user_from_token(token)— helper returning the user referenced by theuser_idclaim of a sliding token.
User serializers
| Class | Fields |
|---|---|
UserListSerializer |
full_name, email, last_login, is_staff, is_superuser, id, first_name, last_name, level (is_staff, is_superuser and level are read-only) |
UserSerializer |
email, full_name, last_login, is_staff, is_superuser, is_active, profile, roles, first_name, last_name, permissions, username, password (write-only) |
UserExtendedSerializer |
Same as UserSerializer, nesting ProfileExtendedSerializer (avatar + app_settings) |
UserTokenSerializer |
username, is_staff, is_superuser, level, full_name, last_login |
UserProfileSerializer |
first_name, last_name, last_login, profile (read-only) |
UserSerializer.validate() rejects an email already in use; create() builds the user and its profile in one step and requires a password; update() updates the user and its nested profile. The inclusion of roles and permissions is driven by ZB_AUTH_INCLUDE_ROLES / ZB_AUTH_INCLUDE_PERMISSIONS, and can be overridden per call through the load_roles / load_permissions context keys.
Profile serializers
ProfileSerializer—timezone,theme,lang,messages_timeout,keep_logged_in,multiple_login,secure_password.ProfileExtendedSerializer— addsavatarandapp_settings. The avatar is handled byHybridImageField, which accepts both a multipart file and a base64 string, and is rebuilt at instantiation time to honour the currentZB_AUTH_AVATAR_BASE64andZB_AUTH_AVATAR_SIZEvalues.
Authorization serializers
GroupListSerializer—id,name,level(falls back toOPERATORwhen the group has no level).PermissionSerializer—id,name.MenuSerializer— menu fields plus the recursivechildrenlist and the computedentity_permissionsmap. It expectsuserandchildren_mapin the serializer context, so the tree is serialized without one query per node.
Library — zibanu.django.auth.lib
backends.ZbAuthBackend— authentication backend accepting username or email, withauthenticate()andaauthenticate()counterparts. It hashes a password even for non-existent accounts to keep the response time constant.enums.GroupLevelEnum—SUPER_USER(0),STAFF(1),SUPER_ADMIN(2),ADMIN(3),SUPERVISOR(4),OPERATOR(5),GUEST(9). The lower the value, the higher the privilege.managers.ApplicationManager— addsget_by_appid(appid).managers.DefaultMenuManager—get_home,get_home_count,get_menu_index,get_children,get_children_count,get_full_menu_from_user,get_menu_from_user,reindex_items,set_children_enabled,set_children_level.utils.get_user(user=None, **kwargs)— resolves a real Django user from a SimpleJWTTokenUser, or fromkwargs["request"].utils.get_cache_key(request, user)— builds the session cache key as the md5 hex digest of"<username>.<md5 of the HTTP origin>".
Signals — zibanu.django.auth.lib.signals
| Signal | Dispatched when | Arguments |
|---|---|---|
on_change_password |
A user successfully changes its own password | user, request |
on_request_password |
A new password is generated and emailed | user, request |
on_user_created |
A user is created | user (serialized), request |
on_user_updated |
A user is updated | user (serialized), request |
on_user_deleted |
A user is deleted | user (serialized), request |
Receivers (lib/receivers.py) are connected by ZbDjangoAuth.signals_factory() once the application registry is ready. They:
- create the default
GroupLevelrecord for every new group (post_saveonGroup); - audit user creation/update/deletion;
- audit
on_change_password,on_request_password,user_logged_inanduser_login_failed, and maintain both the password-request counters/locks and the failed-login counters/locks inUserControl(login_error_counter/login_error_blockedare incremented onuser_login_failedand reset on the nextuser_logged_in).
Audit records are persisted only when the optional zibanu.django.logging package is installed; otherwise the events are ignored.
Admin views — zibanu.django.auth.views
ZbGroupAdminView— replaces the nativeGroupadmin, adding the group level field and column.ApplicationAdminView— application management (name,description,enabled).DefaultMenuAdminView— menu tree of an application, edited as inlines overApplicationProxy. The groups a menu item is granted to are edited here, through thegroup_menufield.
Configuration
Every setting below is given its default by ZbDjangoAuth.ready() with getattr at application startup, so any value already declared by the project takes precedence over the default. Most settings are then read at call time from django.conf.settings inside the consuming service/model (several UserService values are exposed as @property, and UserControl.is_request_password_blocked/is_login_blocked read their lock-time setting inside the property body), so a value changed at runtime (e.g. via override_settings in tests) takes effect immediately. The one exception is ZB_AUTH_MENU_URL, read once at import time in urls.py to build the URL pattern — changing it requires reloading the URLconf.
ZB_AUTH_* settings
| Setting | Default | Description |
|---|---|---|
ZB_AUTH_ALLOW_MULTIPLE_LOGIN |
False |
Allow a user to hold several simultaneous sessions from the same application type. |
ZB_AUTH_ALLOW_MULTIPLE_ROLES |
False |
Allow a user to belong to more than one group. Declared for project use; not consumed by the package code yet. |
ZB_AUTH_AUTO_PASSWORD |
True |
Generate the password automatically on user creation. |
ZB_AUTH_AUTO_USERNAME |
True |
Generate the username automatically on user creation. |
ZB_AUTH_AVATAR_BASE64 |
True |
Represent the profile avatar as a base64 string. |
ZB_AUTH_AVATAR_SIZE |
0 |
Maximum avatar width/height in pixels. 0 is an intentional sentinel meaning "no size limit". |
ZB_AUTH_CHANGE_PASSWORD_TEMPLATE |
"change_password" |
Template of the password change confirmation email. |
ZB_AUTH_CODE_EXPIRATION_TIME |
120 |
Lifetime, in seconds, of the password request verification code. |
ZB_AUTH_DEFAULT_THEME |
"corporate" |
Default theme assigned to a user profile. |
ZB_AUTH_DEFAULT_PASSWORD_MIN_LENGTH |
8 |
Minimum length for regular passwords. |
ZB_AUTH_DEFAULT_PASSWORD_MAX_LENGTH |
32 |
Maximum length for regular passwords. |
ZB_AUTH_INCLUDE_ROLES |
True |
Include the user groups in the serialized user payload. |
ZB_AUTH_INCLUDE_PERMISSIONS |
False |
Include the user permissions in the serialized user payload. |
ZB_AUTH_LOGIN_FAILED_COUNT |
5 |
Consecutive failed login attempts allowed before the account is locked (UserControl.login_error_counter, checked in lib/receivers.py). |
ZB_AUTH_LOGIN_FAILED_LOCK_TIME |
60 |
Lock time, in minutes, applied once ZB_AUTH_LOGIN_FAILED_COUNT is reached (UserControl.is_login_blocked). |
ZB_AUTH_NEW_USER_TEMPLATE |
"new_user" |
Template of the welcome email sent to a new user. |
ZB_AUTH_MENU_URL |
"menu/" |
Relative URL used to publish the menu service. |
ZB_AUTH_PERMISSIONS_KEY |
"permissions" |
Request data key holding the permission identifiers. |
ZB_AUTH_REQUEST_PASSWORD_BLOCK_TIME |
1440 |
Lock time, in minutes, after too many password requests. |
ZB_AUTH_REQUEST_PASSWORD_LAPSE |
60 |
Time window, in minutes, used to count password requests. |
ZB_AUTH_REQUEST_PASSWORD_MAX_COUNT |
5 |
Password requests allowed inside the lapse before locking. |
ZB_AUTH_REQUEST_PASSWORD_TEMPLATE |
"request_password" |
Template of the email carrying the new password. |
ZB_AUTH_ROLES_KEY |
"roles" |
Request data key holding the group identifiers. |
ZB_AUTH_SECURE_PASSWORD_MIN_LENGTH |
16 |
Minimum length for secure passwords. |
ZB_AUTH_SECURE_PASSWORD_MAX_LENGTH |
32 |
Maximum length for secure passwords. |
ZB_AUTH_SEND_CODE_TEMPLATE |
"send_code" |
Template of the email carrying the verification code. |
ZB_AUTH_USER_TOKEN_SERIALIZER |
"zibanu.django.auth.api.serializers.UserTokenSerializer" |
Dotted path of the serializer embedding the user payload in the JWT token. |
Email templates
The four *_TEMPLATE settings hold a template name without extension. The Email utility of zibanu.django.lib loads both variants, so each name requires two files resolvable by the Django template loaders:
templates/
├── change_password.txt | change_password.html
├── new_user.txt | new_user.html
├── request_password.txt | request_password.html
└── send_code.txt | send_code.html
The rendering context provides user, plus new_password (new user and request password emails) or code (verification code email). SMTP failures are swallowed on purpose, so an unavailable mail server never breaks the REST operation that triggered the message.
Cache
The single-session policy requires a working Django cache backend: the issued token is stored under the md5 hex digest of "<username>.<md5 of the HTTP origin>" with the token lifetime as timeout. Verification codes are cached under "request_password_<user id>".
Password policies
- Default policy (
ZB_AUTH_DEFAULT_PASSWORD_*): letters (a-z, A-Z), digits (0-9) and the special characters!#$%&()*+.-@/_, without blank spaces or the letterñ/Ñ. - Secure policy (
ZB_AUTH_SECURE_PASSWORD_*), applied when the profile hassecure_passwordenabled: additionally requires at least one uppercase letter, one lowercase letter, one digit and one special character.
Testing
The package's unit tests live in unit_tests/zb_auth/ (one test_*.py module per unit under test: test_backends.py, test_group_level_enum.py, test_lib_utils.py, test_token_serializer.py, test_user_model.py, test_user_serializer.py). Shared fixtures and the BaseTestCase used for endpoint tests live in unit_tests/zb_base/test_base.py.
# Run the whole project suite
python manage.py test
# Run every test of this package
python manage.py test unit_tests.zb_auth
# Run a specific module, class or method
python manage.py test unit_tests.zb_auth.test_user_model
python manage.py test unit_tests.zb_auth.test_token_serializer.TokenSerializerTestCase.test_method
# Verbose output
python manage.py test --verbosity=2
Every DB-accessing test runs against the project's own local/dev database: TEST_RUNNER = "mainApp.test_runner.NoDbTestRunner" never creates a separate test_* database, and isolation relies on TestCase's per-test transaction rollback — never TransactionTestCase, which would truncate real tables. DB-free units use django.test.SimpleTestCase instead.
Endpoint tests (exercising api/services/*.py through the real URLconf) extend BaseTestCase from unit_tests/zb_base/test_base.py, which provides create_test_user() (creates the user and its profile inside the transaction), do_login() (asserts the 200 and returns the sliding token) and request() (sends the token as Authorization: JWT <token>, matching AUTH_HEADER_TYPES = ("JWT",), not Bearer).
When testing the authentication flows, keep in mind that:
- a cache backend must be configured, otherwise the single-session validation cannot be exercised; the project's cache is a shared Memcached instance also used by the running dev server, so tests must never call
cache.clear(), only delete the specific keys they created; - email sending failures are logged and swallowed rather than raised, so assertions must target
django.core.mail.outboxrather than the HTTP response, and any test touchinguser/add/must wrap it inoverride_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")to avoid sending a real welcome email; request-password/always answers200for unknown emails by design;- exercising the failed-login lockout requires driving
ZB_AUTH_LOGIN_FAILED_COUNTfailed attempts against the same user and asserting the next attempt returns401(login_blocked) even with the correct password, then asserting the lock clears on the next successful login (or afterZB_AUTH_LOGIN_FAILED_LOCK_TIMEminutes).
Requirements
- Python >= 3.9 (the reference project runs on 3.11+)
Django >= 4.1djangorestframeworkdjangorestframework-simplejwt(includingrest_framework_simplejwt.token_blacklist)django-timezone-utilszibanu-django >= 1.5.0(base package)
Optional:
zibanu.django.logging— enables persistence of the authentication audit records.Pillow— required by DjangoImageFieldfor avatar handling.
Contributing
zibanu.django.auth lives in its own git repository, nested at zibanu/django/auth/ inside the project tree (remote: github.com/CQ-Inversiones/zibanu-django-auth). Commit changes to this package from that repository, never from the base zibanu/.git repo.
Guidelines:
- Do not modify the core package to extend it; prefer inheritance (
ModelViewSet,Model,Manager, serializers) or a separate application depending onzibanu.django.auth. - Document every public class and function with NumPy-style docstrings in English.
- Keep this README and
README.es.mdsynchronized: both must share the same structure and content. - Add or update migrations whenever a model changes (
python manage.py makemigrations zb_auth). - Update the translation catalogs under
locale/when translatable strings change. - Bump
__version__and__build__inzibanu/django/auth/__init__.pyfor every release.
License
Distributed under the GNU General Public License v3.0 or later (GPL-3.0-or-later). See the LICENSE file shipped with the installer at installers/zibanu-django-auth/LICENSE.
Developed by CQ Inversiones SAS. Copyright © 2019-2026. All rights reserved.
Changelog
All notable changes to zibanu.django.auth are documented here, one section per
principal version (x.y.z). Pre-releases of the same version (-dev.N,
-alpha.N, -beta.N) are folded into that version's section, never listed on
their own.
[3.0.0] — 2026-10-01
Added
- Failed-login lockout is now actually enforced:
UserControl.is_login_blockedplus theZB_AUTH_LOGIN_FAILED_COUNT/ZB_AUTH_LOGIN_FAILED_LOCK_TIMEsettings (previously declared but not consumed by any code path). UserControlnow inheritsDatedModel, addingcreated_at/modified_attimestamps (existing rows are backfilled once at migration time). Its pre-existing domain timestamps (requested_password_at,login_error_at, …) are unchanged — they describe the throttled events, not row mutation time (AUTH-R84).
Changed
- Breaking:
menu/now accepts anonymous (unauthenticated) callers, who receive only the items flaggedis_public. AnApplicationwith no declared member list stays open to every authenticated caller; one with a member list restricts access to it; a disabled application is invisible to everyone (404) — anonymous or not. Its membership check now resolves all member ids with a single query instead of two (AUTH-R54). user/list/now accepts anorder_byfield in the request body, validated against an allow-list (400on an invalid value), and filters results by the caller's privilege level directly in the queryset, so excluded users are never serialized (AUTH-S52).GroupService.listbuilds its queryset withselect_related("group_level"), removing a per-row query when resolving each group's level. It now also filters disallowed group levels (group_level__level__gte) directly in the queryset instead of serializing the whole table and filtering the response in Python: disallowed rows are no longer read/serialized, and the documented204-on-empty response is reachable again once the filter empties the result (AUTH-R10).- Breaking: the
on_user_created,on_user_updatedandon_user_deletedsignals now send the affected user as a model instance inuser— matching Django's ownuser_logged_in— and its serialized payload in the newuser_dataargument. Previouslyusercarried the serialized dictionary on these three signals but the model instance onon_change_password/on_request_password, so a receiver connected to both families had to branch on the payload type. A third-party receiver readingkwargs["user"]as a dictionary must switch tokwargs["user_data"](AUTH-S35). They are now dispatched withsender=self.__class__instead ofsender=self, matching every other signal dispatch site in the package (AUTH-S60). menu/'spermission_classeschanged fromIsOwnerOrReadOnlytoAllowAny. This is not a permission relaxation: that class's only hook,has_object_permission, is never invoked by this action (and would have dereferenced a non-existentobj.ownerif it were), so it was already a no-op. Real authorization is unchanged and lives where it always did: theis_publicfiltering inget_full_menu_from_userand theApplicationmembership check (AUTH-R2).- The internal
DefaultMenuInlineFormformset class is renamed toDefaultMenuInlineFormSet— the name it should have had, being a formset, not a form. The old name remains available as a backwards-compatible alias (AUTH-R94). Application.appidandApplicationManager.get_by_appidno longer describe the identifier as a UUID in their docstrings: it is an opaque unique string, a UUID4 only by default; non-UUID values are accepted by design (AUTH-R16).Applicationnow declares its owndb_table = "zb_auth_application"explicitly, matching every sibling model in the package. This is the name Django already derived, so it changes nothing on an existing database — it only stops the table name from silently following the app label if the app is ever renamed (AUTH-R77).- Root menu-item position uniqueness (
application+menu_indexwhereparent IS NULL) is now validated only byfull_clean(), no longer also by a database constraint. Every database-level approach evaluated — a partial or functional unique index — breaks a legitimate parent-menu deletion: Django's delete collector reassigns a deleted parent's children to root (parent_id = NULL) via anUPDATEthat runs before the rows are deleted, and during that step several rows legitimately holdmenu_index = 1at once, which a partial index rejects. This is not backend-specific — a PostgreSQL partial index fails exactly the same way, not only MariaDB/MySQL's variant. The bulk-write paths (bulk_update,bulk_create,QuerySet.update, raw SQL) still bypassfull_clean()and remain a known, open gap; closing it needs a model change (a non-null sentinel parent for root items so the existing sibling constraint covers them), which is design work, not QA (AUTH-R81 — the database-level fix originally proposed was evaluated and rejected as unsound on every supported backend, not implemented).
Removed
- Breaking: the
GroupMenumodel is gone, along with its unregistered admin view and the placeholder template that went with it; migration0018_delete_groupmenudrops thezb_auth_groupmenutable. It described theDefaultMenu↔Grouprelation a second time, in its own table, while the relation actually written by the admin and read byDefaultMenuManager.get_full_menu_from_useris theDefaultMenu.group_menumany-to-many field (tablezb_auth_group_menu). No code path ever instantiated or queriedGroupMenu, so its table was empty in every deployment and itsenabledflag never affected menu visibility. Per-group enable/disable, if wanted later, belongs on the surviving relation as an explicitthroughmodel (AUTH-I34, AUTH-I15, AUTH-S16, AUTH-S25). - The redundant, non-unique database index on
UserControl.useris gone: the field is aOneToOneField, already backed by Django's own unique index, so the second one only duplicated a b-tree every write had to maintain twice (AUTH-R40).
Fixed
- Credentials are now validated before the single-session check in login, so an unauthenticated caller can no longer force-logout another user's active session (AUTH-C2).
request_password's anonymous code-generation branch now dispatcheson_request_password, closing a gap that let that path bypass the request throttle (AUTH-C3).user/delete/now rejects deletion attempts against an account more privileged than the requester's own level (deleting a peer at the same level is still allowed), instead of letting any caller holdingauth.delete_userdelete any account (AUTH-I24).MenuService.listreturns400/404for caller errors instead of a generic500.get_by_appidonly enforces application membership when the application actually declares a member list, and only returns enabled applications.User.levelnow caches its resolved value on the instance and, when the caller has not already prefetchedgroups, resolves each group's level withselect_relatedinstead of one query per group;_create_user,_update_useranddestroyno longer pay the uncached cost twice per request (AUTH-R9, AUTH-R88).UserControl.userandUserProfile.usernow targetsettings.AUTH_USER_MODEL(a lazy string reference) instead of callingget_user_model()at class-body/import time (AUTH-R83, AUTH-R85).GroupListSerializer.get_levelno longer performs a redundantis not Nonecheck afterhasattr(the attribute can never beNoneonce it resolves) and now always returns a plainint, instead of sometimes returning the enum/model value directly (AUTH-R46).- The refresh-token serializer no longer raises when the incoming request carries no user id, and validates the session before blacklisting the refresh token, so a recoverable failure no longer burns a valid token.
- Logging in with a
closeflag no longer mutates the caller's request body, and no longer raises on an immutable request body. destroy/change_password/get_avatar/get_profileon the user service no longer turn a missing user or missing profile into a500: a missing email now resolves to404, and no profile now resolves to204.- Failed outbound mail (welcome/notification e-mail) is now logged instead of being silently swallowed.
_update_usernow resolves its target user explicitly, replacing an always-true identity comparison. It can also answer the documented404again: it no longer catchesModel.DoesNotExistand converts it into a genericValidationError(AUTH-R3).set_children_enabled/set_children_level(the default-menu manager) now walk the affected subtree breadth-first and write it with oneUPDATEper depth level, instead of recursing and issuing one query-plus-update per node (AUTH-I13). Reindexing sibling menu items (reindex_items) no longer risks a mid-batchIntegrityErroron MySQL/MariaDB either: the batch is first moved into a non-colliding temporary range before writing the final consecutive sequence, because these backends check a unique constraint per row as eachUPDATEin a batch executes (AUTH-R33).- User creation (
UserService._create_user) now checks the requester-vs-new-user privilege level after the new user's groups are assigned (the level is derived from them) and before its permissions are written, so a rejected creation no longer leaves permissions written on a user about to be rolled back (AUTH-S9). get_entity_permissionsno longer 500s for an anonymous caller: it returns the all-Falsepermission set instead (AUTH-I44). Refreshing a sliding token whose user no longer exists now answers401instead of raising an unhandledKeyError(AUTH-I45).- A failure inside an audit
Logwrite (e.g. a misconfigured optionalzibanu.django.loggingpackage) can no longer break the authentication flow it was auditing: the write is now wrapped in its owntry/except(AUTH-I47). admin.site.unregister(Group)no longer depends onINSTALLED_APPSordering having already registered it: the call is now guarded againstNotRegistered(AUTH-S58).- A failed-login audit row now records the account that failed to log in (previously left unresolved), so failures can be joined by user id (AUTH-S59).
ApplicationAdminView's changelist gainedsearch_fields/list_filter;ZbGroupAdminView's changelist gained back itsgroup_levelcolumn (previously gated behind a Django-version check that never applied to any supported version) plusselect_relatedto avoid a per-row query (AUTH-R41, AUTH-R92, AUTH-R97).DefaultMenuAdminViewno longer silently drops a validation error raised while saving the inlineDefaultMenurows:save_relatednow surfaces it the same waysave_modelalready did (AUTH-R35). The inline'sparentfield is now scoped to the application being edited, instead of offering every menu item across every application as a possible parent (AUTH-R93).DefaultMenu.save()now resynchronizes its internal level snapshot after persisting, so callingsave()twice on the same in-memory instance no longer re-runs the reindex/propagate logic a second time (AUTH-R4). It also no longer allows aparentbelonging to a differentApplication, which would silently graft a branch into a foreign menu tree (AUTH-R82); a menu item can now be created programmatically without an explicitmenu_level(AUTH-R80); the prospective level is now derived from the parent before deciding whether to propagate/reindex, instead of using the value from beforeclean()ran (AUTH-R79); the sibling reindex now runs after the row is persisted, not before, so it no longer excludes the row it was reindexing on behalf of (AUTH-R37); and an unreachable "missing parent" guard inclean()was removed, since accessingself.parentearlier already raises the sameDoesNotExist(AUTH-R36).- Updating a user's serialized profile no longer requires the
profilekey to be present in the request payload (AUTH-R5), and a user with no profile row yet no longer raisesRelatedObjectDoesNotExiston update — the missing row is created instead (AUTH-R32). MenuSerializer.get_childrenno longer risks a stack overflow on a cyclicchildren_map: a visited-ids set now cuts a cycle off, returning an empty branch instead of recursing forever (AUTH-R47).profile/update/'s documented406(empty body) is reachable again: the guard now tests body emptiness instead ofis not None, which an empty JSON object always satisfies (AUTH-R56). The same handler now builds its serializer throughget_serializer()instead of instantiating it directly, so it receives the DRF context (request/view/format) it was missing (AUTH-R57), and itsValidationErrorhandler now always answers the documented406, instead of forwarding whatever status code the underlying exception happened to carry (AUTH-Q04).- A background-thread failure while connecting the package's signal
receivers on startup no longer vanishes silently:
after_readynow logs the exception, and the thread itself is daemonized and its handle kept on theAppConfiginstead of dropped (AUTH-R24). group_post_save_receivernow respects Django'sraw=Trueflag: loading a fixture (loaddata) no longer creates a spuriousGroupLevelrow for aGroupthe fixture already provides one for (AUTH-R39).- The failed-login audit
detailis now read from the same key (User.USERNAME_FIELD) used to resolve the account, instead of a hardcoded"username"(AUTH-R71). - Multipart/form-encoded user-service requests (the realistic path for
avatar uploads) no longer break on the underlying
QueryDict: scalar values are now unwrapped before reaching pk lookups orgroups.set(), which previously received single-item lists instead of the scalar value they expected (AUTH-R6).UserService.list'skwargsare now updated in place instead of rebound, so a caller-supplied kwarg is no longer silently discarded (AUTH-R7). - User creation (
create), password change (change_password) and user deletion (destroy) now run their side effects (signal dispatch, welcome/notification mail, cache eviction) inside the sametransaction.atomic()block as the underlying write, so a failure partway through rolls the whole operation back instead of leaving it half-committed under a500(AUTH-R30, AUTH-Q01, AUTH-Q02, AUTH-Q03). create/updateno longer leak the raw internal exception message (str(exc)) as the API error detail (AUTH-Q06)._send_mailnow catches any exception, not onlysmtplib.SMTPException, so a broken template or a failingsend_mail-signal receiver is logged and swallowed instead of escaping uncontrolled (AUTH-Q13).- The refresh-token serializer no longer raises when no HTTP request is
available in its context: it now fails with the same documented error
family instead of a
500insideget_cache_key(AUTH-R50).EmailTokenRefreshSlidingSerializer.get_serializer_classis now a classmethod resolved with the same precedence as the parent implementation —serializer_classfirst (AUTH-R51) — and refresh now generates the new token from the serializer's owntoken_classinstead of a hardcodedSlidingToken, so a subclass swapping the token type refreshes with the right one (AUTH-R52). get_file_digestnow seeks the file back to position 0 before computing its digest, instead of reading from whatever position a prior read left it at (AUTH-R86).UserProfile.clean()'s "existing row" lookup now fetches by primary key instead of byuser, matching the actual invariant being checked — "the stored version of me" (AUTH-R87).- Fixed the reverse operation of the case-insensitive e-mail migration
(
0022): it dropped the generated column before its index, which MariaDB rejects with error 1072; the index is now dropped first.
Security
- Critical:
profile/update/had no real authentication check. The endpoint declaredpermission_classes = [IsOwnerOrReadOnly], but that class's only hook,has_object_permission, is invoked by DRF exclusively fromget_object()— which no action on this viewset calls, sinceupdateresolves its target fromrequest.userdirectly.has_permissionwas therefore left atBasePermission's always-Truedefault, silently dropping the project-wideIsAuthenticateddefault it overrode. The only thing standing between an anonymous caller and the view body was an incidental500(UserProfile(user=AnonymousUser)raising), not a control — and it made the documented401unreachable. Restored toIsAuthenticated(AUTH-R1). - Major: the group admin form accepted the reserved
SUPER_USER(0) andSTAFF(1) levels — which mirror theis_superuser/is_staffuser flags — as a group's level, granting every member of that group superuser-equivalent authority. The field is now aTypedChoiceFieldwhose choices exclude both values, so a forged POST of0/1is rejected by the field's own server-side validation, not only hidden from the widget (AUTH-R96). The same field now reads withcleaned_data.get()instead of.pop()(no longer mutating a dict owned by Django) and coerces its value tointinstead of persisting the submitted value as a string (AUTH-R44). - User accounts differing only by letter case could coexist, because e-mail
was compared case-insensitively at login but case-sensitively everywhere
else, and
auth.User.emailcarries no uniqueness constraint at all. Login always resolved the lower-pkaccount, silently locking the other one out with no diagnosable error, and password recovery quietly no-opped for the wrong casing. E-mail lookups are now uniformly case-insensitive throughout the package, and a new migration enforces the invariant at the database level: a generated column plus unique index on MariaDB/MySQL older than 8.0.13 (whereUniqueConstraint(Lower(...))would otherwise be silently skipped — this backend does not support expression indexes and Django does not fail when the feature is missing), a functional index on MySQL ≥ 8.0.13, and a partial index on PostgreSQL/Oracle; the migration verifies its own creation instead of trusting the backend (AUTH-R29, AUTH-R53). - The session cache key (
get_cache_key) is now the MD5 hex digest of the composite key instead of the raw"<username>.<hashed origin>"concatenation, closing an unbounded-length/character cache-key issue (AUTH-R74). - The failed-login and password-request lockout counters are now updated
under a row lock (
select_for_update) inside their own transaction, closing a race where two concurrent requests against the same account could both read the same pre-increment count and each independently decide "not yet locked" (AUTH-R34). - The password-request lockout no longer raises
TypeError— silently disabling itself — when theUserControlrow has norequested_password_atyet, e.g. one created first by the failed-login branch (AUTH-R69). It now also triggers at the documentedZB_AUTH_REQUEST_PASSWORD_MAX_COUNT, instead of one request later than that (AUTH-R8), and a verification attempt against an invalid reset code now counts against the same lockout counter as a request, closing a path that let a code be guessed without limit (AUTH-R60). - A failing write to the lockout counters is now logged at
criticalwith the affected user id, instead of at the same severity as routine noise, so it cannot be mistaken for it (AUTH-R70). - Login now hashes the submitted password against a transient user instance
even when the supplied address does not resolve to any account, closing a
timing side-channel that had been reopened in the sliding-token serializer
(the transient-instance mitigation already applied by
ZbAuthBackend) (AUTH-R28). - A password change or password reset now evicts the caller's own cached session token, so a credential issued before the change stops working immediately instead of remaining valid until it expires on its own (AUTH-R58, AUTH-R59).
Documentation
- README (EN/ES) and the OpenAPI spec rewritten against current behaviour:
menu/documented as open to anonymous callers;user/list/'sorder_byallow-list and400response documented;user/delete/'s privilege-level rejection documented; theMultipleLoginErrorresponse corrected from401to its actual412 Precondition Failed. - README (EN/ES) corrected to stop describing
ProfileServiceas usingIsOwnerOrReadOnly(it usesIsAuthenticated— see Security, AUTH-R1) and to describeget_cache_key's current hashed key format (AUTH-Q10). - OpenAPI examples for
user.{en,es}.yamlnow use the error codes the service actually emits (does_not_exists,validation_error,delete_user) instead of placeholders (AUTH-Q08); the documented-but- unreachable500response was removed fromgrouplevel.{en,es}.yaml, sinceGroupLevelService.listhas no branch that can produce it (AUTH-Q09). retrieve's docstring now documents the406it can actually return (AUTH-Q11);LogoutUser.logout's duplicatedNotesdocstring section was merged into one (AUTH-Q12); a docstring citing a nonexistent function was corrected (AUTH-R17).- Type-hint coverage across services, serializers and models raised from roughly 38% to 78.2%, annotated directly from the existing NumPy docstrings with no behavior change (AUTH-R43).
- Package-wide QA cleanup: explicit
__all__exports in every__init__.py(replacing star re-exports), module-level loggers (getLogger(__name__)) in place of the root logger throughoutlib/receivers.py,api/services/user.pyandapi/serializers/token.py, and consistent PEP 8 formatting (line length, blank lines, trailing newlines) — no behavior change beyond what's called out above.
[2.0.2] — never released (superseded by 3.0.0)
This principal version only ever existed as
2.0.2-dev.0ondevelop; it was bumped straight to3.0.0-dev.0without an intervening plain2.0.2release. Its commits are listed here for completeness — a consumer could never install this exact version — and everything below shipped, unchanged, as part of3.0.0once that major bump landed.
Added
- Bundled bilingual (EN/ES) OpenAPI 3.0 spec under
openapi/, and a Swagger UI served fromurls.pyonly whensettings.DEBUGisTrue.
Fixed
- The login backend no longer 500s on an unknown username (a
DoesNotExists/DoesNotExisttypo had been swallowing the real exception). - The login backend now hashes/compares the password against a real user instance instead of the model class, restoring the timing-attack mitigation that had been silently disabled for unknown usernames.
- The async authentication path no longer crashes: a broken default value was fixed and the underlying synchronous ORM call is now wrapped correctly.
- Logout no longer silently discards exceptions raised while blacklisting the refresh token.
GroupService.listnow enforces the same permission check as its sibling endpoints (previously it had none).- Building the menu tree no longer issues one query per node (single prefetch instead).
- Added the migration for
UserControl.user, which the model had declared without ever generating it.
Security
- User data serialization no longer exposes the password field (
write_onlynow, previously readable).
Documentation
- Docstrings added/fixed across the package (no behavior change).
- README rewritten from a Spanish-only, endpoints-only document (missing
installation/configuration/testing/requirements/license) into a full
bilingual guide — itself superseded by the further rewrite that shipped
under
3.0.0.
[2.0.1] — 2026-07-22
No tag exists for this version. It was released from
main, and that release point is not part ofdevelop's own history.
Changed
- Refined the password-change, password-request, and verification-code e-mail templates (HTML and plain text).
Documentation
- Updated the Spanish translation catalog for the template-facing strings above.
[2.0.0] — 2026-07-01
Added
- Password-recovery flow: generates and e-mails a one-time verification code
(
request-password/send-code), with dedicated HTML/plain-text templates for the new-user, request-password, and send-code e-mails. - Foundation for failed-login lockout: the
UserControlmodel tracks failed login attempts and lock timestamps via signal receivers, configurable through newZB_AUTH_*settings. (Enforcement of the lockout itself,is_login_blocked, was not completed until3.0.0— see that section.) - Group-level-based CRUD permission handling for the
menu/endpoint. - Spanish translation catalog for user-facing template strings.
Fixed
- Two related superuser menu-loading errors in the default-menu admin.
- A
zb_group_adminincompatibility with Django versions earlier than 4. - An import error in
admin.py. - A validation error when saving a default-menu item in the admin.
Changed
GroupLevelEnummoved out of a single module into its ownlib/enumspackage.- Application-id handling reworked in the
Applicationmodel and its manager.
[1.2.8] — never released (superseded by 2.0.0)
This principal version only ever existed as
1.2.8-alpha.0; the very next version-bump jumped straight to2.0.0-dev.0. Listed for completeness — a consumer could never install this exact version.
Added
- The original
menu/REST endpoint: an unauthenticated menu-creation service and an authenticated menu edit/retrieve service, with their supporting serializer and URL routes. - Async support in the authentication backend (
ZbAuthBackend).
[1.2.7] — 2025-08-27
Tag
1.2.7is dated 2025-08-27, but the version string in__init__.pystayed at1.2.7for several months of further development afterwards (through 2025-12-10) before the next bump; those commits are included here — see the report for why.
Added
- Admin-managed models and forms for applications, default menus/menu items,
group-menu associations, and group levels, including an inline
group/menu editing form and an
ApplicationProxyadmin model.
Fixed
- Compatibility with Python versions earlier than 3.11 in the token serializer.
- Application UUID could not be edited correctly from the admin.
- A validation error in the inline home-menu admin form.
[1.2.6] — 2025-06-21
Added
- Signal-based auditing of user authentication/access events.
[1.2.5] — 2025-03-14
This is the date of the first commit in this repository (the package's split into its own repo);
1.2.5may already have existed before that point in the former monorepo history, which this repository does not contain. See the report.
Added
- Package split into its own independently versioned repository.
- Migration for the
UserProfile.timezonefield, following a related timezone update in the repository package.
Metadata
Release files for zibanu-django-auth 3.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| zibanu_django_auth-3.0.0.tar.gz | 242.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| zibanu_django_auth-3.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 507.9 kB
Release files / zibanu_django_auth-3.0.0.tar.gz
| Download URL | zibanu_django_auth-3.0.0.tar.gz |
|---|---|
| Size | 242.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
13fb807e3d43b772ec2888e442d84b81bc3f61888d01e950ea656656ad92de5c
|
|
BLAKE2b-256 checksum How to use checksums |
5aece1bd39a6208e24ba7147569e2f9f4746ba208463de58376828e4d2bc8625
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.11.13
|
Release files / zibanu_django_auth-3.0.0-py3-none-any.whl
| Download URL | zibanu_django_auth-3.0.0-py3-none-any.whl |
|---|---|
| Size | 265.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3846bc1a717c114dbaeba8b9870f69985bc10f8687a35ea817b70ba8a4d790f3
|
|
BLAKE2b-256 checksum How to use checksums |
fd530aabecf477bd7365f934e7568bcd140f013c7f6986a090b89bbff77f36b9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.11.13
|