Skip to content
caramelcookbook · 0.16.0
Browse documentation
Cookbook / Accounts and access

Sign users in with revocable sessions.

The signed cookie carries a random token. PostgreSQL holds its digest, expiry and revocation, and every request rechecks them.

Proved by scripts/check page-sessionsTarget: 0.16.0
ContractValue
NeedsA Caramel installation set up as in Your first application; the recipe creates clubhouse and its App::Account tenant with frappe make tenancy (Multi-tenancy)
You will haveLogin and logout over a revocable token row, per-request current_session, current_user and current_membership readers, a membership guard on tenant pages, and a request spec for expiry, revocation, deactivation, removed membership, replayed cookies and 72-byte passwords
StatusRun end to end by scripts/check page-sessions
Proved byfrappe check · frappe corretto · frappe lint

Where the change goes

app/models/user.crEmail, digest, active flag
app/models/user_session.crRevocable token row
app/models/membership.crUser-to-account join
db/migrations/Tables from the diff
app/actions/sessions/new.crShow the form
app/actions/sessions/login.crCreate the session
app/actions/sessions/logout.crRevoke the session
config/routes.crLogin and logout routes
app/views/sessions/new.crSign-in form
app/views/layouts/application.crSign-out form in header
app/actions/application_action.crMemoized session readers
app/actions/accounts/home.crMember-only home
app/actions/accounts/create.crCreator becomes member
spec/support/sessions.crShared builders
spec/requests/sessions_spec.crLifecycle examples
spec/requests/accounts_spec.crSign-up, signed in

Create the application and its tenant

The application is clubhouse. Its pages live under an account’s slug, so the tenant comes first; the generator also writes the account(db, slug) spec helper the examples below use.

  1. Create the application
    Terminal · after machine setup
    frappe new clubhouse
    cd clubhouse

    Expect: Prints Created clubhouse., then clubhouse is configured at https://clubhouse.caramel.

  2. Make Account the tenant
    Terminal · inside the application
    frappe make tenancy Account

    Expect: Prints Generated PATH for each file it writes or edits, app/models/account.cr, app/actions/accounts/create.cr and spec/requests/accounts_spec.cr among them, then Next: run frappe migrate and open /accounts/new.

  3. Apply the tenant’s migration
    Terminal · inside the application
    frappe migrate

    Expect: Prints Applied 1 migrations., then The database matches the declared schema.

Keep the revocable part in PostgreSQL

A cookie that holds user_id cannot be revoked: Caramel::Session is a signed cookie with no server-side expiry (see Security), so a copy of it stays valid until the browser drops it. Put a random token in the cookie instead and keep its SHA-256 digest, an expiry and a revocation time in a table. Each request looks the digest up, so ending the row ends the session.

An App::User has an email, a bcrypt digest and an active flag. App::UserSession is the revocable row. App::Membership joins a user to an account, unique per pair; the guard below reads it. All three are central tables: they declare no tenant.

app/models/user.cr
module App
  struct User < SugarORM::Schema
    schema "users" do
      field id : Int64, primary: true
      field email : String
      field password_digest : String
      field active : Bool = true
      timestamps
      index :email, unique: true
    end
  end
end
app/models/user_session.cr
module App
  struct UserSession < SugarORM::Schema
    schema "user_sessions" do
      field id : Int64, primary: true
      belongs_to user : User
      field token_digest : String
      field expires_at : Time
      field revoked_at : Time?
      timestamps
      index :token_digest, unique: true
    end
  end
end
app/models/membership.cr
module App
  struct Membership < SugarORM::Schema
    schema "memberships" do
      field id : Int64, primary: true
      belongs_to user : User
      belongs_to account : Account
      timestamps
      index :user_id, :account_id, unique: true
    end
  end
end
  1. Derive the migration
    Terminal · inside the application
    frappe db diff --name create_user_sessions

    Expect: Prints Wrote db/migrations/…_create_user_sessions.cr: one migration that creates users, user_sessions and memberships.

  2. Apply the migration
    Terminal · inside the application
    frappe migrate

    Expect: Prints Applied 1 migrations., then The database matches the declared schema.

The digest is unique, so one lookup finds one row.

Create the session at login, revoke it at logout

