Skip to content

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. 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…).

OptionEffect
--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-onlyWith codes: only the accounts the app created sign in, and signing in never creates one. See Invite only
--sign-in=passwordAccounts 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
--apiA 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=customersPuts the routes under a path, e.g. /customers/sign-in: for an app with two kinds of public accounts
--model-onlyNeither 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 ​

  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).

Sessions ​

WebsiteAPI (--api)
Held bya signed customer_session_id cookie (HttpOnly, SameSite=Lax)a JWT the app sends as Authorization: Bearer …
Expiresafter 30 days without a request, and a year after signing inthe same, checked on the session row: the JWT only names it
Signing outdeletes the session and the cookiedeletes 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:

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:

HelperReturns
current_customer, customer_signed_in?the signed-in customer, if any
staff_userthe 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.

PathPage
GET /sign-inthe sign-in form (customer_sign_in_path): the email for a code, or the email and password
GET /sign-in/codethe code form (codes)
GET /sign-upthe sign-up form (passwords)
GET /password-reset/newasking for a link to choose a new password (passwords)
GET /account/confirm?token=…the link confirming the email (passwords)
DELETE /sign-outsigns out
GET /accountthe account: name, email, password, signed-in browsers, deletion (customer_account_path)
GET /account/email/newchanging the email, confirmed by a code or a link sent to the new address
GET /account/password/editchanging 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.

EndpointBodyAnswer
POST /api/v1/sign-incodes: email; passwords: email, passwordcodes: 202, a code is emailed; passwords: 201, { token, customer }
POST /api/v1/sign-in/codeemail, otp201, { token, customer } (codes)
POST /api/v1/sign-upemail, password, name201, { token, customer } (passwords)
POST /api/v1/password-resetemail202, a link is emailed if the email has an account (passwords)
PATCH /api/v1/password-resettoken, password204 (passwords)
POST, PATCH /api/v1/account/confirmationtoken for PATCHsends the confirmation link again; confirms the email (passwords)
DELETE /api/v1/session204, signs out
GET, PATCH, DELETE /api/v1/accountnamethe account; deleting needs a recent sign-in
POST /api/v1/account/emailemail202, a code or a link is emailed to the new address
PATCH /api/v1/account/emailcodes: email, otp; passwords: tokenthe account, with its new email; 409 when another account has it
PATCH /api/v1/account/passwordpassword204 (passwords); needs a recent sign-in
GET /api/v1/account/sessionsthe signed-in devices, current for this one
DELETE /api/v1/account/sessions/:id, /others204

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::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.

Released under the MIT License.