Skip to main content

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

  1. Overview
  2. Features
  3. Installation
  4. Quick Start
  5. Usage
  6. REST API Endpoints
  7. API Reference
  8. Configuration
  9. Testing
  10. Requirements
  11. Contributing
  12. 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 its is_public items.
  • 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 close flag 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_COUNT consecutive failed attempts the account is locked for ZB_AUTH_LOGIN_FAILED_LOCK_TIME minutes (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 the is_public items.
  • 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.logging package 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): public appid (UUID) of the application.

Responses

  • 200: root items, each one with its nested children and its entity_permissions (view, add, change, delete).
  • 204: no menu item is visible to the caller.
  • 400: app_id is 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: email is 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 lacks auth.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 lacks auth.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 the multiple_login flag.
  • 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 when ZB_AUTH_AUTO_USERNAME / ZB_AUTH_AUTO_PASSWORD are enabled.
  • profile: nested profile data; defaults to an empty object.
  • roles / permissions: identifiers of the granted groups and permissions. The key names are configurable through ZB_AUTH_ROLES_KEY and ZB_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 lacks auth.add_user or zb_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

  • id or email (one required): identifies the user to delete.

Responses

  • 200: the user was deleted.
  • 401: authentication required.
  • 403: the author lacks auth.delete_user / zb_auth.delete_userprofile, or the user owns protected child records.
  • 404: the user does not exist.
  • 406: neither id nor email was 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 of first_name (default), last_name, email, last_login or is_active, optionally prefixed with - for descending order.