Login is POST /login with an email and a password. Every attempt runs exactly one bcrypt comparison: a missing user is checked against a fixed dummy digest. That keeps the answer and the work comparable, so the response does not say which emails exist; it does not make the timing identical.

ConditionOutcome
Unknown emailanswers 422, the same page
Inactive useranswers 422, the same page
Wrong passwordanswers 422, the same page
Password of 72 bytes or moreanswers 422, the same page
Valid password for an active userrevokes the row behind any token the cookie holds, clears the session, creates a new token and redirects to /

Crystal’s bcrypt reads at most 71 bytes of a password and verify raises Crypto::Bcrypt::Error from 72 bytes up; without the action’s own test that would answer 500, and only for an email that exists. A contract max: counts characters, not bytes, so the action tests bytesize itself. The success path revokes the old row because session.clear would otherwise orphan a row that stays valid for anyone who copied the old cookie. Only the token’s digest reaches the database.

app/actions/sessions/new.cr
module App::Sessions
  # The sign-in form, at GET /login.
  struct New < App::ApplicationAction
    contract do
    end

    def handle(contract : Contract)
      page "Sign in", Views::Sessions::New.new(csrf_token, false)
    end
  end
end
app/actions/sessions/login.cr
require "crypto/bcrypt/password"
require "digest/sha256"

module App::Sessions
  struct Login < App::ApplicationAction
    # A valid bcrypt digest that no password matches. An unknown or inactive
    # email is checked against it, so every attempt costs one bcrypt comparison.
    DUMMY_DIGEST = "$2a$11$gAYJo.vJfIwsu1p8HuehU.GEPrMMm4NrGSBZIZiDHdZVg.JHmm5wi"

    contract do
      field email : String
      field password : String
    end

    def handle(contract : Contract)
      user = User.query.where(email: contract.email, active: true).first
      # bcrypt reads at most 71 bytes: verify raises for 72 or more, which would
      # answer 500 only for an existing email. Treat those as a wrong password.
      fits = contract.password.bytesize < 72
      digest = user ? user.password_digest : DUMMY_DIGEST
      matches = Crypto::Bcrypt::Password.new(digest).verify(fits ? contract.password : "")
      unless user && matches && fits
        self.status = 422
        return false
      end
      # A cookie that still holds a session token must not leave its row open.
      current_session.try &.update!(revoked_at: Time.utc)
      session.clear
      token = Random::Secure.urlsafe_base64(32)
      UserSession.create!(
        user_id: user.id,
        token_digest: Digest::SHA256.hexdigest(token),
        expires_at: Time.utc + 14.days)
      session["session_token"] = token
      true
    end

    def render(result)
      return page "Sign in", Views::Sessions::New.new(csrf_token, true) unless result
      redirect_to "/"
    end
  end
end

Logout is POST /logout. It marks the current row revoked before sign_out clears the cookie, so a copy of the old cookie no longer matches an open row.

app/actions/sessions/logout.cr
module App::Sessions
  struct Logout < App::ApplicationAction
    contract do
    end

    def handle(contract : Contract)
      current_session.try &.update!(revoked_at: Time.utc)
      sign_out
      true
    end

    def render(result)
      redirect_to "/login"
    end
  end
end

The three routes are central: they go in config/routes.cr above # Frappé resource routes, outside the tenant block.

config/routes.cr · in the central block
get "/login", App::Sessions::New
post "/login", App::Sessions::Login
post "/logout", App::Sessions::Logout
  1. List the routes
    Terminal · inside the application
    frappe routes login

    Expect: Prints GET /login App::Sessions::New and POST /login App::Sessions::Login email:String password:String; frappe routes logout prints POST /logout App::Sessions::Logout.

Sessions::New renders the form below. Both forms turn boosting off with "hx-boost": "false" and carry the hidden _csrf field. Signing in or out changes the header, which sits outside #content, and a boosted swap would leave it stale; a full page load refreshes it.

app/views/sessions/new.cr
module App::Views::Sessions
  class New < App::ApplicationView
    def initialize(@csrf_token : String, @failed : Bool)
    end

    private def blueprint
      h1 { "Sign in" }
      p { "Email or password is wrong." } if @failed
      form method: "post", action: "/login", "hx-boost": "false" do
        input type: "hidden", name: "_csrf", value: @csrf_token
        label { "Email" }
        input type: "email", name: "email", required: true
        label { "Password" }
        input type: "password", name: "password", required: true
        button(type: "submit") { "Sign in" }
      end
    end
  end
