---
url: /reference/models/user.md
---
# User

The host application's `User` model, into which Trek injects authentication (`has_secure_password` and database-backed sessions), roles, theme & locale preferences and the invitation workflow.

It is the admin's account. The site's or the mobile app's users (customers, members…) get a model of their own, apart from it: see [Public accounts](/reference/generators/public-accounts).

## Columns added by Trek

| Column | Type | Notes |
| --- | --- | --- |
| `name` | string | display name |
| `role` | integer | enum, default `guest` |
| `theme` | integer | enum, default `auto` |
| `locale` | string | admin UI language |
| `password_digest` | string | `has_secure_password` (bcrypt), required |
| `last_activity_at` | datetime | updated while signed in, at most every 5 minutes (see [Sessions](#sessions)) |
| `password_changed_at` | datetime | when the password was last set, for its expiry |
| `password_renewal_required_at` | datetime | when an admin asked everyone for a new password (`password_renewal_required?`) |
| `failed_sign_in_count` | integer | failed sign-ins in a row, for the [lockout](#failed-sign-ins) |
| `sign_in_locked_until` | datetime | until when signing in is locked (`sign_in_locked?`) |
| `webauthn_id` | string | the random id the user's [passkeys](#passkeys) were created for |

When the app has no `User` model yet, Trek creates the `users` table with its `email` column. Signed-in browsers are `User::Session` records (`user.sessions`; `user_agent`, `ip_address`) in a `user_sessions` table, found through a signed `session_id` cookie. Password reset (15 minutes) and invitation (1 week) links carry tokens from `generates_token_for`, so no column stores them.

## Sign-in and password resets

Neither tells whether an email has an account, as the [ANSSI recommends](https://messervices.cyber.gouv.fr/guides/recommandations-relatives-lauthentification-multifacteur-et-aux-mots-de-passe) (R14):

* **Sign-in:** an unknown email, a wrong password, a user who isn't privileged and a [locked](#failed-sign-ins) one get the same message and status. `authenticate_by` hashes the password typed even when no user matches, so the response takes as long.
* **Password reset request:** every email gets the same answer (« a link to choose a new password has been sent to you (unless no account matches this email) »). The request always queues `Admin::PasswordResetJob`, which looks the user up and emails privileged ones only, so the response takes as long either way.

## Failed sign-ins

Password guessing is slowed down at three levels, as the [ANSSI recommends](https://messervices.cyber.gouv.fr/guides/recommandations-relatives-lauthentification-multifacteur-et-aux-mots-de-passe) (R10):

| Limit | Default | Counted in |
| --- | --- | --- |
| Sign-ins from an IP address (`rate_limit` in `Admin::UserSessionsController`) | 5 within 3 minutes | `Rails.cache` |
| Failed sign-ins from an IP address, whatever the account (`FAILED_SIGN_INS_PER_IP`, `FAILED_SIGN_INS_WINDOW`) | 20 within an hour | `Rails.cache` |
| Failed sign-ins in a row on an account, whatever the IP address (`Trek::Users::SignInLockout`) | 5, then locked | the `users` table |

Past an IP address's limits, the sign-in form answers « Too many attempts » (429) to any sign-in from it.

An account's lock lasts `sign_in_lockout_duration` (1 minute), doubled on each further failure up to `sign_in_lockout_max` (1 hour); never for good, so knowing someone's email isn't enough to keep them out. While locked, signing in fails even with the right password, with the same answer as a wrong one, and the attempts don't count. A successful sign-in or a new password (e.g. through a reset) unlocks it. When it first locks, the user gets an email (`AdminMailer#sign_in_locked`) offering a password reset, and warning that someone may be guessing their password.

Set them in the model, e.g. `self.sign_in_lockout_after = 10`, or `self.sign_in_lockout_email = false` to send no email.

Each sign-in, failed or throttled, is a row of the [audit log](/reference/generators/audit-log) when it is installed, on the account tried.

::: warning Rails.cache
The IP address limits count in `Rails.cache`. With a cache per process or per server (`:memory_store`, or `:file_store`, Rails' fallback when no store is set), each one counts on its own, so the limits multiply by the number of processes and servers. Use a shared cache store, such as Solid Cache (the default of Rails 8 apps) or Redis, when the app runs more than one. Under `:null_store`, set in a new app's `test.rb`, they never kick in.
:::

## Sessions

Each signed-in browser is a `User::Session`, which `Trek::Users::SessionLifetime` ends, as the [ANSSI recommends](https://messervices.cyber.gouv.fr/guides/recommandations-relatives-lauthentification-multifacteur-et-aux-mots-de-passe) (R12):

| Setting | Default | |
| --- | --- | --- |
| `idle_timeout` | 48 hours | without a request |
| `lifetime` | 30 days | since signing in, whatever the activity; also the cookie's expiry |
| `activity_interval` | 5 minutes | the least time between two writes of the session's `last_activity_at` and the user's `last_activity_at`, so browsing doesn't write on every page |

Set them in `app/models/user/session.rb`, e.g. `self.idle_timeout = 2.hours`. An expired session is dropped on its next request, and the user signs in again. `Trek::PurgeExpiredSessionsJob` deletes the expired ones: the installer schedules it every night in GoodJob's cron (see [Scheduled jobs](/background-jobs#scheduled-jobs-cron)); with another Active Job backend, schedule it daily.

Users see their **active sessions** in a tab of their own profile: browser and system, IP address, last activity and sign-in date. They can sign out any other session, or all of them at once (**Sign out everywhere else**). A password reset signs the user out everywhere.

## Sign-ins

`Trek::Users::SignIns` helps users spot a sign-in that wasn't theirs:

* the **Active sessions** tab of their profile shows their **previous sign-in**: its date, browser and IP address. It's kept on the user (`last_sign_in_at`, `last_sign_in_ip`, `last_sign_in_user_agent`, with their `current_sign_in_*` counterparts), as sessions are deleted when they end;
* a sign-in from a **browser that never signed in as the user** emails them (`AdminMailer#new_sign_in`): date, browser, IP address and its host name (as on the active sessions), with links to change their password and to their active sessions. A browser is recognized by an encrypted cookie listing the last 20 users who signed in from it, kept 13 months after the last of those sign-ins: a browser that didn't sign in for longer counts as new again. Their very first sign-in sends nothing.

Set `self.new_sign_in_email = false` in the `User` model to turn the email off.

## Passkeys

`Trek::Users::Passkeys` lets users sign in with a passkey instead of their password: their fingerprint, face or device PIN unlocks a key their device or password manager keeps, and Trek keeps its public half. A passkey can't be phished or reused on another site, and the device verifies the user, so it counts as two factors on its own.

* **Adding:** the **Passkeys** tab of their profile adds one (**Add a passkey**): their device creates it, then Trek saves it. The list names each passkey after the password manager keeping it (iCloud Keychain, Google Password Manager, 1Password, a YubiKey…), or else after the browser it was added from, with when it was added and last used; they can delete it there.
* **Signing in:** the sign-in form offers **Sign in with a passkey**, and the browser also suggests the site's passkeys in the email field's autofill. No email to type: the passkey says whose it is. It works even while [failed passwords](#failed-sign-ins) lock signing in, and lifts that lock, as a passkey can't be guessed.
* **Password:** it keeps working. Asking for a passkey after the password, as a second factor, will come with TOTP.

Passkeys are `User::Passkey` records (`user.passkeys`, `user_passkeys` table): the credential's id and public key, its signature counter, transports, AAGUID (the id of the authenticator's model, which names its password manager through `Trek::PasskeyProviders`), and `last_used_at`. The user's `webauthn_id`, random, is the id their passkeys were created for. Trek verifies them with the [webauthn](https://github.com/cedarcode/webauthn-ruby) gem, without checking the authenticator's make (no attestation).

A passkey belongs to a domain: Trek uses the request's (`request.host`, with `request.base_url` as the allowed origin), so passkeys work in each environment, with their own. A passkey added on `www.example.com` doesn't work on `example.com`, and behind a proxy, Rails must see the original scheme and host (see [Proxies](/proxies#passkeys)). To share passkeys between subdomains, override `passkey_relying_party` in `Admin::PasskeysController` and `Admin::PasskeySessionsController`, with `id: "example.com"` and every allowed origin.

The password managers' names come from the community list [passkey-authenticator-aaguids](https://github.com/passkeydeveloper/passkey-authenticator-aaguids), names only, embedded in Trek and refreshed with `bin/update-passkey-providers` before a release. An authenticator may send no AAGUID, or one the list doesn't know: the passkey then shows no password manager.

Browsers without WebAuthn's JSON methods (Safari before 18.4) don't show the passkey buttons.

## Roles

`Trek::Users::Roles` defines the role enum and access levels:

```ruby
enum :role, { admin: 5, editor: 4, user: 3, reader: 2, guest: 1 }, default: "guest"
```

* `privileged?` — `true` for `admin` and `editor`; this is what the default [policies](/reference/policies) check
* `User.human_roles` — translated role names for form selects

## Themes

`Trek::Users::Themes` lets each user pick the admin theme:

```ruby
enum :theme, { auto: 0, light: 1, dark: 2 }, default: "auto"
```

`User.human_themes` returns translated theme names.

## Locales

`Trek::Users::Locales` provides `User.human_locales` — the available admin UI languages with translated labels, sourced from `I18n.available_locales`.

## Invitations

`Trek::Users::Invitable` adds a transient `send_invite` flag: when set on creation, the user receives an invitation email with a password-reset link.

```ruby
User.create(email: "new@example.com", role: :editor, send_invite: true)
```

## Validations

`Trek::Users::Validations` enforces:

* `email` — stored stripped and in lower case (lookups too, so sign-in and password resets ignore case), present, unique whatever its case, and well-formed by [`email_validator`](https://github.com/K-and-R/email_validator)'s strict mode (no consecutive or leading/trailing dots, a domain with a TLD)
* `password` — when one is assigned: follows the [password policy](#password-policy), and is not spaces only. A blank password is ignored by `has_secure_password` (the current one is kept); the password reset form refuses it. `Trek::Users::PasswordHistory` also refuses one of the user's last 5 passwords (see [Previous passwords](#previous-passwords))

### Disposable email addresses

Admins create the accounts, so `User` accepts any well-formed address. When users can sign up themselves, refuse throwaway addresses with [`NotDisposableValidator`](/reference/validators#disposable-email-addresses), which any other model can use too:

```ruby
validates :email, not_disposable: true
```

## Password policy

`Trek::Users::PasswordPolicy`, which `Trek::Users::Validations` includes, follows the [ANSSI's recommendations on passwords](https://messervices.cyber.gouv.fr/guides/recommandations-relatives-lauthentification-multifacteur-et-aux-mots-de-passe) (v2.0, 2021, co-signed by the CNIL). An admin is privileged access, so Trek targets their **"medium to high"** sensitivity level (about 85 bits of entropy):

| Rule | ANSSI | Default | Setting |
| --- | --- | --- | --- |
| Minimum length | R21 | 12 characters | `password_min_length` |
| A digit and a symbol, except for a passphrase | R23 | below 20 characters | `passphrase_length` |
| Not a common password, whatever its case, accents and punctuation | R27 | those longer than 8 characters among the 10,000 most common passwords and the 5,000 most common French ones ([SecLists](https://github.com/danielmiessler/SecLists)) | — |
| No sequence: 5 characters along a keyboard row (AZERTY, QWERTY), the alphabet or the digits, or the same character 5 times | R27 | — | — |
| Not the user's name or email | R27 | parts of 4 characters or more | — |
| Not in a known data breach ([Have I Been Pwned](https://haveibeenpwned.com/API/v3#PwnedPasswords)) | R27 | off | `password_check_pwned` |
| Maximum length | R22 | 72 bytes | — |

Set them in the model, e.g. `self.password_min_length = 15` for the "high" level. The maximum is bcrypt's, which ignores anything beyond: the ANSSI asks for a few hundred characters, which hashing with Argon2 will allow. The Have I Been Pwned check only sends the first 5 characters of the password's SHA-1, but stays off by default, as the ANSSI warns against sending password data to an outside service; when the service can't be reached, the password is accepted.

The new password form states the rules and rates the password as it is typed (`Trek::Form::PasswordStrengthComponent`); the model has the last word.

### Expiry and renewal

`Trek::Users::PasswordRenewal` sends a user to the new password form, signed out, when:

* their password has expired: never by default, as the ANSSI advises for ordinary accounts (R24). For admin accounts signing in with a password only, it suggests 1 to 3 years (R25): `self.password_expires_after = 1.year`;
* an admin asked everyone for a new password, from the users list (**Ask everyone for a new password**), after a suspected compromise (R26). In code: `User.require_password_renewal!`.

## Previous passwords

`Trek::Users::PasswordHistory` refuses a password among the user's last 5, the current one included, as the [ANSSI recommends](https://messervices.cyber.gouv.fr/guides/recommandations-relatives-lauthentification-multifacteur-et-aux-mots-de-passe) (R27). The previous digests are kept as `User::PreviousPassword` records (`user_previous_passwords` table): only as many as needed, deleted with the user.

Set the number in the model, after `include Trek::Users::PasswordHistory`; 0 turns the check off:

```ruby
class User < ApplicationRecord
  has_secure_password
  include Trek::Users::PasswordHistory
  self.password_history_size = 10
end
```

## Scopes

`Trek::Users::Scopes` adds `User.ordered` — alphabetical by name, then email.

## Form models

**`Trek::UserSession`**, a non-persisted `ActiveModel::Model`, holds the `email` + `password` typed in a sign-in form. Deprecated, removed in Trek 3: the generated sign-in controller passes the params to `User.authenticate_by` instead.
