Public accounts
bin/rails g trek:public_accounts Customer [--sign-in=code|password] [--invite-only] [--api] [--path=customers] [--model-only]
bin/rails db:migrateAccounts for the people using the site or the mobile app (customers, members, readers…), apart from the admin's User. They have their own model, table, sessions and sign-in, so nothing public can reach a staff account: signing up, deleting inactive accounts or merging duplicates only ever sees public accounts, and a public session never opens the admin.
The name is required, and is the app's own: User is the admin's account, so the generator refuses it, a namespaced name, and the names of Trek's models. Pick the word of the app's domain (Customer, Member, Reader…).
| Option | Effect |
|---|---|
--sign-in=code (default) | Accounts sign in with a code emailed to them, which creates the account the first time: there is no password, nor sign-up form |
--invite-only | With codes: only the accounts the app created sign in, and signing in never creates one. See Invite only |
--sign-in=password | Accounts sign up with an email and a password, confirm the email by a link, and reset a forgotten password by one: there are no codes |
--api | A mobile app: JSON endpoints under /api/v1, and a session held through a JWT, with no pages. Without it, a website: pages, and a session kept in a cookie |
--path=customers | Puts the routes under a path, e.g. /customers/sign-in: for an app with two kinds of public accounts |
--model-only | Neither pages nor endpoints: see Model only. Can't be combined with --api or --path |
Model only
With --model-only, the generator adds the models, their migration, Current.customer_session and Current.customer, the mailer, its job for codes, the locales and the model specs, but no controllers, pages, routes nor JWT. It's for an app whose sign-in is its own, e.g. an API ported from another app that its mobile app already calls: the app keeps its endpoints and tokens, on top of Customer and its rules (passwords, lockout, sessions, inactive accounts).
The app then creates and finds the Customer::Session rows itself, or ignores them, and sets Current.customer_session. With passwords, the emailed links point at the app's domain (https://<host>/account/confirm?token=…, /password-reset?token=…, /account/email?token=…), or at CUSTOMERS_APP_LINKS_URL, as for the API: the app's own pages or mobile app handle them.
Signing in with an emailed code
- The person types their email; the app emails them a 6-digit code. It answers the same whether or not the email has an account (ANSSI guide on passwords, R14).
- They type the code: it signs them in to the email's account, created the first time. Signing up is signing in.
A code is valid for 10 minutes, for 5 tries (the right one included), and once. A new code replaces the earlier one. Only an HMAC of the code is stored, keyed on the app's secret_key_base, and the code is created in the job sending the email, so it's never in the job queue either. Change validity and max_attempts on Customer::SignInCode.
A new email address is confirmed the same way: a code sent to it.
Invite only
With --invite-only, signing in is no longer signing up: only the accounts the app created sign in (from an admin panel, an invitation…), for a private site such as a client portal or an intranet. Asking for a code still answers the same for every email, "If ada@example.com has an account, a code has just been sent to it", but Customers::SignInCodeJob sends a code only to an email that has an account, so the form doesn't tell who has one, neither by its answer nor by its timing. A code then signs in to that account, and never creates one.
Which accounts may sign in is Customer.for_sign_in(email), find_by(email:) by default: narrow it to let only some in, e.g. active.find_by(email:). Both the job and the sign-in use it.
Signing in with a password
- Signing up takes an email and a password, following the same rules as the admin's (
Trek::Users::PasswordPolicy: 12 characters, a digit and a symbol below 20…, configurable), and signs in at once. A link confirming the email is sent, valid a day; following it setsconfirmed_at. The app decides what an unconfirmed account may do (customer.confirmed?), and the account page sends the link again. - Failed sign-ins lock signing in as for the admin (
Trek::Users::SignInLockout), andCustomerMailer#sign_in_lockedtells the account. - A forgotten password is reset by a link, valid 15 minutes and used up by the new password (
has_secure_password'spassword_reset_token), which signs out every session. Asking for one answers the same whether or not the email has an account. - Changing the password needs a recent sign-in (15 minutes,
Customer::Session::RECENT), and signs out the other sessions. - A new email address is kept in
unconfirmed_emailuntil the link sent to it, valid an hour, is followed.
Rate limits
Against guessing and flooding, each IP address may try to sign in 10 times in 3 minutes (5 with a password) and fail 20 times an hour, and ask for 10 emails (codes, links) in 15 minutes; each address gets at most 5 emails an hour. The limits are counted in Rails.cache, which must be shared between servers (see Failed sign-ins).
Sessions
| Website | API (--api) | |
|---|---|---|
| Held by | a signed customer_session_id cookie (HttpOnly, SameSite=Lax) | a JWT the app sends as Authorization: Bearer … |
| Expires | after 30 days without a request, and a year after signing in | the same, checked on the session row: the JWT only names it |
| Signing out | deletes the session and the cookie | deletes the session, and the JWT stops working |
Both are Customer::Session rows (customer_sessions), listed on the account, where the person can sign out the others. Change idle_timeout and lifetime on Customer::Session (Trek::Users::SessionLifetime). The JWT is signed with HS256 and a key derived from secret_key_base, and carries the session's id (sub) and the end of its lifetime (exp): unlike a self-contained token, it can be revoked.
Deleting the account, and changing the password, need a sign-in less than 15 minutes old; otherwise the website sends the person to sign in again, and the API answers 403 with recent_sign_in_needed.
The generator schedules the purge of expired sessions every night in GoodJob's cron, as for the admin's (Trek::PurgeExpiredSessionsJob with args: ["Customer::Session"]). With another Active Job backend, it says so, and the purge is to schedule daily: Trek::PurgeExpiredSessionsJob.perform_later("Customer::Session").
With codes, it also schedules the purge of expired codes, which each hold an email (Trek::PurgeExpiredSignInCodesJob with args: ["Customer::SignInCode"], at 3:45am): a new code deletes the expired ones too, but nothing would delete the last ones requested. An app generated before it adds the entry to its config.good_job.cron by hand:
purge_expired_customer_sign_in_codes: {
cron: "45 3 * * *",
class: "Trek::PurgeExpiredSignInCodesJob",
args: ["Customer::SignInCode"],
description: "Delete the customer sign-in codes past their validity"
},The admin's own session (session_id, User::Session) is untouched: someone may be signed in to both, as two accounts that can share an email address.
What the generator adds
CustomerandCustomer::Session,Customer::SignInCodefor codes, and a migration creating their tablesCurrent.customer_session, andCurrent.customerCustomers::Authentication, and the controllers: underCustomers::with pages for a website, underApi::V1::Customers::for the APICustomerMailerwith its views in English and French, andCustomers::SignInCodeJobfor codesconfig/locales/customers.{en,fr}.yml- the routes, on paths of their own (below), so renaming the model keeps them
- specs, when the app has a
specdirectory
Website
Customers::Authentication is included in ApplicationController, so every page may use:
| Helper | Returns |
|---|---|
current_customer, customer_signed_in? | the signed-in customer, if any |
staff_user | the admin's user signed in in this browser, when privileged, for previews and links to the admin on the site. Read only: it neither extends nor ends their admin session, and never signs a customer in |
A page for customers only calls before_action :require_customer, which sends the others to sign in and brings them back after.
| Path | Page |
|---|---|
GET /sign-in | the sign-in form (customer_sign_in_path): the email for a code, or the email and password |
GET /sign-in/code | the code form (codes) |
GET /sign-up | the sign-up form (passwords) |
GET /password-reset/new | asking for a link to choose a new password (passwords) |
GET /account/confirm?token=… | the link confirming the email (passwords) |
DELETE /sign-out | signs out |
GET /account | the account: name, email, password, signed-in browsers, deletion (customer_account_path) |
GET /account/email/new | changing the email, confirmed by a code or a link sent to the new address |
GET /account/password/edit | changing the password (passwords) |
The pages render in the site's layout, unstyled, with their notices in app/views/customers/_flash.html.slim: move those to the layout if it shows flash messages.
API
The endpoints are the API's first version, under /api/v1, so that a later version can be served next to them while older releases of the mobile app still call these. Every endpoint answers JSON; an error comes as { "error": "invalid_code", "message": "…" }, the message in the request's locale.
| Endpoint | Body | Answer |
|---|---|---|
POST /api/v1/sign-in | codes: email; passwords: email, password | codes: 202, a code is emailed; passwords: 201, { token, customer } |
POST /api/v1/sign-in/code | email, otp | 201, { token, customer } (codes) |
POST /api/v1/sign-up | email, password, name | 201, { token, customer } (passwords) |
POST /api/v1/password-reset | email | 202, a link is emailed if the email has an account (passwords) |
PATCH /api/v1/password-reset | token, password | 204 (passwords) |
POST, PATCH /api/v1/account/confirmation | token for PATCH | sends the confirmation link again; confirms the email (passwords) |
DELETE /api/v1/session | 204, signs out | |
GET, PATCH, DELETE /api/v1/account | name | the account; deleting needs a recent sign-in |
POST /api/v1/account/email | email | 202, a code or a link is emailed to the new address |
PATCH /api/v1/account/email | codes: email, otp; passwords: token | the account, with its new email; 409 when another account has it |
PATCH /api/v1/account/password | password | 204 (passwords); needs a recent sign-in |
GET /api/v1/account/sessions | the signed-in devices, current for this one | |
DELETE /api/v1/account/sessions/:id, /others | 204 |
With passwords, the emailed links point at the app's domain (https://<host>/account/confirm?token=…, /password-reset?token=…, /account/email?token=…), or at CUSTOMERS_APP_LINKS_URL: the mobile app opens them (iOS universal links, Android app links) and sends their token to the endpoints above.
Audit log
When the app has the audit log, the account controllers write to it too: sign-ins, failed or not, sign-ups, code and link requests (with no actor), and account changes, the customer being the actor.
Inactive accounts
Customer.inactive_since(time) lists the accounts with no request since time (last_activity_at, written at most every 5 minutes), e.g. for a job deleting them. It can only ever list customers.
Upgrading Trek
The generated files are the app's own: upgrading Trek never changes them. On each upgrade, check:
The CHANGELOG, for entries on what the generated code relies on in the gem:
Trek::Users::SessionLifetime,Trek::Users::SignInCode(codes),Trek::Users::SignInLockoutandTrek::Users::PasswordPolicy(passwords),Trek::PurgeExpiredSessionsJob,Trek::ResolveSessionHostnameJobandTrek::Auditable.The generator's own changes, a security fix among them, which reach the app only by running it again. On a clean working tree, run it with the same name and options, then review
git diffand restore the app's own changes:shbin/rails g trek:public_accounts Customer --force # plus the options it was generated with git diffRunning it again adds nothing already there: no second migration, route or
Currentattribute.The admin's session, which
staff_userreads: itssession_idcookie andUser::Session, inCustomers::Authentication#staff_user. An upgrade note says when they change.