end

The layout is the generated one with two changes: initialize takes @signed_in : Bool, and the header shows the sign-out form when it is true. A view cannot call the action’s readers, so the action passes the state in; the next section shows the layout override that does.

app/views/layouts/application.cr
module App::Views::Layouts
  class Application < App::ApplicationView
    def initialize(@page : Caramel::Page, @csrf_token : String, @signed_in : Bool)
    end

    private def blueprint
      doctype
      html lang: Caramel.language do
        head do
          meta charset: "utf-8"
          meta name: "viewport", content: "width=device-width, initial-scale=1"
          title { "#{@page.title} · #{App::TITLE}" }
          link rel: "stylesheet", href: "/assets/app.css"
          script src: "/assets/htmx-4.0.0.min.js", defer: true
          script src: "/assets/caramel-islands.js", defer: true
          script src: "/assets/app.js", defer: true
        end
        body "hx-boost:inherited": "true", "hx-target:inherited": "#content",
          "hx-swap:inherited": "innerMorph",
          "hx-headers:inherited": {"X-CSRF-Token" => @csrf_token}.to_json do
          header class: "site-header" do
            a href: "/", class: "brand" do
              span(class: "mark", aria_hidden: "true") { "c." }
              plain App::TITLE
            end
            if @signed_in
              form method: "post", action: "/logout", "hx-boost": "false" do
                input type: "hidden", name: "_csrf", value: @csrf_token
                button(type: "submit") { "Sign out" }
              end
            end
            span(class: "made-with") { "Made with Caramel" }
          end
          main(id: "content") { raw @page.html }
          footer { "Make something worth opening." }
        end
      end
    end
  end
end

Load the session once per request

Replace the generated App::ApplicationAction with one that passes the signed-in state to the layout and holds three memoized readers. Each has an explicit @…_loaded flag, so a nil result is cached too. @current_user ||= … would run the query again on every call of an anonymous request, because nil is falsy.

app/actions/application_action.cr
require "digest/sha256"

module App
  abstract struct ApplicationAction < Caramel::Action
    include App::Paths

    @session_loaded = false
    @current_session : UserSession? = nil
    @user_loaded = false
    @current_user : User? = nil
    @membership_loaded = false
    @current_membership : Membership? = nil

    def layout(page : Caramel::Page) : String
      Views::Layouts::Application.new(page, csrf_token, signed_in: !current_user.nil?).to_s
    end

    def title_for(page : Caramel::Page) : String
      "#{page.title} · #{App::TITLE}"
    end

    def current_session : UserSession?
      unless @session_loaded
        @session_loaded = true
        if token = session["session_token"]?
          @current_session = UserSession.query.where(
            token_digest: Digest::SHA256.hexdigest(token),
            revoked_at: nil,
            expires_at: Time.utc..
          ).first
        end
      end
      @current_session
    end

    def current_user : User?
      unless @user_loaded
        @user_loaded = true
        if user_session = current_session
          @current_user = User.query.where(id: user_session.user_id, active: true).first
        end
      end
      @current_user
    end

    def current_membership : Membership?
      unless @membership_loaded
        @membership_loaded = true
        if user = current_user
          @current_membership = Membership.query
            .where(user_id: user.id, account_id: tenant.id).first
        end
      end
      @current_membership
    end
  end
end
ReaderMatches
current_sessionThe token digest, with revoked_at: nil and expires_at from now on
current_userThe session’s user, who must be active
current_membershipThe user and the tenant’s id
ConditionOutcome
A session is revoked or expirestakes effect on the next request
A user is deactivated or a membership removedtakes effect on the next request
The next requestqueries again, because one action instance serves one request

The memo lives on the action struct, so pass values, not the action, to helpers and views. current_membership reads tenant, so call it only from a tenant action.

Guard tenant pages with ingress

A tenant action names an authenticator, and the router answers 401 when it returns false, before the contract binds (Security). Nothing else creates a membership, so the action that creates an account must make its creator the first member, or the guard locks every new account out.

