> For the complete documentation index, see [llms.txt](https://docs.layeronecloud.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.layeronecloud.com/platform/identity/users-and-sign-in.md).

# Users and sign-in

accounts.User is AbstractBaseUser + PermissionsMixin with USERNAME\_FIELD = 'email' and no username column.

## 1.1 The user model <a href="#id-11-the-user-model" id="id-11-the-user-model"></a>

`accounts.User` is `AbstractBaseUser` + `PermissionsMixin` with `USERNAME_FIELD = "email"` and no username column. **A user is keyed by email alone.**

* Addresses are casefolded on every write. `UserManager.create_user` normalises and `User.save()` normalises again, because sign-in casefolds its input while `get_by_natural_key` matches exactly: a row stored as `Mixed.Case@Example.COM` could never be signed in to, and password reset could not repair it either. The Django-admin add form bypasses `create_user`, so `apps/accounts/admin.py` normalises on the form too, keeping a case-variant duplicate a field error rather than a unique-constraint 500.
* Authorization is `access_level` (`Admin` / `Client`), **not** `is_staff`.
* The Paymenter-era `paymenter_user_id` column is gone from `User`, `BillingAccount`, `Ticket`, `TicketMessage` and `AuditEvent`. Nothing read it, it was unique with no default so every creation path had to remember to set it, and on `Ticket` it backed a second ownership branch that matched every blank-id row for a blank-id user. Do not reintroduce an external identity column without a system that actually consumes it.

**A passwordless `User` row is a placeholder, not an account.** The public contact form creates one for whatever address it is given (`tickets.get_or_create_guest_ticket_user`). See [1.3](#id-13-registration-and-placeholder-claiming).

## 1.2 Sign-in <a href="#id-12-sign-in" id="id-12-sign-in"></a>

The public sign-in page is email-first. Continue either redirects to the organization's OpenID provider for that email domain ([1.14](/platform/identity/audit-and-oidc.md#id-114-openid-connect)) or reveals the password field. A password manager that posts email and password together still signs in in one step.

`accounts.services.sign_in_with_password` is the password credential path. Django admin's login form is deliberately unmounted (`config/urls.py` redirects it here) so there is no second, unthrottled front door onto the same passwords.

Order of operations: casefold the address and resolve the client IP → refuse if the **per-IP** failure counter is at its limit (40 per 15 min) → `authenticate()` → reject on no user, wrong credentials, disabled account, or a `DEBUG_ONLY_ACCOUNT_EMAILS` name while `DEBUG` is off → on rejection increment both counters and record an audit event → on success clear the per-email counter only, log in, set session expiry, record a `UserSession`, audit `session.signed_in`.

Invariants:

* **Every failure returns the same generic "email or password" message**, including a disabled account, so nothing enumerates accounts. The throttle message is keyed on the submitted string rather than on whether an account exists, so it does not enumerate either.
* **The per-email counter does not gate the credential check.** It used to, and since it was only cleared by a successful sign-in — which the counter itself was blocking — eight wrong guesses locked a known account out for fifteen minutes, renewable forever from any IP. It now only decides which message a *failing* attempt gets.
* **Success clears only the per-email key.** The per-IP key is shared by every address tried from that host; clearing it let an attacker with one valid account spend guesses on other addresses and then refill the budget.
* Sign-in also carries a Turnstile check when a `TurnstileConfig` is active (`templates/includes/anti_bot_fields.html`). Turnstile uses Cloudflare's `interaction-only` appearance so a silent pass does not reserve a blank band above Continue. There is no honeypot: a hidden `website_url` field was filled by password managers after a reset and silently discarded a correct password with no error and no audit row.

**Client IP resolution.** `utils.request_ip_address` reads `X-Forwarded-For` **from the right, never the left**: everything left of the trusted hops is client-supplied and forgeable. The number of appending proxies is explicit in `TRUSTED_PROXY_HOPS` (default 1, the Railway edge). Putting a CDN in front makes it 2; leaving it at 1 collapses every visitor into one rate-limit bucket and writes the CDN's address into `UserSession`, `UserActivity` and `AuditEvent`.

## 1.3 Registration and placeholder claiming <a href="#id-13-registration-and-placeholder-claiming" id="id-13-registration-and-placeholder-claiming"></a>

`register_client_account_with_password` creates a `Client` user and its `BillingAccount` in one transaction, ensures an organization ([1.10](/platform/identity/organizations.md#id-110-organizations-and-tenants)), then signs the person in. The address is unconfirmed until they click the emailed link (or later sign in through OpenID with `email_verified=true`). That is a notification-bell item, not a permission gate: every signed-in page stays usable. The operator can also send a one-off **Email confirmation** campaign (lifecycle, all unconfirmed client accounts) from Emails.

Throttles: per-email and per-IP failure counters (8 and 40 per 15 min) plus a per-IP **success** cap (5 per hour). Anti-bot verification fails open when no `TurnstileConfig` is active and the failure counters never see a successful signup, so without the success cap account creation was unthrottled.

Every refusal returns `GENERIC_REGISTRATION_FAILURE_MESSAGE`, which is also what an existing real account gets, so registration does not reveal which addresses are taken.

**Placeholder claiming.** `_is_claimable_placeholder_account` decides whether an existing row may become the caller's account. It requires an active, non-staff, non-superuser `Client` row with **no usable password** — exactly the property that makes a row unreachable by its owner. Refusing instead would let anyone lock a prospect out of signing up permanently, since password reset also skips rows with no usable password.

It additionally refuses any row whose tickets still hold a live `TicketSecret`: nothing in the claim proves the caller controls the address, and a claimed row inherits the placeholder's ticket history, where the client ticket page renders active secrets in plaintext. Such a row stays unclaimable until the secrets age out (14 days). A claim records `account.placeholder_claimed`.

## 1.4 Password reset and break-glass recovery <a href="#id-14-password-reset-and-break-glass-recovery" id="id-14-password-reset-and-break-glass-recovery"></a>

`request_password_reset` behaves identically whether or not the address exists, is throttled per email (5 min) and per IP (30 s), and skips rows with no usable password. `complete_password_reset` sets the password and revokes every active `UserSession` for that user. Django's session cookie stays valid until the next request; `UserSessionMiddleware` signs that request out. If the request is already a POST to sign-in, it logs them out and lets the view consume the new password instead of redirecting and dropping the body.

`/emergency-access/<token>/` is inert (404) unless `EMERGENCY_RESET_TOKEN` is set in the environment, is locked to one email, and self-disables after one use via a synchronously written audit row plus a cache flag. The token is compared with `hmac.compare_digest` **on bytes**: as a `str` it raised `TypeError` on any non-ASCII input, answering 404 while unarmed and 500 while armed — which told an attacker whether recovery was available. The path is redacted out of the audit trail through `apps/core/credential_paths.py` and the response carries `Referrer-Policy: no-referrer`.

## 1.5 Sessions and activity <a href="#id-15-sessions-and-activity" id="id-15-sessions-and-activity"></a>

* `UserSession` records one row per login: hashed session key, persistent flag, IP, user agent, last seen, expiry, revocation.
* "Stay logged in" sets a \~30-day persistent expiry (`session_policy.PERSISTENT_SESSION_DELTA`); otherwise the session is browser-scoped.
* `UserSessionMiddleware` signs out a request whose recorded session has been revoked or expired, and rolls a persistent session forward on activity.
* `/console/account/sessions/` lists a user's own sessions and revokes them.
* `UserActivity` (`apps/accounts/activity.py`) is the separate request trail. **Query strings are never stored** and credential-bearing paths (VNC console, reset links, one-time credential reveals) are excluded — keep it that way when adding routes. Rows are pruned by `accounts.purge_old_user_activity`. Stats polling and silent provisioning refreshes are excluded so a repeat visit ordering (A → B → A) stays truthful.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.layeronecloud.com/platform/identity/users-and-sign-in.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
