Cluster ERP signs tenant users in with a user name or company e-mail and a password, then issues a short-lived access token and a rotating refresh token. The web client receives both tokens as HttpOnly cookies; the desktop client and scripts receive them in the response body and send a Bearer header. This page explains the sign-in routes in modules/auth/login/routes.py, the token helpers in modules/auth/jwt_utils.py, the cookie transport in modules/auth/session_cookies.py, password hashing in modules/auth/password.py, revocation through auth_token_blacklist, and the permission memory in modules/auth/auth_cache.py. The operator console has its own sign-in; see operator console.
In short¶
- Passwords are hashed with Argon2 through
argon2.PasswordHasher, and a hash is upgraded when its parameters are outdated (src/cluster_erp/modules/auth/password.py:3-21,src/cluster_erp/modules/auth/users/services.py:298-308). - Access and refresh tokens are signed with the
secret_keysetting; each carries a uniquejtiand atypeclaim thatverify_tokenchecks (src/cluster_erp/modules/auth/jwt_utils.py:10-60,src/cluster_erp/modules/auth/jwt_utils.py:80-91). - The web client asks for cookie transport with the
X-Token-Transportheader; cookie-carried writes must also carry theX-Requested-Withheader (src/cluster_erp/modules/auth/session_cookies.py:31-39,src/cluster_erp/modules/auth/session_cookies.py:112-121). - Every refresh spends the presented refresh token and issues a new pair; the session end is fixed at sign-in and survives rotation (
src/cluster_erp/modules/auth/login/routes.py:283-354). - Sign-out, a password change, and an anonymisation stamp
last_logout_at, which invalidates every token issued earlier (src/cluster_erp/modules/auth/login/routes.py:423-424,src/cluster_erp/modules/auth/users/services.py:273-276,src/cluster_erp/modules/auth/privacy_handlers.py:96,src/cluster_erp/modules/auth/guards.py:153-165).
How it works¶
Sign-in and refresh flow¶
The sequence shows a web sign-in, a later request, and a refresh.
sequenceDiagram participant C as web client participant L as login route participant U as UserService participant G as get_current_user participant R as refresh route C->>L: POST /api/v1/auth/login with X-Token-Transport L->>L: _check_attempt_budget L->>U: get_by_identifier, authenticate U-->>L: AuthUser or None L-->>C: user and permissions, tokens in HttpOnly cookies C->>G: request with access cookie and X-Requested-With G->>G: verify_token, jti blacklist, last_logout_at C->>R: POST /api/v1/auth/refresh with refresh cookie R->>R: spend old jti, check last_logout_at R-->>C: new access and refresh cookies
In prose:
- The login route first checks the failed-attempt budget for the pair of client address and user name (
src/cluster_erp/modules/auth/login/routes.py:129-131). get_by_identifierresolves the user name. When nothing matches and the identifier holds@, it tries the company e-mail, then the part before@as a user name (src/cluster_erp/modules/auth/users/services.py:92-119).- A locked account is refused before the password is checked (
src/cluster_erp/modules/auth/login/routes.py:135-147). authenticaterefuses inactive users, verifies the password, and re-hashes it when needed (src/cluster_erp/modules/auth/users/services.py:298-308).- On success, the route clears the failure counter, stamps
last_login_atat most once a minute, and loads role and permission codes (src/cluster_erp/modules/auth/login/routes.py:181-201). - It issues the tokens. In multi-tenant mode both tokens carry the tenant key resolved for the request (
src/cluster_erp/modules/auth/login/routes.py:203-228). - A cookie client gets empty token fields in the body and the tokens in cookies; other clients get the tokens in the body (
src/cluster_erp/modules/auth/login/routes.py:230-257). - Every protected request runs
get_current_user. It reads the credential, applies the CSRF rule, verifies the access token, requires ajti, checks the blacklist, loads the live user with roles, and comparesiatwithlast_logout_at(src/cluster_erp/modules/auth/guards.py:85-167). - The refresh route verifies the refresh token, blacklists its
jti, refuses a second use after a short grace window, refuses tokens issued before the last sign-out, and issues a new pair with the same absolute session end (src/cluster_erp/modules/auth/login/routes.py:260-370).
Token contents¶
| Token | Claims | Lifetime setting | Source |
|---|---|---|---|
| Access | sub, roles, type set to access, jti, iat, exp, optional tenant |
jwt_access_expire_minutes |
src/cluster_erp/modules/auth/jwt_utils.py:10-30 |
| Refresh | sub, type set to refresh, jti, iat, exp, sexp (absolute session end), persist, optional tenant |
jwt_refresh_expire_days, or jwt_session_hours without "remember me" |
src/cluster_erp/modules/auth/jwt_utils.py:33-60, src/cluster_erp/modules/auth/login/routes.py:215-228 |
read_tenant_claim reads the tenant claim of a signature-verified token and ignores expiry. It only routes a request to its tenant database; authentication still runs verify_token (src/cluster_erp/modules/auth/jwt_utils.py:63-77). See multi-tenancy.
Cookie transport¶
set_session_cookies writes two cookies. Both are HttpOnly, SameSite=Strict, and Secure over HTTPS. The access cookie uses path / and takes the __Host- prefix over HTTPS; the refresh cookie is limited to the path /api/v1/auth (src/cluster_erp/modules/auth/session_cookies.py:46-54, src/cluster_erp/modules/auth/session_cookies.py:124-153). Without "remember me" (Keep me signed in on this device, webapp/i18n/en.json key auth.remember_me), the cookies carry no Max-Age and the refresh chain ends after jwt_session_hours (src/cluster_erp/modules/auth/login/routes.py:215-220). A well-formed Bearer header takes precedence over the access cookie (src/cluster_erp/modules/auth/session_cookies.py:63-75).
Revocation and sign-out¶
POST /api/v1/auth/logoutblacklists the access tokenjti, and for a cookie session also the refresh tokenjti. It clears both cookies and stampslast_logout_at(src/cluster_erp/modules/auth/login/routes.py:373-431).- The blacklist table
auth_token_blacklistholdstoken_jti(unique) andexpires_at(src/cluster_erp/modules/auth/token_blacklist/models.py:11-23). The daily log retention pass deletes expired rows (src/cluster_erp/server/app.py:1168-1192,src/cluster_erp/server/app.py:1281). POST /api/v1/auth/users/me/change-passwordverifies the current password and stampslast_logout_at(src/cluster_erp/modules/auth/users/routes.py:162-174,src/cluster_erp/modules/auth/users/services.py:255-278).
Failed attempts¶
Two mechanisms limit password guessing:
- A failure budget per client address and user name, counted in the table
auth_login_budgetso that every worker process shares it. The limit is the settinglogin_rate_limit_per_minute; above it the route answers HTTP 429 withAUTH_TOO_MANY_ATTEMPTS(src/cluster_erp/modules/auth/login/routes.py:53-120). Successful sign-ins do not consume the budget (src/cluster_erp/modules/auth/login/routes.py:56-58). - An account lockout after a fixed number of consecutive failures, for a fixed period (
_MAX_FAILED_ATTEMPTS,_LOCKOUT_MINUTES), stored infailed_login_countandlocked_until(src/cluster_erp/modules/auth/login/routes.py:48-49,src/cluster_erp/modules/auth/login/routes.py:150-168,src/cluster_erp/modules/auth/users/models.py:67-76).
Permission memory¶
auth_cache keeps each user's permission codes and each permission's active policies in process memory for auth_cache_seconds seconds, keyed by database name (src/cluster_erp/modules/auth/auth_cache.py:41-55, src/cluster_erp/modules/auth/auth_cache.py:97-135, src/cluster_erp/server/app.py:1310). A version stamp from core_cache_versions, moved by database triggers, makes a change in one worker visible in all workers on the next request (src/cluster_erp/modules/auth/auth_cache.py:63-89). An after_flush listener clears the memory when roles, grants, user roles, permissions, or policies change (src/cluster_erp/modules/auth/auth_cache.py:153-184). The user row itself is not cached (src/cluster_erp/modules/auth/auth_cache.py:13-14).
First administrator¶
At start-up, _seed_permissions_from_metadata ensures the admin role and then calls ensure_bootstrap_admin, which creates one administrator only when the database has no user (src/cluster_erp/server/app.py:862-900, src/cluster_erp/modules/auth/users/services.py:412-453). The account takes its name, e-mail, and password from bootstrap_admin_username, bootstrap_admin_email, and bootstrap_admin_password or bootstrap_admin_password_hash (src/cluster_erp/config/settings.py:195-201). When no password is configured, a random one is generated and written to an owner-only file named by bootstrap_password_file; the log carries only the file path (src/cluster_erp/server/app.py:881, src/cluster_erp/server/app.py:914-923, src/cluster_erp/server/app.py:591-609). assign_admin_to_unassigned gives the admin role to role-less users only while no active administrator exists (src/cluster_erp/modules/auth/users/services.py:455-499); tests/test_admin_bootstrap.py pins this.
Why it is built this way¶
- The module docstring of
session_cookies.pygives the reasons for cookie transport: no browser storage protects a token from injected script, so the web client never sees a token, and the custom header adds a second CSRF barrier besideSameSite=Strict(src/cluster_erp/modules/auth/session_cookies.py:1-20). - The rotation comment states that the reuse grace window lets two frames of the same origin refresh at once (
src/cluster_erp/modules/auth/login/routes.py:283-286). The absolute session end prevents rotation from extending a session (src/cluster_erp/modules/auth/jwt_utils.py:41-45). - The failure budget lives in the database because an in-process counter gave each worker its own budget (
src/cluster_erp/modules/auth/login/routes.py:53-59). - The failed-attempt counter is committed before the error response because the session dependency rolls back on any exception (
src/cluster_erp/modules/auth/login/routes.py:162-168).
Rules and boundaries¶
- Other modules use only
modules/auth/guards.pyfrom the auth module; the comment namestests/test_architecture_boundaries.pyas the test that holds the line (src/cluster_erp/modules/auth/guards.py:22-27). - The actor of change history comes from the same access token, read again by
_resolve_actor_idin the session dependency (src/cluster_erp/server/dependencies.py:82-101). See database and sessions. - Token lifetimes, the session length, the reuse grace window, the failure budget, and the cache lifetime are settings:
jwt_access_expire_minutes,jwt_refresh_expire_days,jwt_session_hours,jwt_refresh_reuse_grace_seconds,login_rate_limit_per_minute, andauth_cache_seconds(src/cluster_erp/config/settings.py:307-332). Their types and defaults are in the settings reference.
Interaction with other parts¶
- The
authmodule answersGetUserByIdandGetUserPermissionson the EventBus for other modules (src/cluster_erp/modules/auth/module.py:81-82,src/cluster_erp/modules/auth/module.py:110-144). - In multi-tenant mode, the session dependency resolves the tenant before the login route runs, and the route stamps the tenant key into both tokens (
src/cluster_erp/server/dependencies.py:104-157,src/cluster_erp/modules/auth/login/routes.py:203-213). - The web client side of sign-in is documented in boot, session, and API.
Limits and open points¶
- Tenant users have no second factor in the code at this commit:
verify_totpis called only fromsrc/cluster_erp/server/operators.py:162. See second factor and secret storage. - A password policy beyond length is not determined from code. The schemas set lengths of 8 to 128 characters, and the setting
password_min_length(src/cluster_erp/config/settings.py:327) is not read by any code at this commit (src/cluster_erp/modules/auth/users/schemas.py:12,src/cluster_erp/modules/auth/users/schemas.py:54-60).
Terms introduced¶
| Term | Code identifier | Definition | Turkish term | Source |
|---|---|---|---|---|
| access token | create_access_token |
A short-lived signed token that authenticates one user's requests. | erişim belirteci | src/cluster_erp/modules/auth/jwt_utils.py:10 |
| refresh token | create_refresh_token |
A signed token that is spent once to obtain a new token pair. | yenileme belirteci | src/cluster_erp/modules/auth/jwt_utils.py:33 |
| token blacklist | TokenBlacklist |
The table of revoked token ids that are refused until they expire. | belirteç kara listesi | src/cluster_erp/modules/auth/token_blacklist/models.py:11 |
| cookie transport | wants_cookie_transport |
The web sign-in mode in which the tokens travel only as HttpOnly cookies instead of in the response body. | çerez taşıması | src/cluster_erp/modules/auth/session_cookies.py:42 |
| sign-in failure budget | _check_attempt_budget |
The per-client and per-name limit on failed sign-ins within one minute. | giriş hata bütçesi | src/cluster_erp/modules/auth/login/routes.py:71 |
Related pages¶
- Identity, access, tenancy, privacy, and licensing
- Roles and permissions
- Multi-tenancy
- Auth module
- API authentication and headers
- Settings reference
Verified against commit 92f8051 on 2026-11-13.