ConditionOutcome
Anonymous request to a tenant action with an authenticatoranswers 401 before the contract binds
Non-member request to such an actionanswers 401 before the contract binds
Anonymous request to Accounts::Createanswers 401 before the contract binds
Signed-in user creates an accountsaves it, then writes the membership and redirects to /SLUG
The account fails to savere-renders the form with 422 and writes no membership

Replace the generated Accounts::Home with one that admits members only. The tenant’s routes still sit in the tenant block.

app/actions/accounts/home.cr
module App::Accounts
  # The account's home page, at /SLUG, for its members.
  struct Home < App::ApplicationAction
    ingress authenticate: :member?

    contract do
    end

    def handle(contract : Contract)
      page tenant.name, Views::Accounts::Home.new(tenant)
    end

    private def member? : Bool
      !current_membership.nil?
    end
  end
end

The generated Accounts::Create redirects to the new account without a membership. Replace it so that only a signed-in user creates an account and becomes its member. The generated spec/requests/accounts_spec.cr created an account anonymously and would now get 401; the last section replaces it.

app/actions/accounts/create.cr
module App::Accounts
  struct Create < App::ApplicationAction
    include Form

    # Only a signed-in user creates an account, and becomes its first member.
    ingress authenticate: :signed_in?

    contract do
      field name : String
      field slug : String
    end

    def handle(contract : Contract)
      # signed_in? ran before the contract bound, so current_user is a User.
      user = current_user.as(User)
      changes = App::Account.create(name: contract.name, slug: contract.slug)
      return render_form(contract.values, changes.errors, 422) unless changes.saved?
      App::Membership.create!(user_id: user.id, account_id: changes.record.id)
      self.status = 201
      {record: changes.record}
    end

    def render(result)
      redirect_to("/#{result[:record].slug}")
    end

    private def signed_in? : Bool
      !current_user.nil?
    end
  end
end

Test the lifecycle in Corretto

Corretto’s client.sign_in(user) writes only user_id into the session cookie, which this guard ignores, so the examples log in through /login (Corretto reference →). The builders they share live in one module.

To testDo this
LoginLog in through the real route and follow its redirect
client.sign_in(user) aloneIt writes only user_id, so a member page answers 401
ExpiryMove expires_at into the past with update!
ReplaySave the cookie before logout and restore it into client.cookies afterwards; the same trick proves that a second login revokes the first session
72-byte passwordIt must get the same page as a wrong one
Account creationIt must make its creator a member
spec/support/sessions.cr
require "crypto/bcrypt/password"

# Builders the session specs share: a user, a member of an account, a login.
module SessionsSpec
  extend self

  def user(db, active = true) : App::User
    digest = Crypto::Bcrypt::Password.create("secret", cost: 4).to_s
    App::User.create!(db, email: "ann@example.com", password_digest: digest, active: active)
  end

  def member(db, account : App::Account, active = true) : App::User
    user = user(db, active)
    App::Membership.create!(db, user_id: user.id, account_id: account.id)
    user
  end

  def log_in(client, password = "secret") : Caramel::Response
    client.post("/login", params: {"email" => "ann@example.com", "password" => password})
  end
end
spec/requests/sessions_spec.cr
require "../spec_helper"
require "../support/sessions"

