---
title: Authentication and Authorisation
description: Signing users up, in and out with the authentication generator, protecting routes, checking permissions, throttling sign-in attempts, and working with an existing Rails app's passwords and tokens.
category: Going Further
order: 2
---
Glacier doesn't hide authentication inside the framework. A generator
writes sign up, sign in and sign out into your app as ordinary code, with
its tests, and the framework supplies only the parts that are easy to get
wrong: password hashing and session tokens.
After reading this guide, you will know:
- What `glacier generate authentication` writes, and how it works.
- How passwords and session tokens are stored.
- How to let only signed-in users reach a route.
- How to check what a signed-in user may do, with policies, and how to
catch actions that don't check.
- How to limit sign-in attempts.
- How to authenticate an API with bearer tokens.
- How an existing Rails app's bcrypt passwords and JSON Web Tokens carry
over.
The examples come from two apps. `accounts` is an HTML app with notes
that belong to the user who wrote them; `api` is a JSON API, described in
[Building JSON APIs](json_apis.html).
## Generating Authentication
In a new app, run the generator:
```sh
$ glacier generate authentication
create db/migrations/m20261008190759_create_users.glacier
create db/migrations/m20261008190800_create_sessions.glacier
create db/migrations/m20261008190801_create_recovery_codes.glacier
create src/models/user.glacier
create src/models/session.glacier
create src/models/recovery_code.glacier
create src/jobs/prune_sessions.glacier
create db/migrations/m20261008190802_create_glacier_jobs.glacier
create src/auth.glacier
create src/access/user_access.glacier
create src/middleware/authentication.glacier
create src/middleware/require_authentication.glacier
create src/controllers/sessions_controller.glacier
create src/controllers/registrations_controller.glacier
create src/views/sessions/new.html
create src/views/registrations/new.html
update config/default.toml
create src/controllers/passwords_controller.glacier
create src/mailers/passwords_mailer.glacier
create src/views/passwords/new.html
create src/views/passwords/edit.html
create src/views/passwords_mailer/reset.html
create src/views/passwords_mailer/reset.txt
create tests/controllers/passwords_controller.test.glacier
create src/controllers/email_verifications_controller.glacier
create src/mailers/email_verifications_mailer.glacier
create src/views/email_verifications/new.html
create src/views/email_verifications_mailer/verify.html
create src/views/email_verifications_mailer/verify.txt
create src/middleware/require_verified.glacier
create src/controllers/password_confirmations_controller.glacier
create src/views/password_confirmations/new.html
create src/middleware/require_recent_password.glacier
create tests/controllers/password_confirmations_controller.test.glacier
create src/controllers/sign_in_links_controller.glacier
create src/mailers/sign_in_links_mailer.glacier
create src/views/sign_in_links/show.html
create src/views/sign_in_links_mailer/sign_in.html
create src/views/sign_in_links_mailer/sign_in.txt
create tests/controllers/sign_in_links_controller.test.glacier
create tests/controllers/email_verifications_controller.test.glacier
create src/controllers/email_changes_controller.glacier
create src/mailers/email_changes_mailer.glacier
create src/views/email_changes/new.html
create src/views/email_changes_mailer/confirm.txt
create src/views/email_changes_mailer/confirm.html
create src/views/email_changes_mailer/changed.txt
create src/views/email_changes_mailer/changed.html
create tests/controllers/email_changes_controller.test.glacier
create src/controllers/two_factors_controller.glacier
create src/controllers/two_factor_challenges_controller.glacier
create src/views/two_factors/new.html
create src/views/two_factors/recovery_codes.html
create src/views/two_factor_challenges/new.html
create tests/controllers/two_factors_controller.test.glacier
create tests/factories/user.glacier
create tests/models/user.test.glacier
create tests/controllers/sessions_controller.test.glacier
create tests/controllers/registrations_controller.test.glacier
update src/app.glacier
update src/routes.glacier
Next: glacier db migrate. Routes for signed-in users go in a scope
through the `authenticated` pipeline:
scope "/", through: [:browser, :authenticated] do
get "/account", AccountsController.show, as: :account
end
and `Auth.current_user(&ctx)` (with `alias Auth`) is who's signed in.
Link to /email_change/new and /two_factor/new from a page of the
user's account to let them change their email address and turn on
two-factor sign-in.
```
That's:
- `User` and `Session` models, and migrations for their tables.
- `src/auth.glacier`, the `Auth` module: who's signed in, and signing in
and out.
- Two middleware in `src/middleware/`: `Authentication`, which finds who is
signed in on each request, and `RequireAuthentication`, which keeps
signed-out visitors away from a route.
- A sessions controller (sign in and out) and a registrations controller
(sign up), with their pages.
- Password resets, sign-in links, email confirmation and confirmed email
changes by mailed links;
a `sudo` pipeline that asks for the password again; and two-factor
sign-in with an authenticator app, with recovery codes (a
`RecoveryCode` model).
- Throttles on sign-in attempts, mailed links and two-factor codes, in
`src/app.glacier`.
- Tests for all of it, and a `User` factory, which gives each user it
makes an email address of their own (see
[Testing](testing.html#sequences)).
It also adds the middleware to the app's settings in `src/app.glacier`,
and the routes. The
generator won't run in an app that already has a `User` or `Session`
model, `src/auth.glacier`, or either controller.
After `glacier db migrate`, `/registration/new` signs up and
`/session/new` signs in. The rest of this chapter walks through what was
written.
### Users
```glacier
module User do
implements Model
field email_address: String, required, unique
field password_digest: String, protected
# When the user confirmed their email address by the mailed link.
field email_verified_at: Option<Timestamp>, protected
# Set from a form or JSON, and stored only as password_digest.
field password: Option<String>, virtual
# Two-factor sign-in: the authenticator app's secret, when it was turned
# on, and the step of the last code used.
field otp_secret: Option<String>, protected, encrypted
field otp_enabled_at: Option<Timestamp>, protected
field otp_last_step: Option<i64>, protected
# A new email address, until a link mailed to it is followed.
field unconfirmed_email: Option<String>, protected
# Staff can sign in as another user (`ImpersonationsController`).
field staff: bool, protected
has_many :sessions
has_many :recovery_codes
# The user with this email address and password, if any. It takes as
# long either way, so it doesn't reveal who has an account.
def authenticate_by(email_address: &str, password: &str) -> Option<User> do
user = User.find_by(email_address: User.normalize_email(email_address))!
digest = user.as_ref().map(fn u -> u.password_digest.as_str() end)
if glacier::auth::verify_password(password, digest) do
# An older hash (bcrypt, from a Rails app) becomes Argon2 now the
# password is known. Not a new password, so sessions stay.
if let Some(found) = user.as_mut() do
if glacier::auth::needs_rehash(&found.password_digest) do
found.password_digest = glacier::auth::hash_password(password)
found.save()!
end
end
user
else
None
end
end
# A token for a password reset link: good for 15 minutes, and only
# until the password changes, which makes it single-use.
def reset_token(self) -> String do
glacier::auth::token_for("password_reset", self.id, &self.password_digest, 15.minutes())
end
# The user a reset link's token is for, while it's still good.
def find_by_reset_token(token: &str) -> Option<User> do
case glacier::auth::read_token("password_reset", token) do
Some(signed) -> User.find_by(id: signed.id)!.filter(fn u -> signed.matches(&u.password_digest) end)
nil -> None
end
end
# A token for the link confirming the email address: good for 3 days,
# and only for the address it was sent to.
def verification_token(self) -> String do
glacier::auth::token_for("email_verification", self.id, &self.email_address, 3.days())
end
# The user a confirmation link's token is for, while it's still good.
def find_by_verification_token(token: &str) -> Option<User> do
case glacier::auth::read_token("email_verification", token) do
Some(signed) -> User.find_by(id: signed.id)!.filter(fn u -> signed.matches(&u.email_address) end)
nil -> None
end
end
# A token for the link confirming a new address: good for a day, and
# only for the address it was sent to.
def email_change_token(self) -> String do
pending = self.unconfirmed_email.clone().unwrap_or_default()
glacier::auth::token_for("email_change", self.id, &pending, 1.days())
end
def find_by_email_change_token(token: &str) -> Option<User> do
case glacier::auth::read_token("email_change", token) do
Some(signed) -> User.find_by(id: signed.id)!.filter(fn u -> u.unconfirmed_email.as_ref().is_some_and(fn pending -> signed.matches(pending) end) end)
nil -> None
end
end
# Makes the new address the user's, confirmed, unless someone has taken
# it since.
def confirm_email_change(self) -> bool do
pending = self.unconfirmed_email.take().unwrap_or_default()
taken = User.find_by(email_address: &pending)!.is_some()
if !taken do
self.email_address = pending
self.email_verified_at = Some(Timestamp.now())
end
self.save()!
!taken
end
# A token for a sign-in link: good for 15 minutes and one sign-in, as
# signing in (any way) makes it stale.
def sign_in_token(user: &User) -> String do
fingerprint = User.sign_in_fingerprint(user)!
glacier::auth::token_for("sign_in", user.id, &fingerprint, 15.minutes())
end
# The user a sign-in link's token is for, while it's still good.
def find_by_sign_in_token(token: &str) -> Option<User> do
with Some(signed) <- glacier::auth::read_token("sign_in", token),
Some(user) <- User.find_by(id: signed.id)! do
fingerprint = User.sign_in_fingerprint(&user)!
signed.matches(&fingerprint).then_some(user)
else
None
end
end
# The password and the latest session: what a sign-in link's token is
# good for.
defp sign_in_fingerprint(user: &User) -> String do
latest = Session.where(user_id: user.id).order(id: :desc).first()!
latest_id = latest.map(fn s -> s.id end) || 0
"#{user.password_digest}/#{latest_id}"
end
# Whether signing in asks for a code from an authenticator app too.
def two_factor(self) -> bool do
self.otp_enabled_at.is_some()
end
# Turns two-factor sign-in on with the secret the user's app was given,
# if `code` is from that app; returns recovery codes to show them, once.
def enable_two_factor(self, secret: &str, code: &str) -> Option<Array<String>> do
if let Some(step) = glacier::auth::totp::verify(secret, code, None) do
self.otp_secret = Some(secret.to_owned())
self.otp_enabled_at = Some(Timestamp.now())
self.otp_last_step = Some(step)
self.save()!
Some(self.new_recovery_codes()!)
else
None
end
end
# Whether `code` is the current one from the user's authenticator app,
# or one of their recovery codes, which is then used up.
def verify_second_factor(self, code: &str) -> bool do
secret = self.otp_secret.clone().unwrap_or_default()
if let Some(step) = glacier::auth::totp::verify(&secret, code, self.otp_last_step) do
self.otp_last_step = Some(step)
self.save()!
true
else
digest = glacier::auth::token_digest(&glacier::auth::totp::normalize_recovery_code(code))
RecoveryCode.where(user_id: self.id, code_digest: digest).delete_all()! > 0
end
end
# Ten new recovery codes, replacing any the user had.
def new_recovery_codes(self) -> Array<String> do
RecoveryCode.where(user_id: self.id).delete_all()!
codes = glacier::auth::totp::recovery_codes(10)
for code in &codes do
RecoveryCode.new(user_id: self.id, code_digest: glacier::auth::token_digest(code)).save()!
end
codes
end
def disable_two_factor(self) do
self.otp_secret = None
self.otp_enabled_at = None
self.otp_last_step = None
self.save()!
RecoveryCode.where(user_id: self.id).delete_all()!
end
def normalize_email(email_address: &str) -> String do
email_address.trim().to_lowercase()
end
validate do
if self.password_digest.is_empty() and self.password.is_none() do
errors.add("password", "can't be blank")
end
if self.password.as_ref().is_some_and(fn p -> p.chars().count() < 8 end) do
errors.add("password", "is too short (minimum is 8 characters)")
end
end
before_validation do
self.email_address = User.normalize_email(&self.email_address)
end
before_save do
if self.password.is_some() do
# A new password ends the user's sessions, wherever they are.
if not self.is_new() do
Session.end_all(self.id)!
end
self.password_digest = glacier::auth::hash_password(&self.password.take().unwrap_or_default())
end
end
end
```
`password` is a `virtual` field: it's set from the sign-up form but never
stored. `before_save` replaces it with its hash in `password_digest`. The
`validate` block requires a password for a new user and checks its
length. Email addresses are trimmed and lower-cased before validation, so
`" USER@example.com "` and `user@example.com` are the same account.
`password_digest` is `protected`, so it can't be set from params: a form
that sends `password_digest` is ignored, and only `password` changes it:
```glacier
test "ignores a password_digest sent as a param" do
user = User.build([email_address: "new@example.com", password_digest: "not a hash"])
refute user.save()!
assert user.password_digest == ""
assert user.errors_on("password") == ["can't be blank"]
end
```
`authenticate_by` finds the user and checks the password. When there's no
user with that email address, `glacier::auth::verify_password` still
spends the time of an Argon2 check, so for accounts with Argon2 passwords,
signing in takes about as long whether or not the email address has an
account. (Passwords imported from a Rails app are a different case; see
[Existing Rails Apps](#existing-rails-apps).)
### Sessions
Each sign-in is a row in `sessions`:
```glacier
module Session do
implements Model
field token_digest: String, required, unique, protected
field user_agent: Option<String>
belongs_to :user
# How long a session lasts after signing in; then the user signs in again.
LIFETIME_DAYS = 30
# How long a session lasts unused: a browser left signed in and
# forgotten is signed out sooner.
IDLE_DAYS = 14
# Starts a session for the user, returning its token for the client. The
# database keeps only the token's digest.
def start(user_id: i64, user_agent: Option<String>) -> String do
token = glacier::auth::new_token()
session = Session.new(user_id: user_id, user_agent: user_agent, token_digest: glacier::auth::token_digest(&token))
session.save()!
token
end
# The session with this token, and its user, unless it's expired. Using
# it marks it used, at most once an hour.
def find_by_token(token: &str) -> Option<Session> do
started_after = Session.LIFETIME_DAYS.days().ago()
used_after = Session.IDLE_DAYS.days().ago()
found = Session.where(token_digest: glacier::auth::token_digest(token))
.filter(fn s -> s.inserted_at.gt(started_after) end)
.filter(fn s -> s.updated_at.gt(used_after) end)
.preload(:user)
.first()!
if let Some(session) = &found do
if session.updated_at < 1.hours().ago() do
session.touch()!
end
end
found
end
# Deletes the sessions that have expired, signed out or unused, and
# returns how many: the daily `PruneSessions` job's work.
def prune() -> u64 do
started_after = Session.LIFETIME_DAYS.days().ago()
used_after = Session.IDLE_DAYS.days().ago()
old = Session.filter(fn s -> s.inserted_at.lt(started_after) end).delete_all()!
unused = Session.filter(fn s -> s.updated_at.lt(used_after) end).delete_all()!
old + unused
end
# Ends every session of the user's: signing out everywhere.
def end_all(user_id: i64) do
Session.where(user_id: user_id).delete_all()!
end
end
```
```glacier
migration do
create_table :sessions do
references :user, null: false, on_delete: :cascade
# Only the token's digest: the token itself stays with the client.
string :token_digest, null: false, unique: true
string :user_agent
timestamps
end
end
```
`Session.start` makes a new token for the client and stores only its
digest. `Session.find_by_token` digests the token a request sends and
looks that up, loading the user with it.
### Passwords and Tokens
The generated code relies on five functions in `glacier::auth`:
| `hash_password(password)` | An Argon2id hash with a random 16-byte salt and the `argon2` crate's default cost (`m=19456,t=2,p=1`), as a PHC string (`$argon2id$v=19$m=19456,t=2,p=1$…`) |
| `verify_password(password, digest)` | Whether the password matches the digest; `digest` is an `Option`, and `None` still takes the time of a check |
| `new_token()` | 32 random bytes from the operating system, base64url-encoded (43 characters) |
| `token_digest(token)` | The token's SHA-256 digest, hex-encoded |
| `bearer_token(&ctx)` | The token from an `Authorization: Bearer …` header, if the request has one |
Only the digest of a session token is stored, so someone who reads the
`sessions` table can't use what they find there to sign in. The token
itself goes to the browser, inside the session cookie.
The generated `User` tests check the password half of this:
```glacier
test "stores only a hash of the password" do
user = insert(User, email_address: "user@example.com")
assert user.password_digest.starts_with("$argon2id$")
assert user.password.is_none()
end
test "signs in with the right password only" do
insert(User, email_address: "user@example.com")
assert User.authenticate_by("user@example.com", "password")!.is_some()
assert User.authenticate_by(" USER@example.com ", "password")!.is_some()
assert User.authenticate_by("user@example.com", "wrong")!.is_none()
assert User.authenticate_by("nobody@example.com", "password")!.is_none()
end
test "needs a password of 8 characters or more" do
user = User.build(params_for(User, password: "short"))
refute user.save()!
assert user.errors_on("password") == ["is too short (minimum is 8 characters)"]
end
test "email addresses are unique, whatever their case" do
insert(User, email_address: "user@example.com")
user = User.build(params_for(User, email_address: "User@Example.com"))
refute user.save()!
assert user.errors_on("email_address") == ["has already been taken"]
end
```
### Signing Up, In and Out
The controllers are short, because the work is in the models and
`Auth`:
```glacier
module RegistrationsController do
implements Controller
alias Auth
def new do
render user: User.new(), title: "Sign up"
end
def create(ctx) do
# A bot's sign-up goes no further (`{{ spam_trap() }}` in the form).
glacier::spam::check(¶ms)?
user = User.build(params)
if user.save()! do
Auth.start_session(&ctx, &user)!
Auth.send_email_verification(&user)!
redirect "/", notice: "Welcome! Check your email to confirm your address."
else
render :new, user, title: "Sign up", status: 422
end
end
end
```
```glacier
module SessionsController do
implements Controller
alias Auth
def new do
render email_address: "", alert: "", title: "Sign in"
end
def create(ctx) do
email_address = params.get("email_address").unwrap_or_default()
password = params.get("password").unwrap_or_default()
case User.authenticate_by(email_address, password)! do
Some(user) ->
if user.two_factor() do
# The code from their app comes next.
Auth.begin_second_factor(&ctx, &user, true)?
redirect new_two_factor_challenge_path()
else
Auth.start_session(&ctx, &user)!
Auth.password_entered(&ctx)?
redirect Auth.after_sign_in_path(&ctx), notice: "Signed in."
end
None ->
alert = "Try another email address or password."
render :new, email_address: email_address, alert: alert, title: "Sign in", status: 422
end
end
def destroy(ctx) do
Auth.end_session(&ctx)!
redirect new_session_path(), notice: "Signed out."
end
end
```
A wrong email address and a wrong password get the same message, so the
sign-in page doesn't reveal which accounts exist. The sign-up page does: an
email address that's already registered gets "has already been taken". The routes go through the
`browser` pipeline, so the forms are protected against CSRF:
```glacier
scope "/", through: :browser do
get "/session/new", SessionsController.new, as: :new_session
post "/session", SessionsController.create, as: :session
delete "/session", SessionsController.destroy
get "/registration/new", RegistrationsController.new, as: :new_registration
post "/registration", RegistrationsController.create, as: :registration
get "/passwords/new", PasswordsController.new, as: :new_password
post "/passwords", PasswordsController.create, as: :passwords
get "/passwords/:token/edit", PasswordsController.edit, as: :edit_password
patch "/passwords/:token", PasswordsController.update, as: :password
get "/email_verifications/:token", EmailVerificationsController.show, as: :email_verification
get "/email_changes/:token", EmailChangesController.show, as: :email_change
post "/sign_in_links", SignInLinksController.create, as: :sign_in_links
get "/sign_in_links/:token", SignInLinksController.show, as: :sign_in_link
post "/sign_in_links/:token", SignInLinksController.update
get "/two_factor_challenge/new", TwoFactorChallengesController.new, as: :new_two_factor_challenge
post "/two_factor_challenge", TwoFactorChallengesController.create, as: :two_factor_challenge
end
scope "/", through: [:browser, :authenticated] do
get "/email_verification/new", EmailVerificationsController.new, as: :new_email_verification
post "/email_verification", EmailVerificationsController.create, as: :email_verifications
get "/password_confirmation/new", PasswordConfirmationsController.new, as: :new_password_confirmation
post "/password_confirmation", PasswordConfirmationsController.create, as: :password_confirmations
end
```
### Keeping Bots Out of Sign-Up
Bots sign up to anything with a form. The generated sign-up form has
`{{ spam_trap() }}` in it: a field people never see, which bots fill in
as they fill every field, and the time the form was shown, signed. The
action refuses the form with `422` when the hidden field has text, the
time is missing or forged, or the form came back in under two seconds,
sooner than a person types:
```glacier
def create(ctx) do
# A bot's sign-up goes no further (`{{ spam_trap() }}` in the form).
glacier::spam::check(¶ms)?
user = User.build(params)
if user.save()! do
Auth.start_session(&ctx, &user)!
Auth.send_email_verification(&user)!
redirect "/", notice: "Welcome! Check your email to confirm your address."
else
render :new, user, title: "Sign up", status: 422
end
end
```
```glacier
test "refuses a sign-up with the hidden field filled in, as bots do" do
get "/registration/new"
res = post "/registration", [email_address: "bot@example.com", password: "password", website: "http://spam.example"]
assert res.status == :unprocessable_entity
assert User.count()! == 0
end
```
Tests post forms without loading them, so in the test environment only
the hidden field is checked. Any public form can use the same pair,
`{{ spam_trap() }}` and `glacier::spam::check(¶ms)?`. A CAPTCHA such
as Cloudflare Turnstile isn't built in yet.
### Resetting a Forgotten Password
A user who has forgotten their password asks for a link at
`/passwords/new` ("Forgot your password?" on the sign-in page). The link
goes by mail and holds a signed token, made from the user's id and
password digest, that works for 15 minutes and only until the password
changes, so it can be used once:
```glacier
# A token for a password reset link: good for 15 minutes, and only
# until the password changes, which makes it single-use.
def reset_token(self) -> String do
glacier::auth::token_for("password_reset", self.id, &self.password_digest, 15.minutes())
end
# The user a reset link's token is for, while it's still good.
def find_by_reset_token(token: &str) -> Option<User> do
case glacier::auth::read_token("password_reset", token) do
Some(signed) -> User.find_by(id: signed.id)!.filter(fn u -> signed.matches(&u.password_digest) end)
nil -> None
end
end
```
```glacier
# Resetting a forgotten password: a link by email that works for 15
# minutes, and only until the password changes. Written by `glacier
# generate authentication`; it's your code now.
module PasswordsController do
implements Controller
alias Mailers.PasswordsMailer
def new do
render title: "Forgot your password?"
end
# Mails the link if the address has an account, and says the same
# either way, so the page doesn't reveal who has one.
def create do
email_address = User.normalize_email(¶ms[:email_address])
if let Some(user) = User.find_by(email_address: email_address)! do
link = url_for(edit_password_path(&user.reset_token()))?
PasswordsMailer.reset(&user, &link).deliver_now()!
end
redirect new_session_path(), notice: "If that address has an account, a reset link is on its way."
end
def edit do
case User.find_by_reset_token(¶ms[:token])! do
Some(user) -> render user, token: params[:token].to_s(), title: "Choose a new password"
nil -> redirect new_password_path(), alert: "That reset link is invalid or has expired."
end
end
def update do
case User.find_by_reset_token(¶ms[:token])! do
Some(found) ->
user = found
if user.update(password: params[:password].to_s())! do
redirect new_session_path(), notice: "Password changed. Sign in with it."
else
render :edit, user, token: params[:token].to_s(), title: "Choose a new password", status: 422
end
nil -> redirect new_password_path(), alert: "That reset link is invalid or has expired."
end
end
end
```
`create` says the same whether or not the address has an account, so
the page doesn't tell anyone who signs up here:
```glacier
setup do
get "/passwords/new"
end
# The path of the reset link in the last mail sent.
def reset_link -> String do
text = glacier::testing::delivered_mail().last().unwrap().text.clone().unwrap()
link = text.split_whitespace().find(fn word -> word.contains("/passwords/") end).unwrap().to_owned()
link[link.find("/passwords/").unwrap()..].to_owned()
end
test "mails a reset link, and sets a new password with it" do
insert(User, email_address: "user@example.com")
res = post "/passwords", [email_address: "user@example.com"]
assert res.status == :see_other
link = reset_link()
assert get(&link).status == :ok
res = patch link.trim_end_matches("/edit"), [password: "a new password"]
assert res.location == "/session/new"
assert User.authenticate_by("user@example.com", "a new password")!.is_some()
# Used once: the password changed, so the link no longer works.
assert get(&link).location == "/passwords/new"
end
test "says the same for an address without an account" do
res = post "/passwords", [email_address: "nobody@example.com"]
assert res.status == :see_other
assert glacier::testing::delivered_mail().is_empty()
end
test "refuses a forged link" do
assert get("/passwords/forged/edit").location == "/passwords/new"
end
test "mails at most 3 links every 15 minutes to an address" do
freeze_time()
insert(User, email_address: "user@example.com")
get "/passwords/new"
for _ in 0..3 do
assert post("/passwords", [email_address: "user@example.com"]).status == :see_other
end
assert post("/passwords", [email_address: "user@example.com"]).status == :too_many_requests
# Asking for a sign-in link counts too.
assert post("/sign_in_links", [email_address: "user@example.com"]).status == :too_many_requests
assert post("/passwords", [email_address: "other@example.com"]).status == :see_other
end
```
The link's address comes from `url_for`, which builds it on the app's
`url` setting (`url = "https://example.com"` in `config/production.toml`,
or `GLACIER__URL`), never on the request's `Host` header, which whoever
sends the request chooses: a forged `Host` would otherwise send a real
reset link to someone else's site. Outside production, `url` is the
address the server listens on. The mail needs a sender, so the generator
sets `[mail] from` in `config/default.toml` if the app had none; change
it to an address of yours. `glacier::auth::token_for` and `read_token`
make and read such tokens for other purposes too, such as a link that
confirms an email address.
### Signed Messages (new)
A token carries a record's id. To send any text through a browser and
know it comes back unchanged, sign it: `glacier::auth::sign_message`
signs text for a purpose, as Rails' `MessageVerifier` does, and
`read_message` gives it back only if this app signed it for that
purpose. The text isn't encrypted, so anyone can read it; only the app
can make one. A QR code's link, a list to unsubscribe from, or a value a
form carries round:
```glacier
test "a signed message reads back only for its purpose" do
signed = glacier::auth::sign_message("qr_code", "https://example.com/1/handbook")
assert glacier::auth::read_message("qr_code", &signed) == Some("https://example.com/1/handbook".to_owned())
# Not for another purpose, and not if anyone changed it.
assert glacier::auth::read_message("unsubscribe", &signed) == None
assert glacier::auth::read_message("qr_code", "https://evil.example") == None
end
```
Messages are signed with the app's `secret_key_base` (and read with its
previous ones), and are URL-safe.
### Signing In by a Link
A user can sign in without their password, by a link in the mail ("Email
me a sign-in link" on the sign-in page). Its token is made from the
user's id, password digest and latest session, so it works for 15
minutes and for one sign-in: signing in any way makes it stale. The link
opens a page with a button rather than signing in at once, so a mail
scanner that opens links doesn't use it up, and following it confirms
the address too:
```glacier
# A token for a sign-in link: good for 15 minutes and one sign-in, as
# signing in (any way) makes it stale.
def sign_in_token(user: &User) -> String do
fingerprint = User.sign_in_fingerprint(user)!
glacier::auth::token_for("sign_in", user.id, &fingerprint, 15.minutes())
end
# The user a sign-in link's token is for, while it's still good.
def find_by_sign_in_token(token: &str) -> Option<User> do
with Some(signed) <- glacier::auth::read_token("sign_in", token),
Some(user) <- User.find_by(id: signed.id)! do
fingerprint = User.sign_in_fingerprint(&user)!
signed.matches(&fingerprint).then_some(user)
else
None
end
end
# The password and the latest session: what a sign-in link's token is
# good for.
defp sign_in_fingerprint(user: &User) -> String do
latest = Session.where(user_id: user.id).order(id: :desc).first()!
latest_id = latest.map(fn s -> s.id end) || 0
"#{user.password_digest}/#{latest_id}"
end
```
```glacier
# Signing in by a link in the mail instead of a password: good for 15
# minutes and one sign-in. Written by `glacier generate authentication`;
# it's your code now.
module SignInLinksController do
implements Controller
alias Auth
alias Mailers.SignInLinksMailer
# Mails the link if the address has an account, and says the same
# either way, so the page doesn't reveal who has one.
def create do
email_address = User.normalize_email(¶ms[:email_address])
if let Some(user) = User.find_by(email_address: email_address)! do
link = url_for(sign_in_link_path(&User.sign_in_token(&user)!))?
SignInLinksMailer.sign_in(&user, &link).deliver_now()!
end
redirect new_session_path(), notice: "If that address has an account, a sign-in link is on its way."
end
# The link from the mail: a page with a button, so a mail scanner that
# opens links doesn't sign in (or use the link up).
def show do
case User.find_by_sign_in_token(¶ms[:token])! do
Some(user) -> render email_address: user.email_address.clone(), token: params[:token].to_s(), title: "Sign in"
nil -> redirect new_session_path(), alert: "That sign-in link is invalid or has expired."
end
end
# Signs in. Following the link proves the address is the user's.
def update(ctx) do
case User.find_by_sign_in_token(¶ms[:token])! do
Some(found) ->
user = found
if user.email_verified_at.is_none() do
user.email_verified_at = Some(Timestamp.now())
user.save()!
end
if user.two_factor() do
Auth.begin_second_factor(&ctx, &user, false)?
redirect new_two_factor_challenge_path()
else
Auth.start_session(&ctx, &user)!
redirect Auth.after_sign_in_path(&ctx), notice: "Signed in."
end
nil -> redirect new_session_path(), alert: "That sign-in link is invalid or has expired."
end
end
end
```
```glacier
setup do
get "/session/new"
end
# The path of the link in the last mail sent.
def mailed_link -> String do
text = glacier::testing::delivered_mail().last().unwrap().text.clone().unwrap()
link = text.split_whitespace().find(fn word -> word.contains("/sign_in_links/") end).unwrap().to_owned()
link[link.find("/sign_in_links/").unwrap()..].to_owned()
end
test "signs in by a mailed link, once" do
insert(User, email_address: "user@example.com")
post "/sign_in_links", [email_address: "user@example.com"]
link = mailed_link()
# Opening the link signs no one in: its page has a button.
assert get(&link).body =~ "Sign in as user@example.com?"
assert Session.count()! == 0
res = post &link
assert res.status == :see_other
assert Session.count()! == 1
# Following it confirmed the address too.
assert User.first()!.unwrap().email_verified_at.is_some()
# Used: a second sign-in with it is refused.
assert post(&link).location == "/session/new"
assert Session.count()! == 1
end
test "says the same for an address without an account" do
res = post "/sign_in_links", [email_address: "nobody@example.com"]
assert res.status == :see_other
assert glacier::testing::delivered_mail().is_empty()
end
```
### Confirming an Email Address
Signing up mails a link that confirms the address. Its token is made
from the user's id and email address, so it works for 3 days and only
for the address it was sent to; following it sets the user's
`email_verified_at`:
```glacier
# A token for the link confirming the email address: good for 3 days,
# and only for the address it was sent to.
def verification_token(self) -> String do
glacier::auth::token_for("email_verification", self.id, &self.email_address, 3.days())
end
# The user a confirmation link's token is for, while it's still good.
def find_by_verification_token(token: &str) -> Option<User> do
case glacier::auth::read_token("email_verification", token) do
Some(signed) -> User.find_by(id: signed.id)!.filter(fn u -> signed.matches(&u.email_address) end)
nil -> None
end
end
```
```glacier
# Confirming a user's email address: a link by mail when they sign up,
# good for 3 days and only for that address. Written by `glacier generate
# authentication`; it's your code now.
module EmailVerificationsController do
implements Controller
alias Auth
# The link from the mail.
def show do
case User.find_by_verification_token(¶ms[:token])! do
Some(found) ->
user = found
if user.email_verified_at.is_none() do
user.email_verified_at = Some(Timestamp.now())
user.save()!
end
redirect "/", notice: "Thanks, your email address is confirmed."
nil -> redirect "/", alert: "That confirmation link is invalid or has expired."
end
end
# Asks a signed-in user who hasn't confirmed yet to check their mail.
def new(ctx) do
email_address = Auth.signed_in_user(&ctx)?.email_address.clone()
render email_address: email_address, title: "Confirm your email address"
end
# Sends the link again.
def create(ctx) do
Auth.send_email_verification(Auth.signed_in_user(&ctx)?)!
redirect new_email_verification_path(), notice: "A new confirmation link is on its way."
end
end
```
```glacier
setup do
get "/registration/new"
end
# The path of the link in the last mail sent.
def mailed_link -> String do
text = glacier::testing::delivered_mail().last().unwrap().text.clone().unwrap()
link = text.split_whitespace().find(fn word -> word.contains("/email_verifications/") end).unwrap().to_owned()
link[link.find("/email_verifications/").unwrap()..].to_owned()
end
test "signing up mails a link that confirms the address" do
post "/registration", [email_address: "new@example.com", password: "password"]
assert User.first()!.unwrap().email_verified_at.is_none()
res = get mailed_link()
assert res.status == :see_other
assert get(res.location).body =~ "your email address is confirmed"
assert User.first()!.unwrap().email_verified_at.is_some()
end
test "a link for an address the user has since changed doesn't" do
post "/registration", [email_address: "new@example.com", password: "password"]
link = mailed_link()
user = User.first()!.unwrap()
user.email_address = "other@example.com".to_s()
user.save()!
get &link
assert User.first()!.unwrap().email_verified_at.is_none()
end
test "sends the link again" do
post "/registration", [email_address: "new@example.com", password: "password"]
assert get("/email_verification/new").body =~ "new@example.com"
res = post "/email_verification"
assert res.location == "/email_verification/new"
assert glacier::testing::delivered_mail().len() == 2
end
```
Signing up doesn't wait for it: a new user is signed in at once. Pages
that need a confirmed address go through the `verified` pipeline, after
`authenticated`, which sends anyone who hasn't confirmed to a page that
asks them to and can send the link again:
```glacier
# Only users who've confirmed their email address, after `authenticated`;
# see src/middleware/require_verified.glacier.
pipeline verified = Pipeline.new().with(RequireVerified)
```
```glacier
# Only for users who've confirmed their email address.
scope "/", through: [:browser, :authenticated, :verified, :authorized] do
post "/notes/digest", NotesController.digest, as: :notes_digest
end
```
```glacier
test "asks an unconfirmed user to confirm their address first" do
sign_in()
get "/notes"
res = post "/notes/digest"
assert res.location == "/email_verification/new"
end
```
`email_verified_at` is `protected`, so a sign-up form can't set it. A
test's factory can, as factories set any column, so a test signs in a
confirmed user with `insert(User, email_verified_at: Timestamp.now().to_s())`.
### Changing the Email Address
An address typed wrong, or someone else's, mustn't take over an account,
so the `accounts` app changes it only once a link mailed to the new
address is followed, as Devise's `reconfirmable` does. Until then the
new address waits in `unconfirmed_email`. Asking goes through `sudo`,
and says the same whether or not the address is taken, so the page
doesn't reveal who has an account:
```glacier
# Says the same whether or not the address is taken, so the page doesn't
# reveal who has an account; a taken address never gets the link.
def create(ctx) do
skip_authorization
user = Auth.signed_in_user(&ctx)?.clone()
new_email = User.normalize_email(¶ms[:email_address])
if User.find_by(email_address: &new_email)!.is_none() do
user.unconfirmed_email = Some(new_email.clone())
user.save()!
link = url_for(email_change_path(&user.email_change_token()))?
EmailChangesMailer.confirm(&new_email, &link).deliver_now()!
end
redirect "/account", notice: "Check #{new_email} for a link to confirm it."
end
```
The link's token is good for a day and only for the address it was sent
to, so asking again for another address makes it stale. Following it
makes the new address the user's (and confirmed), unless someone has
taken it since; the old address is told:
```glacier
# A token for the link confirming a new address: good for a day, and
# only for the address it was sent to.
def email_change_token(self) -> String do
pending = self.unconfirmed_email.clone().unwrap_or_default()
glacier::auth::token_for("email_change", self.id, &pending, 1.days())
end
def find_by_email_change_token(token: &str) -> Option<User> do
case glacier::auth::read_token("email_change", token) do
Some(signed) -> User.find_by(id: signed.id)!.filter(fn u -> u.unconfirmed_email.as_ref().is_some_and(fn pending -> signed.matches(pending) end) end)
nil -> None
end
end
# Makes the new address the user's, confirmed, unless someone has taken
# it since.
def confirm_email_change(self) -> bool do
pending = self.unconfirmed_email.take().unwrap_or_default()
taken = User.find_by(email_address: &pending)!.is_some()
if !taken do
self.email_address = pending
self.email_verified_at = Some(Timestamp.now())
end
self.save()!
!taken
end
```
```glacier
test "changes the address once the new one is confirmed" do
user = insert(User, email_address: "old@example.com")
post "/session", [email_address: "old@example.com", password: "password"]
get "/email_change/new"
res = post "/email_change", [email_address: "New@Example.com"]
assert res.location == "/account"
mail = glacier::testing::delivered_mail().last().unwrap().clone()
assert mail.to == ["new@example.com"]
# Not yet: only following the link changes it.
assert User.find(user.id)!.email_address == "old@example.com"
get mailed_link()
user = User.find(user.id)!
assert user.email_address == "new@example.com"
assert user.unconfirmed_email.is_none()
# The old address hears about it.
assert glacier::testing::delivered_mail().last().unwrap().to == ["old@example.com"]
end
```
The mailed-links throttle counts these too. `glacier generate
authentication` writes all of this; link to `/email_change/new` from a
page of the user's account.
## Who's Signed In
The `Authentication` middleware, in
`src/middleware/authentication.glacier`, runs on every request: it reads a token from an
`Authorization: Bearer` header or, failing that, from the session cookie,
and if a session has that token, it stores the session and its user in
the request:
```glacier
# Finds each request's session, from the session cookie or a bearer token,
# so every page knows who's signed in (`Auth.current_user(ctx)`), and
# whose access the request has (`access`). It's in the app's settings,
# src/app.glacier.
module Authentication do
implements Middleware
alias Access.UserAccess
alias Auth
def call(ctx, next) do
token = case glacier::auth::bearer_token(&ctx) do
Some(token) -> Some(token.to_owned())
nil -> session.get::<String>("session_token")
end
if let Some(token) = token do
if token.starts_with("glc_") do
# An API token: its user, limited to what its scopes allow.
if let Some(api_token) = ApiToken.find_by_token(&token)! do
if let Some(user) = api_token.user.get().cloned() do
glacier::auth::limit_to_scopes(&mut ctx, api_token.scope_list())
ctx.set_access(UserAccess { user: user.clone() })
ctx.extensions_mut().insert(user)
end
end
else
if let Some(signed_in) = Session.find_by_token(&token)! do
if let Some(signed_in_user) = signed_in.user.get().cloned() do
# A staff member signed in as someone else acts as them.
user = Auth.impersonated(&ctx, &signed_in_user)!.unwrap_or(signed_in_user)
ctx.set_access(UserAccess { user: user.clone() })
ctx.extensions_mut().insert(user)
ctx.extensions_mut().insert(signed_in)
end
end
end
end
next(ctx)
end
end
```
The generator adds it to the app's settings in `src/app.glacier`:
```glacier
# Who's signed in, for every request.
middleware Authentication
```
`Auth.current_user(&ctx)`, in `src/auth.glacier`, gives the signed-in
user, if there is one (a module of functions, so a controller says `alias
Auth`):
```glacier
# The signed-in user, if there is one.
def current_user(ctx: &Context) -> Option<&User> do
ctx.extensions().get::<User>()
end
```
Signing in and out happens in two functions the controllers call:
```glacier
# Signs `user` in: a new session, with its token in the browser's session
# cookie. Returns the token too, for API clients.
def start_session(ctx: &Context, user: &User) -> String do
user_agent = ctx.header("user-agent").map(str::to_owned)
token = Session.start(user.id, user_agent)!
ctx.session().insert("session_token", &token)?
ctx.session().remove("password_entered_at")
ctx.session().remove("second_factor_user_id")
ctx.session().remove("impersonating_id")
token
end
# Mails the user a link confirming their email address.
def send_email_verification(user: &User) do
link = url_for(email_verification_path(&user.verification_token()))?
EmailVerificationsMailer.verify(user, &link).deliver_now()!
end
# Notes that the user has just entered their password (signing in or
# confirming it), for the `sudo` pipeline.
def password_entered(ctx: &Context) do
ctx.session().insert("password_entered_at", Timestamp.now().timestamp())?
end
# Whether the user entered their password within `within`.
def password_entered_within(ctx: &Context, within: glacier::chrono::TimeDelta) -> bool do
end
# The user gave the right password (or followed a sign-in link) but still
# has a code to enter: remembered in the session for 5 minutes.
def begin_second_factor(ctx: &Context, user: &User, password: bool) do
ctx.session().insert("second_factor_user_id", user.id)?
ctx.session().insert("second_factor_at", Timestamp.now().timestamp())?
ctx.session().insert("second_factor_password", password)?
end
# The user part-way through signing in, while there's still time.
def second_factor_user(ctx: &Context) -> Option<User> do
if let Some(user_id) = ctx.session().get::<i64>("second_factor_user_id").filter(fn _ -> fresh end) do
User.find_by(id: user_id)!
else
None
end
end
# Signs in a user who has entered their code.
def finish_second_factor(ctx: &Context, user: &User) do
if password do
password_entered(ctx)?
end
end
# Signs a staff member in as `user`, until they stop: they see what the
# user sees, and act as them.
def impersonate(ctx: &Context, user: &User) do
ctx.session().insert("impersonating_id", user.id)?
end
def stop_impersonating(ctx: &Context) do
ctx.session().remove("impersonating_id")
end
# The user a signed-in staff member is impersonating, if any; noted for
# the request (`Impersonation`). Never another staff member.
def impersonated(ctx: &Context, staff: &User) -> Option<User> do
user_id = ctx.session().get::<i64>("impersonating_id").filter(fn _ -> staff.staff end)
found = if let Some(user_id) = user_id do
User.find_by(id: user_id)!.filter(fn user -> !user.staff end)
else
None
end
if let Some(user) = &found do
glacier::current::set(Impersonation { staff_id: staff.id, staff_email: staff.email_address.clone(), user_email: user.email_address.clone() })
end
found
end
# Signs the user out everywhere: every session of theirs ends.
def end_all_sessions(user: &User) do
Session.end_all(user.id)!
end
# Signs out: ends the current session, however its token was sent.
def end_session(ctx: &Context) do
if let Some(session) = ctx.extensions().get::<Session>() do
session.clone().destroy()!
end
ctx.session().remove("session_token")
ctx.session().remove("impersonating_id")
end
```
The browser's session cookie is encrypted, `HttpOnly`, `SameSite=Lax`,
and `Secure` in production. It has no expiry date, so the browser usually
drops it when it closes, but the row in `sessions` stays until the user signs
out.
### How Long a Session Lasts
A session lasts `Session.LIFETIME_DAYS` (30) days from signing in, and
ends sooner if it goes `Session.IDLE_DAYS` (14) days unused;
`Session.find_by_token` ignores it then, so its user signs in again.
Using a session marks it used (`session.touch()`, at most once an hour,
so most requests don't write). A new password ends every session of the user's, wherever they
signed in, and `Auth.end_all_sessions(user)` does the same on request,
for a "sign out everywhere" button:
```glacier
test "a new password ends the user's sessions" do
user = insert(User, email_address: "user@example.com")
token = Session.start(user.id, None)!
user.update([password: "a new password"])!
assert Session.count()! == 0
assert Session.find_by_token(&token)!.is_none()
end
test "a session expires" do
user = insert(User, email_address: "user@example.com")
token = Session.start(user.id, None)!
assert Session.find_by_token(&token)!.is_some()
glacier::testing::travel((Session.LIFETIME_DAYS + 1).days())
assert Session.find_by_token(&token)!.is_none()
end
test "a session expires unused, but not while it's used" do
user = insert(User, email_address: "user@example.com")
token = Session.start(user.id, None)!
glacier::testing::travel((Session.IDLE_DAYS - 1).days())
assert Session.find_by_token(&token)!.is_some()
glacier::testing::travel((Session.IDLE_DAYS - 1).days())
assert Session.find_by_token(&token)!.is_some()
glacier::testing::travel((Session.IDLE_DAYS + 1).days())
assert Session.find_by_token(&token)!.is_none()
end
```
```glacier
# The session with this token, and its user, unless it's expired. Using
# it marks it used, at most once an hour.
def find_by_token(token: &str) -> Option<Session> do
started_after = Session.LIFETIME_DAYS.days().ago()
used_after = Session.IDLE_DAYS.days().ago()
found = Session.where(token_digest: glacier::auth::token_digest(token))
.filter(fn s -> s.inserted_at.gt(started_after) end)
.filter(fn s -> s.updated_at.gt(used_after) end)
.preload(:user)
.first()!
if let Some(session) = &found do
if session.updated_at < 1.hours().ago() do
session.touch()!
end
end
found
end
```
Both are the app's own constants, in `src/models/session.glacier`, to
change as it needs.
An expired session can't be used, but its row stays until something
deletes it. The generator adds a job that does, every day,
`src/jobs/prune_sessions.glacier`:
```glacier
# Deletes expired sessions, and the API tokens among them, every day: an
# expired session can't be used, but its row stays until then. Written by
# `glacier generate authentication`.
module PruneSessions do
implements Job
every 1.days()
def perform do
Session.prune()!
end
end
```
An API client's token is a session too, so tokens expire and are pruned
the same way.
## Protecting Routes
The generator adds an `authenticated` pipeline to `src/routes.glacier`:
```glacier
# Only for signed-in users; see src/middleware/require_authentication.glacier.
pipeline authenticated = Pipeline.new().with(RequireAuthentication)
```
and routes that only signed-in users may reach go in a scope through it,
after `browser`:
```glacier
scope "/", through: [:browser, :authenticated, :authorized] do
resources "/notes", NotesController
end
```
(The `authorized` pipeline is covered in
[Authorisation](#catching-a-missing-authorize).)
`RequireAuthentication` lets a signed-in request through. Anyone else is
sent to sign in, or, if the request looks like an API client's (it has a
bearer token, or its `Accept` header asks for JSON), answered with
`401 Unauthorized`:
```glacier
# Lets only signed-in users through. A browser is sent to sign in, and
# back here afterwards; an API client gets `401 Unauthorized`. The
# `authenticated` pipeline in src/routes.glacier uses it.
module RequireAuthentication do
implements Middleware
def call(ctx, next) do
if ctx.extensions().get::<User>().is_some() do
return next(ctx)
end
api = glacier::auth::bearer_token(&ctx).is_some() or ctx.header("accept").is_some_and(fn accept -> accept.contains("application/json") end)
if api do
raise Error.new(StatusCode.UNAUTHORIZED, "sign in first")
end
if *ctx.method() == Method.GET do
session.insert("return_to", ctx.uri().to_s()).ok()
end
redirect new_session_path()
end
end
```
For a `GET`, it remembers the page in the session, and after signing in,
`after_sign_in_path` sends the browser back there:
```glacier
# Where to go after signing in: the page that sent the browser to sign
# in, or the home page.
def after_sign_in_path(ctx: &Context) -> String do
path = ctx.session().get::<String>("return_to")
ctx.session().remove("return_to")
path.unwrap_or_else(fn -> "/".to_owned() end)
end
```
The notes tests check the whole round trip:
```glacier
test "sends a signed-out visitor to sign in, and back afterwards" do
res = get "/notes"
assert res.status == :see_other
assert res.location == "/session/new"
user = insert(User)
get res.location
res = post "/session", [email_address: &user.email_address, password: "password"]
assert res.location == "/notes"
end
```
### The Signed-In User in an Action
Inside the `authenticated` scope, `Auth.current_user(&ctx)` is always
`Some`. `Auth.signed_in_user(&ctx)` turns it into a `Result`, so an action
can use `?`:
```glacier
# The signed-in user, for actions behind the `authenticated` pipeline,
# which has already turned everyone else away.
def signed_in_user(ctx: &Context) -> Result<&User> do
current_user(ctx).ok_or_else(fn -> Error.new(StatusCode.UNAUTHORIZED, "sign in first") end)
end
```
`create` checks it with the policy, then builds the note from
`Note.for(access)` (see [Access](#who-a-request-is-for-access)), which
gives it the signed-in user's `user_id`. Params never set that column, so
a `user_id` sent with the form can't make the note someone else's:
```glacier
def create(ctx) do
authorize NotePolicy.create(Auth.signed_in_user(&ctx)?)
# The signed-in user's note, never a user_id sent with the form.
note = Note.for(access).build(params)
if note.save()! do
redirect note, notice: "Note created."
else
render :new, note, title: "New note", status: 422
end
end
```
```glacier
test "creates a note for the signed-in user" do
user = sign_in()
other = insert(User)
res = post "/notes", params_for(Note, user_id: other.id)
assert res.status == :see_other
assert get(res.location).body =~ "Note created."
assert Note.first()!.unwrap().user_id == user.id
end
```
`update` is as safe: `note.update(params)` ignores a `user_id` from the
form, as the note is `owned_by :user`, so a user can't hand their note to
someone else:
```glacier
def update(ctx) do
note = Note.for(access).find(params[:id])!
authorize NotePolicy.update(Auth.signed_in_user(&ctx)?, ¬e)
# A note keeps its owner, whatever user_id the form sends: params
# never set an owner column.
if note.update(params)! do
redirect note, notice: "Note updated."
else
render :edit, note, title: "Edit note", status: 422
end
end
```
```glacier
test "keeps a note's owner when the form sends another user_id" do
user = sign_in()
other = insert(User)
note = insert(Note, user_id: user.id)
res = patch note, params_for(Note, user_id: other.id), title: "Mine"
assert res.status == :see_other
assert Note.find(note.id)!.user_id == user.id
end
```
### Asking for the Password Again
Some pages change what matters most: the email address or password,
API tokens, deleting the account. A browser left signed in shouldn't be
enough for those, so the generator adds a `sudo` pipeline, which lets a
request through only if the user entered their password in the last 15
minutes, by signing in or by confirming it:
```glacier
# Only if the password was entered in the last 15 minutes, after
# `authenticated`; see src/middleware/require_recent_password.glacier.
pipeline sudo = Pipeline.new().with(RequireRecentPassword)
```
Routes go through it after `authenticated`:
```glacier
# The account page asks for the password again after 15 minutes.
scope "/", through: [:browser, :authenticated, :sudo, :authorized] do
get "/account", AccountsController.show, as: :account
end
```
Otherwise `RequireRecentPassword` sends the user to a page asking for
their password, and back to where they were going afterwards. It's in
`src/middleware/require_recent_password.glacier`; change the 15 minutes
there. `Auth` keeps the time in the session; signing in sets it, and
`start_session` clears any older one:
```glacier
# Notes that the user has just entered their password (signing in or
# confirming it), for the `sudo` pipeline.
def password_entered(ctx: &Context) do
ctx.session().insert("password_entered_at", Timestamp.now().timestamp())?
end
# Whether the user entered their password within `within`.
def password_entered_within(ctx: &Context, within: glacier::chrono::TimeDelta) -> bool do
end
```
```glacier
test "asks for the password again after 15 minutes" do
user = insert(User)
get "/session/new"
post "/session", [email_address: &user.email_address, password: "password"]
assert get("/account").status == :ok
glacier::testing::travel(16.minutes())
res = get "/account"
assert res.location == "/password_confirmation/new"
get res.location
res = post "/password_confirmation", [password: "password"]
assert res.location == "/account"
assert get("/account").status == :ok
end
```
### Two-Factor Sign-In
With two-factor sign-in on, signing in takes a code from an
authenticator app (1Password, Google Authenticator and the like) as well
as the password. The codes are TOTP, the standard every such app
supports, made and checked by `glacier::auth::totp`.
A user turns it on from the account page, which asks for their password
again first (the routes go through `sudo`):
```glacier
# Turning two-factor sign-in on and off asks for the password again too.
# The notes for scripts, with an API token; see NotesApiController.
scope "/api", through: [:api, :authenticated] do
get "/notes", NotesApiController.index
post "/notes", NotesApiController.create
end
scope "/", through: [:browser, :authenticated, :sudo] do
get "/api_tokens", ApiTokensController.index, as: :api_tokens
get "/api_tokens/new", ApiTokensController.new, as: :new_api_token
post "/api_tokens", ApiTokensController.create
delete "/api_tokens/:id", ApiTokensController.destroy, as: :api_token
get "/email_change/new", EmailChangesController.new, as: :new_email_change
post "/email_change", EmailChangesController.create, as: :email_changes
get "/two_factor/new", TwoFactorsController.new, as: :new_two_factor
post "/two_factor", TwoFactorsController.create, as: :two_factors
delete "/two_factor", TwoFactorsController.destroy
end
```
The setup page makes a new secret and shows it as a QR code for the app
to scan; the app lists it under the app's `name` from
`config/default.toml` (`name = "My Shop"`, which `glacier new` writes),
read with `glacier::config::app_name()`. The secret waits in the session
until a code from the app proves the app has it:
```glacier
# A new secret, as a QR code for the user's authenticator app; it's kept
# in the session until a code from the app confirms it.
def new(ctx) do
user = Auth.signed_in_user(&ctx)?
secret = glacier::auth::totp::new_secret()
session.insert("otp_pending_secret", &secret)?
render secret: secret.clone(), qr: qr_code(&user.email_address, &secret), alert: "", title: "Two-factor sign-in"
end
```
```glacier
def create(ctx) do
user = Auth.signed_in_user(&ctx)?.clone()
secret = session.get::<String>("otp_pending_secret").unwrap_or_default()
case user.enable_two_factor(&secret, ¶ms[:code])! do
Some(codes) ->
session.remove("otp_pending_secret")
render :recovery_codes, codes: codes, title: "Recovery codes"
None ->
alert = "That code isn't right; try the newest one."
render :new, secret: secret.clone(), qr: qr_code(&user.email_address, &secret), alert: alert, title: "Two-factor sign-in", status: 422
end
end
```
Turning it on also makes ten recovery codes, shown once, for a lost
phone. Each signs in once; the database keeps only their digests. The
`User` model has the rest:
```glacier
# Whether signing in asks for a code from an authenticator app too.
def two_factor(self) -> bool do
self.otp_enabled_at.is_some()
end
# Turns two-factor sign-in on with the secret the user's app was given,
# if `code` is from that app; returns recovery codes to show them, once.
def enable_two_factor(self, secret: &str, code: &str) -> Option<Array<String>> do
if let Some(step) = glacier::auth::totp::verify(secret, code, None) do
self.otp_secret = Some(secret.to_owned())
self.otp_enabled_at = Some(Timestamp.now())
self.otp_last_step = Some(step)
self.save()!
Some(self.new_recovery_codes()!)
else
None
end
end
# Whether `code` is the current one from the user's authenticator app,
# or one of their recovery codes, which is then used up.
def verify_second_factor(self, code: &str) -> bool do
secret = self.otp_secret.clone().unwrap_or_default()
if let Some(step) = glacier::auth::totp::verify(&secret, code, self.otp_last_step) do
self.otp_last_step = Some(step)
self.save()!
true
else
digest = glacier::auth::token_digest(&glacier::auth::totp::normalize_recovery_code(code))
RecoveryCode.where(user_id: self.id, code_digest: digest).delete_all()! > 0
end
end
```
`totp::verify` accepts the code for now or the 30 seconds either side,
for clocks that drift, and returns the step the code was for. The user
keeps the step in `otp_last_step`, and a code must be for a later step
than that, so a code someone overheard can't be used again.
Signing in with the password (or a sign-in link) no longer starts a
session for such a user. `Auth` remembers who it is for 5 minutes and
asks for the code:
```glacier
Some(user) ->
if user.two_factor() do
# The code from their app comes next.
Auth.begin_second_factor(&ctx, &user, true)?
redirect new_two_factor_challenge_path()
else
Auth.start_session(&ctx, &user)!
Auth.password_entered(&ctx)?
redirect Auth.after_sign_in_path(&ctx), notice: "Signed in."
end
```
```glacier
# The user gave the right password (or followed a sign-in link) but still
# has a code to enter: remembered in the session for 5 minutes.
def begin_second_factor(ctx: &Context, user: &User, password: bool) do
ctx.session().insert("second_factor_user_id", user.id)?
ctx.session().insert("second_factor_at", Timestamp.now().timestamp())?
ctx.session().insert("second_factor_password", password)?
end
# The user part-way through signing in, while there's still time.
def second_factor_user(ctx: &Context) -> Option<User> do
if let Some(user_id) = ctx.session().get::<i64>("second_factor_user_id").filter(fn _ -> fresh end) do
User.find_by(id: user_id)!
else
None
end
end
# Signs in a user who has entered their code.
def finish_second_factor(ctx: &Context, user: &User) do
if password do
password_entered(ctx)?
end
end
```
```glacier
def create(ctx) do
case Auth.second_factor_user(&ctx)! do
Some(found) ->
user = found
if user.verify_second_factor(¶ms[:code])! do
Auth.finish_second_factor(&ctx, &user)!
redirect Auth.after_sign_in_path(&ctx), notice: "Signed in."
else
render :new, alert: "That code isn't right.", title: "Two-factor sign-in", status: 422
end
nil -> redirect new_session_path(), alert: "Sign in again."
end
end
```
```glacier
test "turns two-factor sign-in on, then asks for a code when signing in" do
freeze_time()
insert(User, email_address: "user@example.com")
post "/session", [email_address: "user@example.com", password: "password"]
secret = secret_on(&get("/two_factor/new").body)
assert post("/two_factor", [code: "wrong"]).status == :unprocessable_entity
res = post "/two_factor", [code: code_for(&secret)]
assert res.body =~ "Two-factor sign-in is on"
delete "/session"
get "/session/new"
res = post "/session", [email_address: "user@example.com", password: "password"]
assert res.location == "/two_factor_challenge/new"
# Not signed in yet.
assert get("/account").location == "/session/new"
get "/two_factor_challenge/new"
# The code that turned it on doesn't work twice.
assert post("/two_factor_challenge", [code: code_for(&secret)]).status == :unprocessable_entity
glacier::testing::travel(30.seconds())
res = post "/two_factor_challenge", [code: code_for(&secret)]
assert res.location == "/account"
assert get("/account").status == :ok
end
```
```glacier
test "signs in with a recovery code, once" do
user = insert(User, email_address: "user@example.com")
secret = glacier::auth::totp::new_secret()
codes = user.enable_two_factor(&secret, &code_for(&secret))!.unwrap()
post "/session", [email_address: "user@example.com", password: "password"]
get "/two_factor_challenge/new"
res = post "/two_factor_challenge", [code: codes[0].to_uppercase()]
assert res.status == :see_other
delete "/session"
get "/session/new"
post "/session", [email_address: "user@example.com", password: "password"]
get "/two_factor_challenge/new"
assert post("/two_factor_challenge", [code: &codes[0]]).status == :unprocessable_entity
end
```
A throttle in `src/app.glacier` allows 5 codes a minute for each user
signing in. The secret is an `encrypted` field (see
[Encrypted Fields](models.html#encrypted-fields)), so reading the `users`
table isn't enough to make codes.
### Signing In as Another User
Support staff often need to see what a user sees. The `accounts` app
lets staff (users with `staff` set, which no form can set) sign in as
another user and back, as Rails' `pretender` gem does. Starting is for
staff only, behind `sudo`, and never as another staff member:
```glacier
# Staff only, and after their password again; stopping needs neither.
scope "/", through: [:browser, :authenticated, :sudo, :authorized] do
post "/impersonation", ImpersonationsController.create, as: :impersonation
end
scope "/", through: [:browser, :authenticated, :authorized] do
delete "/impersonation", ImpersonationsController.destroy
end
```
```glacier
def create(ctx) do
staff = Auth.signed_in_user(&ctx)?
authorize staff.staff
user = User.find(params[:user_id])!
# Never another staff member.
authorize !user.staff
Auth.impersonate(&ctx, &user)?
redirect "/account", notice: "You're signed in as #{user.email_address}."
end
```
The session keeps who's really signed in, and `Auth` swaps in the user
being impersonated: `Auth.current_user`, `signed_in_user` and `access`
are all theirs, so every page and query acts as them. The `Authentication`
middleware asks for it on each request:
```glacier
# Signs a staff member in as `user`, until they stop: they see what the
# user sees, and act as them.
def impersonate(ctx: &Context, user: &User) do
ctx.session().insert("impersonating_id", user.id)?
end
def stop_impersonating(ctx: &Context) do
ctx.session().remove("impersonating_id")
end
# The user a signed-in staff member is impersonating, if any; noted for
# the request (`Impersonation`). Never another staff member.
def impersonated(ctx: &Context, staff: &User) -> Option<User> do
user_id = ctx.session().get::<i64>("impersonating_id").filter(fn _ -> staff.staff end)
found = if let Some(user_id) = user_id do
User.find_by(id: user_id)!.filter(fn user -> !user.staff end)
else
None
end
if let Some(user) = &found do
glacier::current::set(Impersonation { staff_id: staff.id, staff_email: staff.email_address.clone(), user_email: user.email_address.clone() })
end
found
end
```
`Auth.impersonated` notes who's really there in an `Impersonation`, a
plain struct, for the request:
```glacier
# A staff member signed in as another user: who's really there, for the
# page's banner and the logs. `Authentication` keeps it for the request.
struct Impersonation do
field staff_id: i64
field staff_email: String
field user_email: String
end
```
and the layout reads it through a helper to show a banner with a button
to stop, so staff never forget whose account they're in:
```glacier
test "lets staff sign in as another user, and stop" do
insert(User, email_address: "staff@example.com", staff: true)
user = insert(User, email_address: "user@example.com")
post "/session", [email_address: "staff@example.com", password: "password"]
get "/account"
res = post "/impersonation", [user_id: user.id]
assert res.location == "/account"
page = get "/account"
assert page.body =~ "Signed in as user@example.com."
assert page.body =~ "staff@example.com, you're signed in as user@example.com."
assert delete("/impersonation").location == "/account"
assert get("/account").body =~ "Signed in as staff@example.com."
end
```
Signing in or out ends an impersonation. `glacier generate
authentication --impersonation` writes all of it: the `staff` column,
`Impersonation`, `Auth.impersonate`, the middleware's check, the
controller and its routes, the layout's banner and the tests. Make
someone staff by setting their `staff` column; a staff member's form
posts `user_id` to `/impersonation`.
### A Name and Password: HTTP Basic Auth (new)
For pages only the people running the app see (a staging site, an ops
page, metrics), user accounts are more than needed. `BasicAuth` asks for
one name and password, which the browser prompts for, as Rails'
`http_basic_authenticate_with` does. The notes app puts an `/ops` page
behind it:
```glacier
# For the people running the app: a name and password, asked for by
# the browser (`[ops]` in the config, or OPS_PASSWORD).
pipeline ops = Pipeline.new().with(BasicAuth.from_config("ops")).with(NoIndex)
```
```glacier
scope "/ops", through: :ops do
get "/", AdminController.ops, as: :ops
end
```
`BasicAuth.from_config("ops")` reads `[ops]` in the config: `username`
(`ops` unless set) and `password`, or the `OPS_PASSWORD` environment
variable, as [secrets](calling_other_services.html#secrets) are read. With no
password set it refuses every request and logs an error, so a missing
setting never leaves the pages open. `BasicAuth.new("name",
"password")` takes them directly, and `.realm("Ops")` names the prompt.
A request without the right ones gets `401 Unauthorized` with a
`WWW-Authenticate` header, and the name and password are compared in
constant time. In tests, `glacier::testing::basic_auth(name, password)`
sends them with the requests that follow:
```glacier
test "asks for the ops password" do
res = get "/ops"
assert res.status == :unauthorized
assert res.header("www-authenticate") == Some("Basic realm=\"Application\", charset=\"UTF-8\"")
glacier::testing::basic_auth("ops", "wrong")
assert get("/ops").status == :unauthorized
insert(Note)
glacier::testing::basic_auth("ops", "test-ops-password")
res = get "/ops"
assert res.status == :ok
assert res.body == "{\"notes\":1}"
end
```
## Who a Request Is For: Access
An app says who each request is for with an *Access*, Glacier's name
for Phoenix's scope. It's a module that implements `Access`, with the
fields it needs and the columns it owns rows by. The `accounts` app's is
the signed-in user, who owns notes by their `user_id`:
```glacier
# Who a request is for: the signed-in user, who sees the notes they own.
# The `Authentication` middleware sets it.
module UserAccess do
implements Access
field user: User
owns :user_id
end
```
`glacier generate authentication` writes it, and the `Authentication`
middleware sets it for every signed-in request with
`ctx.set_access(UserAccess { user: user.clone() })` (see
[Who's Signed In](#who-s-signed-in)). A model that belongs to it says so
with `owned_by :user`, a `belongs_to` whose `user_id` comes from the
access and never from params. `tenant :user` and `belongs_to :user,
access: true` say the same:
```glacier
module Note do
implements Model
field title: String, required
field body: Option<String>
# The user's notes are theirs: `Note.for(access)` (see src/access/).
owned_by :user
end
```
The column is the association's, `user_id`, unless the model says
otherwise, as a model shared with an older app may: `owned_by :user,
foreign_key: :author_id` keeps the user's id in `author_id`, and
`owned_by :author, User` does the same with the association named
`author`. Either way the access is asked for its `user_id`.
In an action, `access` is the request's, as `params` is its params, and
`401 Unauthorized` when no one is signed in. `Note.for(access)` is the
notes it owns: a query like `Note.all()`, whose `find` answers `404 Not
Found` for anyone else's note, as for one that doesn't exist, and whose
`build(params)` gives a new note the access's `user_id`:
```glacier
module NotesController do
implements Controller
alias Auth
alias Policies.NotePolicy
alias Jobs.NoteDigest
def index(ctx) do
authorize NotePolicy.index(Auth.signed_in_user(&ctx)?)
# Only the notes this user's access owns.
notes = Note.for(access).order(inserted_at: :desc)!
render notes, title: "Notes"
end
def show(ctx) do
note = Note.for(access).find(params[:id])!
authorize NotePolicy.show(Auth.signed_in_user(&ctx)?, ¬e)
render note, title: "Note"
end
```
`glacier generate scaffold Note title:string --access user` writes a
whole resource this way: the model `owned_by :user`, a controller whose
every action reads through `Note.for(access)`, routes in a scope through
`authenticated`, and tests that sign in, including one checking that
another user's note is `404 Not Found` to `show`, `edit`, `update` and
`destroy`, and missing from `index`.
Once an app has an Access, `glacier generate scaffold` owns what it
writes without being asked, as Phoenix's generators use its scopes: the
org's records after `glacier generate access org`, otherwise the
signed-in user's after `glacier generate authentication`. Give
`--no-access` for records everyone shares, such as a list of countries.
An owned table gets an index on its owner column, which every query
filters on.
A request's trace says who it was for: its `glacier.request` span gets
a `glacier.access` field, the access's `describe()` (`user:12`, `org:42`).
An access that owns no such column sees nothing, never everything. The
Access and policies answer different questions: the Access which rows a
request sees, a policy what it may do with them.
### Staying in the Tenant
A record that's an org's can only point at the org's own records. When
a model that's `owned_by :org` also `belongs_to` another such model, such
as a task in a project, saving it checks the project is the same org's,
so a form that sends another org's `project_id` gets "isn't one of
yours" on `project_id`, on create and on update alike. A `unique` field of
an owned model is unique within its owner: two orgs may each have a
project called "Launch", but one org can't have two. Give its index the
owner column too, `add_index :projects, [:org_id, :name], unique: true`,
so the database agrees. `glacier generate scaffold Project
name:string:unique --access org` writes both.
### Rows Every Tenant Shares
Some rows belong to no tenant and every tenant should see them: the
checklists an app ships with, alongside each org's own. `global: true`
says so; a row whose owner column is NULL is then everyone's:
```glacier
module Checklist do
implements Model
field name: String
owned_by :org, global: true
end
```
`Checklist.for(access)` finds the org's own checklists and the shared
ones, never another org's. A shared row changes only from code that
reads past the access, in `unscoped "why" do` (or outside any request,
as seeds and tasks run): saving or deleting one under an org's access is
`403 Forbidden`, and `Checklist.for(access).delete_all()` leaves them
alone, so no tenant can change what every tenant sees. The owner column
must allow NULL (`org_id: Option<i64>`, `bigint :org_id` without `null:
false`).
### Reading Past the Access
In a test, reading a model that's `owned_by` someone without going
through `Model.for(access)`, during a request or job that has an access,
is an error naming the table (`notes read for user:12 without its
access`), so a test that requests an action finds one that forgot.
Outside tests it's logged as a warning. Loading a record's associations,
and saving, reloading or deleting a record already read, don't count.
Work that really is for no one in particular says so, and why, with an
`unscoped` block. The `accounts` app's account page counts everyone's
notes beside the user's own:
```glacier
# Every user's notes, counted past this user's access on purpose.
total = 0
unscoped "the app-wide note count" do
total = Note.count()!
end
```
```glacier
test "counts the user's notes, and everyone's" do
user = insert(User)
insert(Note, user_id: user.id)
insert(Note, user_id: insert(User).id)
get "/session/new"
post "/session", [email_address: &user.email_address, password: "password"]
assert get("/account").body =~ "You have 1 of the 2 notes written here."
end
```
A request or job without an access, such as a seed, a recurring job or
a page for signed-out visitors, reads as it always has.
### Access in Background Jobs
A job queued during a request runs later, in a worker, but for the same
access. The job keeps a reference to it (the user's and org's ids, never
the records), and the worker loads them again when the job runs, so in
its `perform`, `access` is the request's:
```glacier
# Adds a note counting the user's notes. Queued from a request, it runs
# later for the same user: `access` is the one it was queued for, loaded
# again when it runs.
module NoteDigest do
implements Job
def perform do
count = Note.for(access).count()!
digest = Note.for(access).build(title: "Digest", body: "You have #{count} notes.")
digest.save()!
end
end
```
```glacier
# Queues a digest of the user's notes, made in the background for them.
def digest(ctx) do
authorize NotePolicy.index(Auth.signed_in_user(&ctx)?)
NoteDigest {}.enqueue()!
redirect Note.collection_path(), notice: "Your digest is on its way."
end
```
```glacier
test "makes the digest in the background, for the user who asked" do
user = insert(User, email_verified_at: Timestamp.now().to_s())
get "/session/new"
post "/session", [email_address: &user.email_address, password: "password"]
insert(Note, title: "One", user_id: user.id)
insert(Note, title: "Two", user_id: user.id)
insert(Note, title: "Theirs", user_id: insert(User).id)
get "/notes"
res = post "/notes/digest"
assert res.status == :see_other
assert glacier::testing::perform_jobs()! == 1
digest = Note.where(title: "Digest").first()!.unwrap()
assert digest.user_id == user.id
assert digest.body == Some("You have 2 notes.".to_s())
end
```
Loading the access again checks it: a job queued by a member who has
left the org since, or for a user who's been deleted, fails rather than
acting for them. A job queued with no access, such as a recurring one,
has none, and `access` in it is `401 Unauthorized`.
A job can be queued for another access than the request's.
`enqueue_for(access)` runs it for the one given: an org a member is
working on from a page outside it (`enqueue_for(OrgAccess { … })`), or
another user the action acts for, such as the author of a post someone
replied to. `enqueue_unscoped()` runs it for no one:
```glacier
test "queued for another user, it runs for them" do
ada = insert(User)
insert(Note, title: "Ada's", user_id: ada.id)
NoteDigest {}.enqueue_for(UserAccess { user: ada.clone() })!
assert glacier::testing::perform_jobs()! == 1
digest = Note.where(title: "Digest").first()!.unwrap()
assert digest.user_id == ada.id
assert digest.body == Some("You have 1 notes.".to_s())
end
```
### Organisations
When users work together, the rows belong to an organisation rather than
to one user. `glacier generate access org` writes that, as code in the
app:
```console
$ glacier generate access org
create db/migrations/m20261007104328_create_orgs.glacier
create db/migrations/m20261007104329_create_memberships.glacier
create src/models/org.glacier
create src/models/membership.glacier
create src/access/org_access.glacier
create src/middleware/in_org.glacier
create src/controllers/orgs_controller.glacier
create src/views/orgs/index.html
create src/views/orgs/new.html
create src/views/orgs/show.html
create tests/controllers/orgs_controller.test.glacier
create tests/factories/org.glacier
create tests/factories/membership.glacier
update src/routes.glacier
```
`OrgAccess` is a member's access inside an org. It `extends UserAccess`,
so it holds the user's access too (`access.user` still works, and it
still owns the user's `user_id` rows), and it owns the rows whose
`org_id` is its org's:
```glacier
# Who a request inside an org is for: a member, in that org. The `InOrg`
# middleware sets it, for the org the request is in. A model that's the org's says
# `owned_by :org`, and an action reads its rows with `Post.for(access)`;
# `access.user` is still the signed-in user. Written by `glacier generate
# access org`; it's your code now.
module OrgAccess do
implements Access
extends UserAccess
field org: Org
field membership: Membership
owns :org_id
end
```
By default the org a user is working in is kept in the session, so URLs
stay as they are (`/projects/7`) and a user moves between their orgs by
choosing one at `/orgs`. The `InOrg` middleware makes the access from it,
only for the org's members, and sends anyone without one to choose:
```glacier
# Lets a request into the org's pages only when the signed-in user has
# chosen an org they're a member of (kept in the session; see
# `OrgsController.switch`), and makes its `access` an `OrgAccess`.
# Without one, the browser is sent to choose. The `in_org` pipeline in
# src/routes.glacier uses it, after `authenticated`.
module InOrg do
implements Middleware
alias Access.UserAccess
alias Access.OrgAccess
def call(ctx, next) do
user = UserAccess.of(ctx.access()?)?
org_id = session.get::<i64>("org_id") || 0
membership = Membership.where(org_id: org_id, user_id: user.user.id).preload(:org).first()!
if let Some(membership) = membership do
if let Some(org) = membership.org.get().cloned() do
ctx.set_access(OrgAccess { user_access: user, org: org, membership: membership })
return next(ctx)
end
end
redirect orgs_path()
end
end
```
It runs in its own pipeline, after `authenticated`, for the org's pages:
```glacier
# Only an org's members, in its routes; see src/middleware/in_org.glacier.
pipeline in_org = Pipeline.new().with(InOrg)
```
```glacier
# The current org's pages: `access` in them is its OrgAccess.
scope "/", through: [:browser, :authenticated, :in_org] do
get "/org", OrgsController.show, as: :org
get "/org/invitations/new", InvitationsController.new, as: :new_invitation
post "/org/invitations", InvitationsController.create, as: :invitations
delete "/org/invitations/:id", InvitationsController.destroy, as: :invitation_withdrawal
delete "/org/members/:id", MembershipsController.destroy, as: :membership
end
# An invitation's link, for whoever is signed in (signing in comes back).
scope "/", through: [:browser, :authenticated] do
get "/invitations/:token", InvitationsController.show, as: :invitation
post "/invitations/:token", InvitationsController.accept
end
```
Choosing an org checks the user is a member; anyone else's is `404 Not
Found`, as if it didn't exist:
```glacier
test "switches to an org the user belongs to" do
user = sign_in()
org = insert(Org, name: "Acme")
insert(Membership, org_id: org.id, user_id: user.id)
res = post "/orgs/#{org.id}/switch"
assert res.location == "/org"
assert get("/org").body =~ "Acme"
end
```
```glacier
test "another org can't be chosen" do
sign_in()
org = insert(Org, name: "Globex")
res = post "/orgs/#{org.id}/switch"
assert res.status == :not_found
assert get("/org").location == "/orgs"
end
```
In those pages, `access` is the org's: a model with `owned_by :org` read
through `Project.for(access)` shows only the org's rows, and
`OrgAccess.of(access)` gives the access as an `OrgAccess` (`404 Not
Found` outside an org). `glacier generate scaffold Project name:string
--access org` writes a resource of the org's, with tests that another
org's project is `404 Not Found` to every read and write.
An app can name the org in its URLs instead, so a link to one org's
page works for every member, whichever org they last chose:
- `--urls prefix` puts the org's id at the front of every path,
`/42/projects/7`, as Basecamp does. The app's `IdPrefix` middleware
takes the id off before routing, so the routes stay as they are
(`resources "/projects"`), `ctx.path_prefix()` is the id `InOrg` reads,
and every path the app builds during the request (`project_path(7)`,
`redirect project`) starts with `/42` again. A test enters the org with
`glacier::testing::path_prefix("/#{org.id}")`.
- `--urls nested` puts the org's pages under `/orgs/:org_id/…`, and the
middleware reads the org from the path. It suits a JSON API, which has
no session, and is its default. A record's path fills `:org_id` from
its own `org_id` (`project.to_path()` is `/orgs/42/projects/7`), and
`Project.collection_path()` from the request's.
`glacier generate scaffold … --access org` works with each of them.
### Inviting Members
An org's members have a role: `owner` for whoever made it, `member` for
everyone who joins. Owners invite people by email address; the
invitation is mailed with a link that works for a week, once. The
`OrgPolicy` decides who may invite or remove members, from the
signed-in user's role in the org. `glacier generate access org` writes
all of this for an app with pages, whichever `--urls` it uses: the
invitation pages sit under the org's own (`/org/invitations`,
`/42/org/invitations` or `/orgs/42/invitations`), and the link in the
mail is `/invitations/:token` for all three. A JSON API gets none yet.
```glacier
# Inviting and removing members is for the org's owners.
def manage_members(access: &OrgAccess) -> Decision do
if access.membership.role == "owner" do
allow
else
deny "Only the org's owners can do that."
end
end
```
```glacier
def create do
org_access = OrgAccess.of(access)?
authorize OrgPolicy.manage_members(&org_access)
email_address = User.normalize_email(¶ms[:email_address])
invitation = Invitation.for(access).build(email_address: email_address, role: "member")
if invitation.save()! do
link = url_for(invitation_path(&invitation.token()))?
InvitationsMailer.invite(&invitation.email_address, &org_access.org.name, &link).deliver_now()!
redirect org_path(), notice: "Invited #{invitation.email_address}."
else
render :new, org_id: org_access.org.id, alert: "Enter an email address.", title: "Invite someone", status: 422
end
end
```
The link takes whoever follows it, signed in (signing in comes back to
it), to a page to accept. Whoever has the link may join, as the mail
went to the invited address; but when they're signed in as another
address, the page says so first ("This invitation was sent to
new@example.com, but you're signed in as other@example.com") and the
button reads "Accept as other@example.com". Accepting makes them a member
with the invitation's role, and the org their current one; the
invitation is then gone, so the link works once:
```glacier
# The link's token: good for a week, and only for this invitation to this
# address. Accepting deletes the invitation, so a link works once.
def token(self) -> String do
glacier::auth::token_for("invitation", self.id, &self.email_address, 7.days())
end
# The invitation a link's token is for, while it's still good. Read
# before anyone is in the org, so past the access, on purpose.
def find_by_token(token: &str) -> Option<Invitation> do
found = None
if let Some(signed) = glacier::auth::read_token("invitation", token) do
unscoped "an invitation, by its link" do
found = Invitation.find_by(id: signed.id)!.filter(fn i -> signed.matches(&i.email_address) end)
end
end
found
end
```
```glacier
# Makes the signed-in user a member, with the invitation's role, and
# takes them to the org.
def accept(ctx) do
skip_authorization
user = Auth.signed_in_user(&ctx)?
case Invitation.find_by_token(¶ms[:token])! do
Some(invitation) ->
joined = Membership.where(org_id: invitation.org_id, user_id: user.id).first()!
if joined.is_none() do
Membership.new(org_id: invitation.org_id, user_id: user.id, role: invitation.role.clone()).save()!
end
org_id = invitation.org_id
unscoped "accepting an invitation, before joining its org" do
invitation.destroy()!
end
session.insert("org_id", org_id)?
redirect org_path(), notice: "Welcome!"
nil -> redirect "/", alert: "That invitation is invalid or has expired."
end
end
```
```glacier
test "an owner invites someone, who joins as a member" do
owner = insert(User, email_address: "owner@example.com")
sign_in_as(&owner)
get "/orgs/new"
post "/orgs", [name: "Acme"]
get "/org/invitations/new"
res = post "/org/invitations", [email_address: "New@Example.com"]
assert res.location == "/org"
assert glacier::testing::delivered_mail().last().unwrap().to == ["new@example.com"]
link = mailed_link()
delete "/session"
invited = insert(User, email_address: "new@example.com")
sign_in_as(&invited)
assert get(&link).body =~ "Join Acme"
res = post &link
assert res.location == "/org"
membership = Membership.where(user_id: invited.id).first()!.unwrap()
assert membership.role == "member"
assert get("/org").body =~ "owner@example.com"
# Used once.
assert get(&link).location == "/"
end
```
```glacier
test "a member can't invite or remove anyone" do
owner = insert(User, email_address: "owner@example.com")
member = insert(User, email_address: "member@example.com")
org = insert(Org, name: "Acme")
insert(Membership, org_id: org.id, user_id: owner.id, role: "owner")
owners_membership = Membership.where(user_id: owner.id).first()!.unwrap()
insert(Membership, org_id: org.id, user_id: member.id, role: "member")
sign_in_as(&member)
post "/orgs/#{org.id}/switch"
res = get "/org/invitations/new"
assert res.status == :forbidden
assert res.body =~ "Only the org's owners can do that."
get "/org"
assert delete("/org/members/#{owners_membership.id}").status == :forbidden
end
```
An `Invitation` is `owned_by :org`, so an org's page lists only its own,
through `Invitation.for(access)`. Finding one by its link happens before
the person is in the org, so it reads past the access, in an
`unscoped "why" do` block that says so. Owners remove members from the
org's page, though not themselves, so an org keeps an owner. `glacier
generate access org` writes all of this for an org kept in the session
(the default); with `--urls prefix` or `--urls nested` it doesn't yet.
## Authorisation
Signing in says who someone is; authorisation says what they may do.
The rules are your app's own code, and `authorize` checks them in an
action.
### Policies
By convention, the rules for a model go in a policy, one per model. The
generator writes one, once the app has a `User` model (it refuses to run
without `src/models/user.glacier` or `user.rs`, which `glacier generate
authentication` writes):
```sh
$ glacier generate policy Note
create src/policies/note_policy.glacier
Next: decide each rule in src/policies/note_policy.glacier, then `authorize NotePolicy.show(user, note)`
in the actions. Add `VerifyAuthorized` to a pipeline to catch actions that forget.
```
A policy is a module in `src/policies/` (see [The .glacier
Language](glacier_language.html)) with a rule for each of `index`,
`show`, `create`, `update` and `destroy` (`new` and `edit` use `create` and
`update`). Each takes the user, and the
rules for one record take the record too. The generator writes every rule
as `false`, so nothing is allowed until you decide, with its arguments
named `_user` and `_note` so that the stubs build without warnings. In
the `accounts` app, any signed-in user may list and write notes, and only
a note's own user may see, change or delete it:
```glacier
# Who may do what with notes: any signed-in user may list and write
# notes, and only a note's own user may see, change or delete it.
module NotePolicy do
# Staff may do anything with a note; for everyone else, the rules
# below decide.
def before(user: &User) -> Option<bool> do
if user.staff do
Some(true)
else
nil
end
end
# Whether the user may list them.
def index(_user: &User) -> bool do
true
end
# Whether the user may see one: someone else's is "not found", as if it
# didn't exist, so its id gives nothing away.
def show(user: &User, note: &Note) -> Decision do
if note.user_id == user.id do
allow
else
deny :not_found
end
end
# Whether the user may make one.
def create(_user: &User) -> bool do
true
end
# Whether the user may change this one.
def update(user: &User, note: &Note) -> bool do
note.user_id == user.id
end
# Whether the user may delete this one.
def destroy(user: &User, note: &Note) -> bool do
note.user_id == user.id
end
end
```
A rule is an ordinary function, so a test can call it directly:
```glacier
test "lets only a note's own user change it" do
user = insert(User)
other = insert(User)
note = insert(Note, user_id: user.id)
assert NotePolicy.update(&user, ¬e)
refute NotePolicy.update(&other, ¬e)
refute NotePolicy.destroy(&other, ¬e)
end
```
### Checking With `authorize`
`authorize` in an action answers `403 Forbidden` unless the rule allows
it. A controller brings the policy in with `alias Policies.NotePolicy`:
```glacier
module NotesController do
implements Controller
alias Auth
alias Policies.NotePolicy
alias Jobs.NoteDigest
def index(ctx) do
authorize NotePolicy.index(Auth.signed_in_user(&ctx)?)
# Only the notes this user's access owns.
notes = Note.for(access).order(inserted_at: :desc)!
render notes, title: "Notes"
end
def show(ctx) do
note = Note.for(access).find(params[:id])!
authorize NotePolicy.show(Auth.signed_in_user(&ctx)?, ¬e)
render note, title: "Note"
end
```
`authorize` takes any `bool` expression (or a `Decision`, below), so a policy is a convention
rather than a requirement: a method on the model, or a check written in
place, works the same way. It compiles to a call to `glacier::authorize`,
which returns an error with status 403 when the expression is false, and
`?` returns it from the action. In a controller written in Rust, call
`glacier::authorize(allowed)?` yourself.
Lists need their own filter, as `index` does with
`Note.for(access)`, because `authorize` checks one thing at a time:
`NotePolicy.index(user)` says whether the user may list notes at all, not
which notes they see.
Every action here reads its note through `Note.for(access)` too, so
someone else's note is `404 Not Found` before the policy is asked, as
though it didn't exist. The policy is for what a user may do with the
rows they can see, which matters once rules differ by role:
```glacier
describe "someone else's note" do
setup do
sign_in()
end
test "can't be seen" do
other = insert(User)
note = insert(Note, user_id: other.id)
# Not the user's: as missing as a note that doesn't exist.
assert get(note).status == :not_found
end
test "can't be deleted" do
other = insert(User)
note = insert(Note, user_id: other.id)
assert delete(note).status == :not_found
assert Note.count()! == 1
end
end
```
### Denying With a Status
A rule that returns `false` makes `authorize` answer `403 Forbidden`. A
rule can say more by returning a `Decision`: `allow`, or `deny` with a
status, a message or both (`deny :not_found`, `deny "Archived notes
can't change"`, `deny :conflict, "…"`). `authorize` answers with them.
`NotePolicy.show` answers someone else's note as not found, as if it
didn't exist, so a note's id tells a stranger nothing:
```glacier
# Whether the user may see one: someone else's is "not found", as if it
# didn't exist, so its id gives nothing away.
def show(user: &User, note: &Note) -> Decision do
if note.user_id == user.id do
allow
else
deny :not_found
end
end
```
```glacier
test "answers someone else's note as not found" do
user = insert(User)
other = insert(User)
note = insert(Note, user_id: user.id)
assert NotePolicy.show(&user, ¬e).is_allowed()
denied = glacier::authorize(NotePolicy.show(&other, ¬e)).unwrap_err()
assert denied.status() == StatusCode.NOT_FOUND
end
```
In a page, `NotePolicy.show(user, note).is_allowed()` asks a rule that
returns a `Decision` the same question a `bool` rule answers directly.
### Exceptions for Everyone: `before` (new)
A policy's `def before(user: &User) -> Option<bool>` is asked before each
of its rules that takes the same user first, as Pundit's and Laravel's
`before` are: `Some(true)` allows, `Some(false)` denies, and `nil`
leaves it to the rule. Staff may do anything with any note:
```glacier
# Staff may do anything with a note; for everyone else, the rules
# below decide.
def before(user: &User) -> Option<bool> do
if user.staff do
Some(true)
else
nil
end
end
```
```glacier
test "lets staff do anything with any note" do
user = insert(User)
staff = insert(User, staff: true)
note = insert(Note, user_id: user.id)
assert NotePolicy.update(&staff, ¬e)
assert NotePolicy.destroy(&staff, ¬e)
assert NotePolicy.show(&staff, ¬e).is_allowed()
end
```
A rule that isn't about one model (Laravel's gates: "may see the admin
area") is a function too, in a module of its own, `authorize
Gates.view_admin(user)`.
### Catching a Missing `authorize`
An action that never calls `authorize` is open to every user who can reach
it. `glacier::middleware::VerifyAuthorized` catches that: on a pipeline,
it checks each request's action once it has answered. The `accounts` app
adds a pipeline for it to the notes routes:
```glacier
# Every action here must `authorize` (or `skip_authorization`).
pipeline authorized = Pipeline.new().with(VerifyAuthorized)
```
```glacier
scope "/", through: [:browser, :authenticated, :authorized] do
resources "/notes", NotesController
end
```
When the action answers with a status below 400 and neither `authorize`
nor `skip_authorization` was called while handling the request,
`VerifyAuthorized` logs an error naming the method and path. Outside
production it also replaces the response with `500 Internal Server
Error`; in production the response goes out unchanged. Which one applies
is set by the `GLACIER_ENV` variable, and an app without it counts as
development, so a production server must set `GLACIER_ENV=production`. An error response
(`4xx` or `5xx`) passes through, so a note that isn't found is still a
`404`. Routes outside the pipeline aren't checked.
Removing the `authorize` line from `destroy` makes its test fail:
```text
---- controllers::notes_controller::deletes_a_note stdout ----
thread 'controllers::notes_controller::deletes_a_note' (12897264) panicked at tests/controllers/notes_controller.test.glacier:117:3:
assertion failed: res.status == :see_other
left: :internal_server_error (500)
right: "see_other"
```
WARNING: `VerifyAuthorized` finds a missing `authorize` in development
and in tests; it doesn't protect anything in production. It checks only
after the action has run, so whatever the action did, such as deleting
someone else's note here, has already happened by the time the response
is replaced, and in production the response isn't replaced at all. It
checks only that `authorize` was called, allowed or not, not that the
rule or the record was the right one, and it doesn't check that a list is
filtered. It's a net for tests that request every action, not a
replacement for `authorize`.
`skip_authorization`, on a line of its own in an action
(`glacier::skip_authorization()` in Rust), marks an action as having
nothing to authorize. Every signed-in user may see their own account
page, so the `accounts` app's `Accounts.show` skips:
```glacier
module AccountsController do
implements Controller
alias Auth
# Every signed-in user may see their own account, so there's nothing to
# authorize.
def show(ctx) do
skip_authorization
user = Auth.signed_in_user(&ctx)?
email_address = user.email_address.clone()
two_factor = user.two_factor()
mine = Note.for(access).count()!
# Every user's notes, counted past this user's access on purpose.
total = 0
unscoped "the app-wide note count" do
total = Note.count()!
end
render email_address: email_address, two_factor: two_factor, mine: mine, total: total, title: "Account"
end
end
```
```glacier
test "shows the signed-in user their account" do
user = insert(User)
get "/session/new"
post "/session", [email_address: &user.email_address, password: "password"]
res = get "/account"
assert res.status == :ok
assert res.body =~ format!("Signed in as {}.", user.email_address)
end
test "counts the user's notes, and everyone's" do
user = insert(User)
insert(Note, user_id: user.id)
insert(Note, user_id: insert(User).id)
get "/session/new"
post "/session", [email_address: &user.email_address, password: "password"]
assert get("/account").body =~ "You have 1 of the 2 notes written here."
end
```
Pipelines run in the order a scope lists them, so `authorized` comes after
`authenticated`. The other way round, the redirect that sends a
signed-out visitor to sign in is checked too: it isn't an error response
and no action authorized it, so outside production it becomes a `500`,
and in production each one logs an error.
## Throttling Sign-In Attempts (updated)
`glacier::middleware::Throttle` limits how often something can happen.
`glacier generate authentication` adds the two throttles below to
`src/app.glacier`, with their tests. It
takes a name
(for the log), a limit, a period, and a function that picks the key to
count a request under, or `None` to leave the request alone. The
`accounts` app counts sign-in attempts per email address, picked by a
function of the app's own:
```glacier
# At most 5 sign-in attempts a minute for each email address.
middleware Throttle.new("sign-in", 5, Duration.from_secs(60), sign_in_email)
```
```glacier
# The address someone is signing in with, when they are.
def sign_in_email(ctx: &Context) -> Option<String> do
signing_in = *ctx.method() == Method.POST and ctx.path() == "/session"
email = ctx.params().get("email_address")?.trim().to_lowercase()
signing_in.then_some(email)
end
```
Over the limit, the answer is `429 Too Many Requests` with the body
`Retry later`, and a `Retry-After` header giving the seconds until the
period's window ends:
```glacier
test "allows 5 sign-in attempts a minute for each email address" do
# One minute's window throughout, however long the test takes.
freeze_time()
insert(User, email_address: "user@example.com")
for _ in 0..5 do
res = post "/session", [email_address: "user@example.com", password: "wrong"]
assert res.status == :unprocessable_entity
end
res = post "/session", [email_address: "user@example.com", password: "password"]
assert res.status == :too_many_requests
res = post "/session", [email_address: "other@example.com", password: "password"]
assert res.status == :unprocessable_entity
end
```
### Mailed Links
Every password reset, sign-in link and email confirmation sends a mail,
so a form that sends one can be used to fill someone's inbox. The
`accounts` app allows 3 every 15 minutes for each address, counted
together:
```glacier
# At most 3 mailed links (password resets, sign-in links and email
# confirmations) every 15 minutes for each address, so nobody can fill
# someone's inbox.
middleware Throttle.new("mailed-links", 3, Duration.from_secs(15 * 60), mailed_to)
```
```glacier
# The address a request would mail a link to, when it would: the
# signed-in user's own, or the one in the form.
def mailed_to(ctx: &Context) -> Option<String> do
mails = *ctx.method() == Method.POST and ["/passwords", "/sign_in_links", "/email_verification", "/email_change"].contains(&ctx.path())
signed_in = Auth.current_user(ctx).map(fn user -> user.email_address.clone() end)
email = signed_in.or_else(fn -> ctx.params().get("email_address").map(str::to_owned) end)?
mails.then_some(email.trim().to_lowercase())
end
```
```glacier
test "mails at most 3 links every 15 minutes to an address" do
freeze_time()
insert(User, email_address: "user@example.com")
get "/passwords/new"
for _ in 0..3 do
assert post("/passwords", [email_address: "user@example.com"]).status == :see_other
end
assert post("/passwords", [email_address: "user@example.com"]).status == :too_many_requests
# Asking for a sign-in link counts too.
assert post("/sign_in_links", [email_address: "user@example.com"]).status == :too_many_requests
assert post("/passwords", [email_address: "other@example.com"]).status == :see_other
end
```
The address is the signed-in user's own when there is one, so resending
a confirmation counts too, and the one in the form otherwise. The
throttle counts the request whether or not the address belongs to a
user, so its answer doesn't tell anyone which addresses are signed up.
In an `app do` block, the `middleware` lines come before the functions
they name.
Some details to know:
- Requests are counted in fixed windows of the period (each minute, here),
not a sliding one, so up to twice the limit can get through around the
boundary between two windows.
- Counts are kept in the cache (see [Caching](caching.html#counters)).
With the default memory store, each process counts separately and a
restart starts afresh; with `[cache] store = "database"`, every server
shares the count. If the cache can't be reached, the request goes
through and the error is logged.
- The period must be at least a second, and is counted in whole seconds.
- Registered on the app with `.middleware(…)`, as here, a throttle runs
before routing, for every request. It can go in a pipeline instead,
with `Pipeline::new().with(Throttle::new(…))`, to cover only that
pipeline's routes.
### Rate Limits in Code (new)
A throttle answers before the action runs. To limit something inside an
action, or a job, use `RateLimit`, which the throttles count with:
```glacier
test "limits attempts per key" do
key = "export:ada"
assert RateLimit.attempt(key, 2, 1.minutes())!
assert RateLimit.remaining(key, 2, 1.minutes())! == 1
assert RateLimit.attempt(key, 2, 1.minutes())!
refute RateLimit.attempt(key, 2, 1.minutes())!
assert RateLimit.too_many(key, 2, 1.minutes())!
# Another key counts on its own; `clear` forgets a key's attempts.
assert RateLimit.attempt("export:bo", 2, 1.minutes())!
RateLimit.clear(key, 1.minutes())!
assert RateLimit.attempt(key, 2, 1.minutes())!
end
```
`RateLimit.attempt(key, limit, period)!` counts an attempt and says
whether it's within the limit. `remaining` and `too_many` look without
counting, `clear` forgets a key's attempts (after a successful sign-in,
say), and `RateLimit.available_in(key, period)` is how long until the
window ends, for a `Retry-After` header of your own. Keys are any text;
put what's being limited and whose it is in them (`"export:#{user.id}"`).
### Client IP Addresses
To throttle by client instead, use `ctx.remote_ip()`. It's the address of the connection, unless that
is a trusted proxy, in which case it's the nearest address in the
`X-Forwarded-For` header that isn't one (or the first address in it, if
they all are; or the connection's address, if the header is missing). Trusted proxies are loopback and
private network addresses (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`,
`192.168.0.0/16`, `::1` and `fc00::/7`), and any in the config's
`trusted_proxies`, addresses or ranges, for a load balancer or CDN with
public addresses:
```toml
trusted_proxies = ["203.0.113.0/24", "2001:db8::/32"] # GLACIER__TRUSTED_PROXIES
```
Only list proxies of your own: a request from one of them can say it's
from any address.
`remote_ip()` returns `None` when there's no connection, which is the case
in tests, so a throttle keyed on it counts nothing in tests.
## Authenticating an API
`glacier generate authentication --api` writes the same models, with
controllers that speak JSON and clients that send their token in an
`Authorization: Bearer` header instead of a cookie. In an app made with
`glacier new --api`, which has no pages, plain `glacier generate
authentication` writes this flavour too; `--api` is for an app that also
has pages, as the `api` app does:
```sh
$ glacier generate authentication --api
create db/migrations/m20261004100423_create_users.glacier
create db/migrations/m20261004100424_create_sessions.glacier
create src/models/user.glacier
create src/models/session.glacier
create src/auth.glacier
create src/access/user_access.glacier
create src/middleware/authentication.glacier
create src/middleware/require_authentication.glacier
create src/controllers/sessions_controller.glacier
create src/controllers/registrations_controller.glacier
create tests/factories/user.glacier
create tests/models/user.test.glacier
create tests/controllers/sessions_controller.test.glacier
create tests/controllers/registrations_controller.test.glacier
update src/app.glacier
update src/routes.glacier
Next: glacier db migrate. Routes for signed-in users go in a scope
through the `authenticated` pipeline:
scope "/", through: [:api, :authenticated] do
get "/account", AccountsController.show, as: :account
end
and `Auth.current_user(&ctx)` (with `alias Auth`) is who's signed in.
```
The `User` and `Session` models are the same. Signing in returns the
token, with `201 Created`; signing out answers `204 No Content`:
```glacier
module SessionsController do
implements Controller
alias Auth
# Signs in, returning the token to send as `Authorization: Bearer <token>`.
def create(ctx) do
email_address = params.get("email_address").unwrap_or_default()
password = params.get("password").unwrap_or_default()
case User.authenticate_by(email_address, password)! do
Some(user) ->
token = Auth.start_session(&ctx, &user)!
json [token: token], status: :created
None ->
json [error: "Try another email address or password."], status: :unauthorized
end
end
def destroy(ctx) do
Auth.end_session(&ctx)!
head :no_content
end
end
```
```glacier
module RegistrationsController do
implements Controller
alias Auth
# Signs up and signs in, returning the token as `Sessions` does.
def create(ctx) do
user = User.build(params)
if user.save()! do
token = Auth.start_session(&ctx, &user)!
json [token: token], status: :created
else
json user.errors_json(), status: :unprocessable_entity
end
end
end
```
`Authentication` reads only the bearer token, and `RequireAuthentication`
always answers `401 Unauthorized`:
```glacier
# Finds each request's session from its bearer token, so every request
# knows who's signed in (`Auth.current_user(ctx)`). It's in the app's
# settings, src/app.glacier.
module Authentication do
implements Middleware
alias Access.UserAccess
def call(ctx, next) do
if let Some(token) = glacier::auth::bearer_token(&ctx).map(str::to_owned) do
if let Some(signed_in) = Session.find_by_token(&token)! do
if let Some(user) = signed_in.user.get().cloned() do
ctx.set_access(UserAccess { user: user.clone() })
ctx.extensions_mut().insert(user)
ctx.extensions_mut().insert(signed_in)
end
end
end
next(ctx)
end
end
```
```glacier
# Lets only signed-in clients through; others get `401 Unauthorized`. The
# `authenticated` pipeline in src/routes.glacier uses it.
module RequireAuthentication do
implements Middleware
def call(ctx, next) do
if ctx.extensions().get::<User>().is_none() do
raise Error.new(StatusCode.UNAUTHORIZED, "sign in first")
end
next(ctx)
end
end
```
The routes go through an `api` pipeline, which the generator adds if the
app doesn't have one: it renders errors as JSON and has no CSRF check,
because API clients send a token rather than a cookie.
In tests, `glacier::testing::bearer(token)` sends the token with every
later request, as a client that has signed in would:
```glacier
test "signs in, returning a token" do
insert(User, email_address: "user@example.com")
res = post "/session", [email_address: "user@example.com", password: "password"]
assert res.status == :created
assert res.body =~ "\"token\":"
assert Session.count()! == 1
end
test "rejects a wrong password" do
insert(User, email_address: "user@example.com")
res = post "/session", [email_address: "user@example.com", password: "wrong"]
assert res.status == :unauthorized
assert Session.count()! == 0
end
test "signs out the session whose token is sent" do
user = insert(User, email_address: "user@example.com")
token = Session.start(user.id, None)!
glacier::testing::bearer(&token)
res = delete "/session"
assert res.status == :no_content
assert Session.count()! == 0
end
```
A page on another origin that calls the API from a browser needs the API
to allow that origin, with `glacier::middleware::Cors` (see
[Cross-Origin Requests](json_apis.html#cross-origin-requests)). A bearer
token isn't a cookie, so the browser sends it only when the page's script
adds it, and the API doesn't need CORS credentials for it.
### API Tokens With Scopes
Signing in gives a token that can do anything the user can, for as long
as a session lasts. A script needs something narrower: a token the user
makes for it, which does only what its scopes allow, until it expires or
the user revokes it, as GitHub's fine-grained tokens do. The `accounts`
app has them, as an `ApiToken` model. Only the token's digest is kept,
and the `glc_` prefix lets people (and secret scanners) tell what a
leaked one is:
```glacier
# Whether these are scopes a token may have: one or more of `SCOPES`.
def valid_scopes(scopes: &Array<String>) -> bool do
!scopes.is_empty() and scopes.iter().all(fn s -> ApiToken.SCOPES.contains(&s.as_str()) end)
end
# A new token for the user, returned once: only its digest is kept. The
# `glc_` prefix lets anyone (and secret scanners) see what it is.
def issue(user_id: i64, name: &str, scopes: Array<String>, expires_in_days: Option<i64>) -> String do
if !ApiToken.valid_scopes(&scopes) do
raise Error.unprocessable("choose one or more scopes")
end
token = "glc_#{glacier::auth::new_token()}"
expires_at = expires_in_days.map(fn days -> days.days().from_now() end)
api_token = ApiToken.new(user_id: user_id, name: name, scopes: scopes.join(" "), token_digest: glacier::auth::token_digest(&token), expires_at: expires_at)
api_token.save()!
token
end
```
```glacier
# The token, and its user, unless it's expired; marks it used, at most
# once an hour.
def find_by_token(token: &str) -> Option<ApiToken> do
found = ApiToken.where(token_digest: glacier::auth::token_digest(token))
.preload(:user)
.first()!
.filter(fn t -> t.expires_at.is_none_or(fn at -> at > Timestamp.now() end) end)
if let Some(api_token) = &found do
if api_token.last_used_at.is_none_or(fn at -> at < 1.hours().ago() end) do
# A copy: saving reloads the record, which would drop its user.
api_token.clone().update(last_used_at: Some(Timestamp.now()))!
end
end
found
end
```
The `Authentication` middleware takes a `glc_` bearer token as its user,
and limits the request to the token's scopes with
`glacier::auth::limit_to_scopes`:
```glacier
if token.starts_with("glc_") do
# An API token: its user, limited to what its scopes allow.
if let Some(api_token) = ApiToken.find_by_token(&token)! do
if let Some(user) = api_token.user.get().cloned() do
glacier::auth::limit_to_scopes(&mut ctx, api_token.scope_list())
ctx.set_access(UserAccess { user: user.clone() })
ctx.extensions_mut().insert(user)
end
end
```
An action asks for the scope it needs with `glacier::auth::require_scope`,
which answers `403 Forbidden` for a token without it, and lets any other
request (a browser's session) through. `"notes:*"` grants every `notes:`
scope:
```glacier
def index(ctx) do
glacier::auth::require_scope(&ctx, "notes:read")?
json Note.for(access).order(id: :asc)!
end
def create(ctx) do
glacier::auth::require_scope(&ctx, "notes:write")?
note = Note.for(access).build(params)
if note.save()! do
json note, status: :created
else
json note.errors_json(), status: :unprocessable_entity
end
end
```
```glacier
test "a token reads what its scopes allow, and no more" do
user = insert(User, email_address: "user@example.com")
insert(Note, user_id: user.id, title: "Mine")
token = ApiToken.issue(user.id, "backup script", vec!["notes:read".to_s()], None)!
assert token.starts_with("glc_")
glacier::testing::bearer(&token)
res = get "/api/notes"
assert res.status == :ok
assert res.body =~ "\"title\":\"Mine\""
res = post_json "/api/notes", "{\"title\": \"From a script\"}"
assert res.status == :forbidden
assert Note.count()! == 1
end
```
Users make, see and revoke their tokens at `/api_tokens`, behind `sudo`;
the token is shown once, when it's made. `glacier generate
authentication --api-tokens` writes them: the `ApiToken` model (with
`read` and `write` scopes to change for your own), the middleware's
check, the pages and routes, and tests. It's for apps with pages, where
users make their tokens; both flags can be given together.
## Existing Rails Apps
### Devise and `has_secure_password` Passwords
Devise and `has_secure_password` store bcrypt hashes (`$2a$…`).
`glacier::auth::verify_password` recognises them and checks them as
bcrypt, so the generated `User.authenticate_by` signs in users whose
passwords a Rails app hashed, without changes. New and changed passwords
are hashed with Argon2id, and so is a bcrypt password the first time its
user signs in: `authenticate_by` replaces the hash once the password is
known (`glacier::auth::needs_rehash` says which do), without ending the
user's sessions. The hash in this test was made by Ruby's `bcrypt` gem:
```glacier
test "signs in a user whose password Devise hashed with bcrypt" do
# Ruby's BCrypt::Password.create("secret", cost: 4), as Devise and
# has_secure_password store it.
digest = "$2a$04$KJY25RprEuJvbxVtp0EL6OAwC3ZAHpva.goKBL6bcCq6/HLxIEkUu"
user = User.new(email_address: "devise@example.com", password_digest: digest)
assert user.save()!
assert User.authenticate_by("devise@example.com", "wrong")!.is_none()
assert User.authenticate_by("devise@example.com", "secret")!.is_some()
# Signing in replaced the bcrypt hash with an Argon2 one.
user = User.find_by(email_address: "devise@example.com")!.unwrap()
assert user.password_digest.starts_with("$argon2id$")
assert User.authenticate_by("devise@example.com", "secret")!.is_some()
end
```
WARNING: Checking a bcrypt hash takes as long as its cost makes it, and
Devise's default cost of 12 is much slower than Glacier's Argon2 check. So
until each user has signed in once, response times can show which email
addresses still have bcrypt hashes.
To use an existing `users` table, the `User` model's fields must match
its columns: Devise's column is `encrypted_password` and its email column
is `email`, where the generator uses `password_digest` and
`email_address`.
### JSON Web Tokens
`glacier::auth::jwt` signs and checks JSON Web Tokens signed with
HMAC-SHA256 (`HS256`), as Rails apps using Ruby's `jwt` gem issue them,
so a Glacier app can accept tokens a Rails app issued, and the other way
round:
- `jwt::encode(&claims, secret)` returns a token for the claims (a JSON
object), with the header `{"alg":"HS256"}` and the claims' keys in
order. The token in this test is byte for byte the one Ruby's `jwt` gem
makes for the same claims and secret.
- `jwt::decode(token, secret)` checks the signature, in constant time, and
the `exp` claim if there is one, and returns the claims. `exp` must be a
whole number of seconds (anything else is malformed), and a token is
expired from that second on, with no allowance for clock skew.
```glacier
test "reads a token signed by the Rails app's jwt gem" do
# Ruby: JWT.encode({user_id: 1, email: "a@b.c", exp: 4102444800}, "s", "HS256")
token = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJlbWFpbCI6ImFAYi5jIiwiZXhwIjo0MTAyNDQ0ODAwfQ.6DVTKEZh7vTr7ohSSxZEV8iyMg6REQCYy7-nCjR3oVk"
claims = glacier::auth::jwt::decode(token, b"s").unwrap()
assert claims["user_id"] == 1
assert claims["email"] == "a@b.c"
assert glacier::auth::jwt::encode(&claims, b"s") == token
assert glacier::auth::jwt::decode(token, b"another secret").is_err()
end
```
`decode` fails with a `JwtError`: `Malformed`, `Algorithm` (the header
isn't `HS256`), `Signature` or `Expired`. It checks nothing else: not
`nbf`, `iat`, `iss` or `aud`, so check any of those your app relies on in
the claims it returns. Other algorithms, such as RS256, aren't supported.
### Encrypted Columns
Columns the Rails app encrypted with `encrypts` read in Glacier as
`encrypted` fields (see [Encrypted Fields](models.html#encrypted-fields)),
given the Rails app's keys, from its `active_record_encryption`
credentials:
```toml
[encryption.rails]
primary_key = "…" # or a list, as Rails allows
deterministic_key = "…"
key_derivation_salt = "…"
```
```glacier
test "reads a column the Rails app encrypted" do
# With the Rails app's `active_record_encryption` credentials in this
# app's config as `[encryption.rails]` (config/test.toml here).
# What `encrypts :otp_secret` stored, from ActiveRecord 8.1.
stored = "{\"p\":\"DkXCjHIod9nRdCl4lbQ9hQ==\",\"h\":{\"iv\":\"CpMuY9YwDEA4YvGC\",\"at\":\"q675ALYa604oZ/HqJnSifg==\"}}"
assert glacier::encryption::decrypt(stored).unwrap() == "JBSWY3DPEHPK3PXP"
end
```
Both of Rails' key derivations (SHA-1, its default before 7.1, and
SHA-256) and its compressed values read. Each value is encrypted the
Glacier way the next time its record is saved with it changed, so the
Rails keys can go once every row has been rewritten. Laravel's `Crypt`
payloads read the same way, with `[encryption.laravel] key` set to the
Laravel app's `APP_KEY` (and `previous_keys` to its
`APP_PREVIOUS_KEYS`).
## What Isn't Built Yet
Many apps need more than the generator writes. Glacier doesn't have
these yet:
- "Remember me".
- A check that an action filtered a list to what the user may see;
`VerifyAuthorized` checks only that `authorize` was called. Lists read
through `Model.for(access)` are filtered by the Access, and tests fail
on an owned model read without it.