Metadata-Version: 2.4
Name: zibanu-django-auth
Version: 3.0.0
Summary: Zibanu authentication library for django projects
Author-email: Mario Cerón <mario.ceron@cqinversiones.co>
License-Expression: GPL-3.0-or-later
Keywords: django,zibanu,library
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.1
Classifier: Environment :: Web Environment
Classifier: Operating System :: OS Independent
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=4.1
Requires-Dist: djangorestframework
Requires-Dist: django-timezone-utils
Requires-Dist: djangorestframework-simplejwt
Requires-Dist: zibanu-django>=3.0.0
Dynamic: license-file

# 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](README.es.md).*

---

## Table of Contents

1. [Overview](#overview)
2. [Features](#features)
3. [Installation](#installation)
4. [Quick Start](#quick-start)
5. [Usage](#usage)
6. [REST API Endpoints](#rest-api-endpoints)
7. [API Reference](#api-reference)
8. [Configuration](#configuration)
9. [Testing](#testing)
10. [Requirements](#requirements)
11. [Contributing](#contributing)
12. [License](#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:

```bash
pip install ./installers/zibanu-django-auth/
```

Or from PyPI:

```bash
pip install zibanu-django-auth
```

### 2. Add it to `INSTALLED_APPS`

In `mainApp/components/base.py`:

```python
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

```python
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:

```python
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`:

```python
from django.urls import include, path

urlpatterns = [
    path("auth/", include("zibanu.django.auth.urls")),
]
```

### 6. Run the migrations

```bash
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](#configuration).

### UserProfile

## Quick Start

Authenticate a user and call a protected endpoint:

```python
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:

```python
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

```python
# 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:

```python
# myapp/serializers.py
from zibanu.django.auth.api.serializers import UserTokenSerializer


class MyTokenSerializer(UserTokenSerializer):
    class Meta(UserTokenSerializer.Meta):
        fields = UserTokenSerializer.Meta.fields + ("email",)
```

```python
# settings
ZB_AUTH_USER_TOKEN_SERIALIZER = "myapp.serializers.MyTokenSerializer"
```

#### Consuming the authentication signals

```python
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

```python
from zibanu.django.auth.lib import GroupLevelEnum
from zibanu.django.auth.models import GroupLevel

GroupLevel.objects.filter(group=group).update(level=GroupLevelEnum.ADMIN)
```

```python
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:

```python
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](#failed-login-lockout)).
- `412`: the user already holds an open session for this origin (`multiple_login`).
- `500`: any other non-controlled exception.

```python
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.

```python
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.

```python
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`.

```bash
# 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.
