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.
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) |
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 |
sign_in_locked_until | datetime | until when signing in is locked (sign_in_locked?) |
webauthn_id | string | the random id the user's 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 (R14):
- Sign-in: an unknown email, a wrong password, a user who isn't privileged and a locked one get the same message and status.
authenticate_byhashes 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 (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 when it is installed, on the account tried.
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 (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); 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 theircurrent_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 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 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). 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, 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:
enum :role, { admin: 5, editor: 4, user: 3, reader: 2, guest: 1 }, default: "guest"privileged?—trueforadminandeditor; this is what the default policies checkUser.human_roles— translated role names for form selects
Themes
Trek::Users::Themes lets each user pick the admin theme:
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.
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 byemail_validator's strict mode (no consecutive or leading/trailing dots, a domain with a TLD)password— when one is assigned: follows the password policy, and is not spaces only. A blank password is ignored byhas_secure_password(the current one is kept); the password reset form refuses it.Trek::Users::PasswordHistoryalso refuses one of the user's last 5 passwords (see 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, which any other model can use too:
validates :email, not_disposable: truePassword policy
Trek::Users::PasswordPolicy, which Trek::Users::Validations includes, follows the ANSSI's recommendations on passwords (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) | — |
| 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) | 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 (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:
class User < ApplicationRecord
has_secure_password
include Trek::Users::PasswordHistory
self.password_history_size = 10
endScopes
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.