---
url: /reference/generators/public-accounts.md
---
# Public accounts

```sh
bin/rails g trek:public_accounts Customer [--sign-in=code|password] [--invite-only] [--api] [--path=customers] [--model-only]
bin/rails db:migrate
```

Accounts for the people using the site or the mobile app (customers, members, readers…), apart from the admin's [`User`](/reference/models/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](#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](#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

1. 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).
2. 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 sets `confirmed_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`), and `CustomerMailer#sign_in_locked` tells the account.
* **A forgotten password** is reset by a link, valid 15 minutes and used up by the new password (`has_secure_password`'s `password_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_email` until 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](/reference/models/user#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](/reference/models/user#sessions)). 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:

```ruby
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

* `Customer` and `Customer::Session`, `Customer::SignInCode` for codes, and a migration creating their tables
* `Current.customer_session`, and `Current.customer`
* `Customers::Authentication`, and the controllers: under `Customers::` with pages for a website, under `Api::V1::Customers::` for the API
* `CustomerMailer` with its views in English and French, and `Customers::SignInCodeJob` for codes
* `config/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 `spec` directory

### 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](/reference/generators/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::SignInLockout` and `Trek::Users::PasswordPolicy` (passwords), `Trek::PurgeExpiredSessionsJob`, `Trek::ResolveSessionHostnameJob` and `Trek::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 diff` and restore the app's own changes:

  ```sh
  bin/rails g trek:public_accounts Customer --force # plus the options it was generated with
  git diff
  ```

  Running it again adds nothing already there: no second migration, route or `Current` attribute.
* **The admin's session,** which `staff_user` reads: its `session_id` cookie and `User::Session`, in `Customers::Authentication#staff_user`. An upgrade note says when they change.