Responses

  • 200: list of users with full_name, email, last_login, is_staff, is_superuser, id, first_name, last_name and level.
  • 204: no user is visible.
  • 400: order_by names a field outside the allowed set.
  • 401: authentication required.
  • 403: the user lacks auth.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 lacks auth.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

  • id or email (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 lacks auth.change_user or zb_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 the close_sessions property.
  • EmailTokenObtainSlidingSerializer — issues a SlidingToken, caches it when the single-session policy applies and dispatches user_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 the user_id claim 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 — adds avatar and app_settings. The avatar is handled by HybridImageField, which accepts both a multipart file and a base64 string, and is rebuilt at instantiation time to honour the current ZB_AUTH_AVATAR_BASE64 and ZB_AUTH_AVATAR_SIZE values.

Authorization serializers

  • GroupListSerializer — id, name, level (falls back to OPERATOR when the group has no level).
  • PermissionSerializer — id, name.
  • MenuSerializer — menu fields plus the recursive children list and the computed entity_permissions map. It expects user and children_map in 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, with authenticate() and aauthenticate() 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 — adds get_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 SimpleJWT TokenUser, or from kwargs["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 GroupLevel record for every new group (post_save on Group);
  • audit user creation/update/deletion;
  • audit on_change_password, on_request_password, user_logged_in and user_login_failed, and maintain both the password-request counters/locks and the failed-login counters/locks in UserControl (login_error_counter/login_error_blocked are incremented on user_login_failed and reset on the next user_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 native Group admin, adding the group level field and column.
  • ApplicationAdminView — application management (name, description, enabled).
  • DefaultMenuAdminView — menu tree of an application, edited as inlines over ApplicationProxy. The groups a menu item is granted to are edited here, through the group_menu field.

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 has secure_password enabled: 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.outbox rather than the HTTP response, and any test touching user/add/ must wrap it in override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") to avoid sending a real welcome email;
  • request-password/ always answers 200 for unknown emails by design;
  • exercising the failed-login lockout requires driving ZB_AUTH_LOGIN_FAILED_COUNT failed attempts against the same user and asserting the next attempt returns 401 (login_blocked) even with the correct password, then asserting the lock clears on the next successful login (or after ZB_AUTH_LOGIN_FAILED_LOCK_TIME minutes).

Requirements

  • Python >= 3.9 (the reference project runs on 3.11+)
  • Django >= 4.1
  • djangorestframework
  • djangorestframework-simplejwt (including rest_framework_simplejwt.token_blacklist)
  • django-timezone-utils
  • zibanu-django >= 1.5.0 (base package)

Optional:

  • zibanu.django.logging — enables persistence of the authentication audit records.
  • Pillow — required by Django ImageField for 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:

  1. Do not modify the core package to extend it; prefer inheritance (ModelViewSet, Model, Manager, serializers) or a separate application depending on zibanu.django.auth.
  2. Document every public class and function with NumPy-style docstrings in English.
  3. Keep this README and README.es.md synchronized: both must share the same structure and content.
  4. Add or update migrations whenever a model changes (python manage.py makemigrations zb_auth).
  5. Update the translation catalogs under locale/ when translatable strings change.
  6. Bump __version__ and __build__ in zibanu/django/auth/__init__.py for 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_blocked plus the ZB_AUTH_LOGIN_FAILED_COUNT / ZB_AUTH_LOGIN_FAILED_LOCK_TIME settings (previously declared but not consumed by any code path).
  • UserControl now inherits DatedModel, adding created_at/modified_at timestamps (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 flagged is_public. An Application with 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 an order_by field in the request body, validated against an allow-list (400 on 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.list builds its queryset with select_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 documented 204-on-empty response is reachable again once the filter empties the result (AUTH-R10).
  • Breaking: the on_user_created, on_user_updated and on_user_deleted signals now send the affected user as a model instance in user — matching Django's own user_logged_in — and its serialized payload in the new user_data argument. Previously user carried the serialized dictionary on these three signals but the model instance on on_change_password / on_request_password, so a receiver connected to both families had to branch on the payload type. A third-party receiver reading kwargs["user"] as a dictionary must switch to kwargs["user_data"] (AUTH-S35). They are now dispatched with sender=self.__class__ instead of sender=self, matching every other signal dispatch site in the package (AUTH-S60).
  • menu/'s permission_classes changed from IsOwnerOrReadOnly to AllowAny. 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-existent obj.owner if it were), so it was already a no-op. Real authorization is unchanged and lives where it always did: the is_public filtering in get_full_menu_from_user and the Application membership check (AUTH-R2).
  • The internal DefaultMenuInlineForm formset class is renamed to DefaultMenuInlineFormSet — 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.appid and ApplicationManager.get_by_appid no 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).
  • Application now declares its own db_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_index where parent IS NULL) is now validated only by full_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 an UPDATE that runs before the rows are deleted, and during that step several rows legitimately hold menu_index = 1 at 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 bypass full_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 GroupMenu model is gone, along with its unregistered admin view and the placeholder template that went with it; migration 0018_delete_groupmenu drops the zb_auth_groupmenu table. It described the DefaultMenu ↔ Group relation a second time, in its own table, while the relation actually written by the admin and read by DefaultMenuManager.get_full_menu_from_user is the DefaultMenu.group_menu many-to-many field (table zb_auth_group_menu). No code path ever instantiated or queried GroupMenu, so its table was empty in every deployment and its enabled flag never affected menu visibility. Per-group enable/disable, if wanted later, belongs on the surviving relation as an explicit through model (AUTH-I34, AUTH-I15, AUTH-S16, AUTH-S25).
  • The redundant, non-unique database index on UserControl.user is gone: the field is a OneToOneField, 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 dispatches on_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 holding auth.delete_user delete any account (AUTH-I24).
  • MenuService.list returns 400/404 for caller errors instead of a generic 500.
  • get_by_appid only enforces application membership when the application actually declares a member list, and only returns enabled applications.
  • User.level now caches its resolved value on the instance and, when the caller has not already prefetched groups, resolves each group's level with select_related instead of one query per group; _create_user, _update_user and destroy no longer pay the uncached cost twice per request (AUTH-R9, AUTH-R88).
  • UserControl.user and UserProfile.user now target settings.AUTH_USER_MODEL (a lazy string reference) instead of calling get_user_model() at class-body/import time (AUTH-R83, AUTH-R85).
  • GroupListSerializer.get_level no longer performs a redundant is not None check after hasattr (the attribute can never be None once it resolves) and now always returns a plain int, 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 close flag no longer mutates the caller's request body, and no longer raises on an immutable request body.
  • destroy/change_password/get_avatar/get_profile on the user service no longer turn a missing user or missing profile into a 500: a missing email now resolves to 404, and no profile now resolves to 204.
  • Failed outbound mail (welcome/notification e-mail) is now logged instead of being silently swallowed.
  • _update_user now resolves its target user explicitly, replacing an always-true identity comparison. It can also answer the documented 404 again: it no longer catches Model.DoesNotExist and converts it into a generic ValidationError (AUTH-R3).
  • set_children_enabled/set_children_level (the default-menu manager) now walk the affected subtree breadth-first and write it with one UPDATE per 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-batch IntegrityError on 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 each UPDATE in 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_permissions no longer 500s for an anonymous caller: it returns the all-False permission set instead (AUTH-I44). Refreshing a sliding token whose user no longer exists now answers 401 instead of raising an unhandled KeyError (AUTH-I45).
  • A failure inside an audit Log write (e.g. a misconfigured optional zibanu.django.logging package) can no longer break the authentication flow it was auditing: the write is now wrapped in its own try/except (AUTH-I47).
  • admin.site.unregister(Group) no longer depends on INSTALLED_APPS ordering having already registered it: the call is now guarded against NotRegistered (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 gained search_fields/list_filter; ZbGroupAdminView's changelist gained back its group_level column (previously gated behind a Django-version check that never applied to any supported version) plus select_related to avoid a per-row query (AUTH-R41, AUTH-R92, AUTH-R97).
  • DefaultMenuAdminView no longer silently drops a validation error raised while saving the inline DefaultMenu rows: save_related now surfaces it the same way save_model already did (AUTH-R35). The inline's parent field 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 calling save() 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 a parent belonging to a different Application, which would silently graft a branch into a foreign menu tree (AUTH-R82); a menu item can now be created programmatically without an explicit menu_level (AUTH-R80); the prospective level is now derived from the parent before deciding whether to propagate/reindex, instead of using the value from before clean() 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 in clean() was removed, since accessing self.parent earlier already raises the same DoesNotExist (AUTH-R36).
  • Updating a user's serialized profile no longer requires the profile key to be present in the request payload (AUTH-R5), and a user with no profile row yet no longer raises RelatedObjectDoesNotExist on update — the missing row is created instead (AUTH-R32).
  • MenuSerializer.get_children no longer risks a stack overflow on a cyclic children_map: a visited-ids set now cuts a cycle off, returning an empty branch instead of recursing forever (AUTH-R47).
  • profile/update/'s documented 406 (empty body) is reachable again: the guard now tests body emptiness instead of is not None, which an empty JSON object always satisfies (AUTH-R56). The same handler now builds its serializer through get_serializer() instead of instantiating it directly, so it receives the DRF context (request/view/format) it was missing (AUTH-R57), and its ValidationError handler now always answers the documented 406, 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_ready now logs the exception, and the thread itself is daemonized and its handle kept on the AppConfig instead of dropped (AUTH-R24).
  • group_post_save_receiver now respects Django's raw=True flag: loading a fixture (loaddata) no longer creates a spurious GroupLevel row for a Group the fixture already provides one for (AUTH-R39).
  • The failed-login audit detail is 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 or groups.set(), which previously received single-item lists instead of the scalar value they expected (AUTH-R6). UserService.list's kwargs are 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 same transaction.atomic() block as the underlying write, so a failure partway through rolls the whole operation back instead of leaving it half-committed under a 500 (AUTH-R30, AUTH-Q01, AUTH-Q02, AUTH-Q03).
  • create/update no longer leak the raw internal exception message (str(exc)) as the API error detail (AUTH-Q06).
  • _send_mail now catches any exception, not only smtplib.SMTPException, so a broken template or a failing send_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 500 inside get_cache_key (AUTH-R50). EmailTokenRefreshSlidingSerializer.get_serializer_class is now a classmethod resolved with the same precedence as the parent implementation — serializer_class first (AUTH-R51) — and refresh now generates the new token from the serializer's own token_class instead of a hardcoded SlidingToken, so a subclass swapping the token type refreshes with the right one (AUTH-R52).
  • get_file_digest now 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 by user, 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 declared permission_classes = [IsOwnerOrReadOnly], but that class's only hook, has_object_permission, is invoked by DRF exclusively from get_object() — which no action on this viewset calls, since update resolves its target from request.user directly. has_permission was therefore left at BasePermission's always-True default, silently dropping the project-wide IsAuthenticated default it overrode. The only thing standing between an anonymous caller and the view body was an incidental 500 (UserProfile(user=AnonymousUser) raising), not a control — and it made the documented 401 unreachable. Restored to IsAuthenticated (AUTH-R1).
  • Major: the group admin form accepted the reserved SUPER_USER (0) and STAFF (1) levels — which mirror the is_superuser/is_staff user flags — as a group's level, granting every member of that group superuser-equivalent authority. The field is now a TypedChoiceField whose choices exclude both values, so a forged POST of 0/1 is rejected by the field's own server-side validation, not only hidden from the widget (AUTH-R96). The same field now reads with cleaned_data.get() instead of .pop() (no longer mutating a dict owned by Django) and coerces its value to int instead 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.email carries no uniqueness constraint at all. Login always resolved the lower-pk account, 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 (where UniqueConstraint(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 the UserControl row has no requested_password_at yet, e.g. one created first by the failed-login branch (AUTH-R69). It now also triggers at the documented ZB_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 critical with 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/'s order_by allow-list and 400 response documented; user/delete/'s privilege-level rejection documented; the MultipleLoginError response corrected from 401 to its actual 412 Precondition Failed.
  • README (EN/ES) corrected to stop describing ProfileService as using IsOwnerOrReadOnly (it uses IsAuthenticated — see Security, AUTH-R1) and to describe get_cache_key's current hashed key format (AUTH-Q10).
  • OpenAPI examples for user.{en,es}.yaml now use the error codes the service actually emits (does_not_exists, validation_error, delete_user) instead of placeholders (AUTH-Q08); the documented-but- unreachable 500 response was removed from grouplevel.{en,es}.yaml, since GroupLevelService.list has no branch that can produce it (AUTH-Q09).
  • retrieve's docstring now documents the 406 it can actually return (AUTH-Q11); LogoutUser.logout's duplicated Notes docstring 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 throughout lib/receivers.py, api/services/user.py and api/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.0 on develop; it was bumped straight to 3.0.0-dev.0 without an intervening plain 2.0.2 release. Its commits are listed here for completeness — a consumer could never install this exact version — and everything below shipped, unchanged, as part of 3.0.0 once that major bump landed.

Added

  • Bundled bilingual (EN/ES) OpenAPI 3.0 spec under openapi/, and a Swagger UI served from urls.py only when settings.DEBUG is True.

Fixed

  • The login backend no longer 500s on an unknown username (a DoesNotExists/DoesNotExist typo 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.list now 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_only now, 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 of develop'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 UserControl model tracks failed login attempts and lock timestamps via signal receivers, configurable through new ZB_AUTH_* settings. (Enforcement of the lockout itself, is_login_blocked, was not completed until 3.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_admin incompatibility 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

  • GroupLevelEnum moved out of a single module into its own lib/enums package.
  • Application-id handling reworked in the Application model 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 to 2.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.7 is dated 2025-08-27, but the version string in __init__.py stayed at 1.2.7 for 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 ApplicationProxy admin 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.5 may 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.timezone field, 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)

Source distribution for zibanu-django-auth 3.0.0
File Size Uploaded
zibanu_django_auth-3.0.0.tar.gz 242.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for zibanu-django-auth 3.0.0
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

This release

3.0.0 This release

2 release files

2.0.1

2 release files

2.0.0

2 release files

1.2.7

2 release files

1.2.6

2 release files

1.2.5

2 release files

1.2.0

2 release files

1.1.8

2 release files

1.1.7

2 release files

1.1.5

2 release files

1.1.3

2 release files

1.1.0

2 release files

1.0.5

2 release files

1.0.4

2 release files

1.0.3

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page