describe "Revocable sessions" do
  it "signs a member in and out" do
    Corretto.session do |client, db|
      acme = account(db, "acme")
      SessionsSpec.member(db, acme)
      client.get("/login").should render_page("Sign in")
      SessionsSpec.log_in(client).should redirect_to("/")
      client.get("/acme").should have_status(200)
      client.post("/logout").should redirect_to("/login")
      client.get("/acme").should have_status(401)
    end
  end

  it "refuses a wrong password" do
    Corretto.session do |client, db|
      SessionsSpec.member(db, account(db, "acme"))
      SessionsSpec.log_in(client, "wrong").should have_status(422)
      client.get("/acme").should have_status(401)
    end
  end

  it "answers a password of 72 bytes or more like a wrong one" do
    Corretto.session do |client, db|
      SessionsSpec.member(db, account(db, "acme"))
      wrong = SessionsSpec.log_in(client, "wrong")
      long = SessionsSpec.log_in(client, "a" * 72)
      long.should have_status(422)
      long.body.should eq(wrong.body)
      client.get("/acme").should have_status(401)
    end
  end

  it "does not accept sign_in alone" do
    Corretto.session do |client, db|
      user = SessionsSpec.member(db, account(db, "acme"))
      client.sign_in(user)
      client.get("/acme").should have_status(401)
    end
  end

  it "refuses an expired session" do
    Corretto.session do |client, db|
      SessionsSpec.member(db, account(db, "acme"))
      SessionsSpec.log_in(client)
      client.get("/acme").should have_status(200)
      App::UserSession.query.first!.update!(db, expires_at: Time.utc - 1.minute)
      client.get("/acme").should have_status(401)
    end
  end

  it "refuses a deactivated user" do
    Corretto.session do |client, db|
      user = SessionsSpec.member(db, account(db, "acme"))
      SessionsSpec.log_in(client)
      user.update!(db, active: false)
      client.get("/acme").should have_status(401)
    end
  end

  it "refuses a user whose membership was deleted" do
    Corretto.session do |client, db|
      SessionsSpec.member(db, account(db, "acme"))
      SessionsSpec.log_in(client)
      client.get("/acme").should have_status(200)
      App::Membership.query.first!.delete(db)
      client.get("/acme").should have_status(401)
    end
  end

  it "refuses a replayed cookie after logout" do
    Corretto.session do |client, db|
      SessionsSpec.member(db, account(db, "acme"))
      SessionsSpec.log_in(client)
      cookie = client.cookies[Caramel::Session::COOKIE_NAME]
      client.post("/logout")
      client.cookies[Caramel::Session::COOKIE_NAME] = cookie
      client.get("/acme").should have_status(401)
    end
  end

  it "revokes the earlier session when a signed-in browser logs in again" do
    Corretto.session do |client, db|
      SessionsSpec.member(db, account(db, "acme"))
      SessionsSpec.log_in(client)
      first = client.cookies[Caramel::Session::COOKIE_NAME]
      SessionsSpec.log_in(client).should redirect_to("/")
      client.get("/acme").should have_status(200)
      client.cookies[Caramel::Session::COOKIE_NAME] = first
      client.get("/acme").should have_status(401)
    end
  end

  it "makes the creator of an account its member" do
    Corretto.session do |client, db|
      SessionsSpec.user(db)
      client.post("/accounts", params: {"name" => "Acme", "slug" => "acme"})
        .should have_status(401)
      SessionsSpec.log_in(client)
      client.post("/accounts", params: {"name" => "Acme", "slug" => "acme"})
        .should redirect_to("/acme")
      client.follow_redirect.should render_page("Acme")
    end
  end
end

The generated sign-up spec keeps its form, 404 and reserved-slug examples; the one that created an account anonymously is now the last example above, and the reserved slug is tried signed in.

spec/requests/accounts_spec.cr · replaces the generated spec
require "../spec_helper"
require "../support/sessions"

describe "Account sign-up" do
  it "renders the sign-up form" do
    Corretto.session do |client|
      client.get("/accounts/new").should render_page("New account")
    end
  end

  it "answers 404 for a slug no account has" do
    Corretto.session do |client|
      client.get("/globex").should have_status(404)
    end
  end

  it "refuses a slug a central route starts with" do
    Corretto.session do |client, db|
      SessionsSpec.user(db)
      SessionsSpec.log_in(client)
      taken = client.post("/accounts", params: {"name" => "Health", "slug" => "health"})
      taken.should have_status(422)
    end
  end
end

The specs create their users directly, so the recipe has no sign-up for users: add a registration action, or insert users with a script, before real people can sign in. Expired and revoked rows are never deleted, and no login rate limit is set.

Prove it

Prove the session lifecycle

Terminal · inside the application
frappe check
frappe corretto
frappe lint

Passing: frappe check exits 0. frappe corretto runs the ten session examples (sign in and out, a wrong password, a 72-byte password, sign_in alone refused, an expired session, a deactivated user, a deleted membership, a replayed cookie, a second login revoking the first, and account creation making its creator a member) beside the three sign-up examples and the generated home example, and ends with Corretto: 1 of 1 workers passed. frappe lint exits 0.

Proved Run end to end by a check.

scripts/check page-sessions creates clubhouse, makes Account its tenant, migrates, copies these files at the steps above, derives and applies the migration, lists the routes and runs the proof, and the website build holds every snippet on this page to those files.