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.
| Contract | Value |
|---|---|
| Needs | A 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 have | Login 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 |
| Status | Run end to end by scripts/check page-sessions |
| Proved by | frappe check · frappe corretto · frappe lint |
Where the change goes
app/models/user.crEmail, digest, active flagapp/models/user_session.crRevocable token rowapp/models/membership.crUser-to-account joindb/migrations/Tables from the diffapp/actions/sessions/new.crShow the formapp/actions/sessions/login.crCreate the sessionapp/actions/sessions/logout.crRevoke the sessionconfig/routes.crLogin and logout routesapp/views/sessions/new.crSign-in formapp/views/layouts/application.crSign-out form in headerapp/actions/application_action.crMemoized session readersapp/actions/accounts/home.crMember-only homeapp/actions/accounts/create.crCreator becomes memberspec/support/sessions.crShared buildersspec/requests/sessions_spec.crLifecycle examplesspec/requests/accounts_spec.crSign-up, signed inCreate 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.
- Create the application
Terminal · after machine setup frappe new clubhouse cd clubhouseExpect: Prints
Created clubhouse., thenclubhouse is configured at https://clubhouse.caramel. - Make Account the tenant
Terminal · inside the application frappe make tenancy AccountExpect: Prints
Generated PATHfor each file it writes or edits,app/models/account.cr,app/actions/accounts/create.crandspec/requests/accounts_spec.cramong them, thenNext: run frappe migrate and open /accounts/new. - Apply the tenant’s migration
Terminal · inside the application frappe migrateExpect: Prints
Applied 1 migrations., thenThe 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.
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
endmodule 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
endmodule 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- Derive the migration
Terminal · inside the application frappe db diff --name create_user_sessionsExpect: Prints
Wrote db/migrations/…_create_user_sessions.cr: one migration that createsusers,user_sessionsandmemberships. - Apply the migration
Terminal · inside the application frappe migrateExpect: Prints
Applied 1 migrations., thenThe 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.
| Condition | Outcome |
|---|---|
| Unknown email | answers 422, the same page |
| Inactive user | answers 422, the same page |
| Wrong password | answers 422, the same page |
| Password of 72 bytes or more | answers 422, the same page |
| Valid password for an active user | revokes 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.
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
endrequire "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
endLogout 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.
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
endThe three routes are central: they go in config/routes.cr above # Frappé resource routes, outside the tenant block.
get "/login", App::Sessions::New
post "/login", App::Sessions::Login
post "/logout", App::Sessions::Logout- List the routes
Terminal · inside the application frappe routes loginExpect: Prints
GET /login App::Sessions::NewandPOST /login App::Sessions::Login email:String password:String;frappe routes logoutprintsPOST /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.
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
endThe 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.
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
endLoad 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.
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| Reader | Matches |
|---|---|
current_session | The token digest, with revoked_at: nil and expires_at from now on |
current_user | The session’s user, who must be active |
current_membership | The user and the tenant’s id |
| Condition | Outcome |
|---|---|
| A session is revoked or expires | takes effect on the next request |
| A user is deactivated or a membership removed | takes effect on the next request |
| The next request | queries 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.
| Condition | Outcome |
|---|---|
| Anonymous request to a tenant action with an authenticator | answers 401 before the contract binds |
| Non-member request to such an action | answers 401 before the contract binds |
Anonymous request to Accounts::Create | answers 401 before the contract binds |
| Signed-in user creates an account | saves it, then writes the membership and redirects to /SLUG |
| The account fails to save | re-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.
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
endThe 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.
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
endTest 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 test | Do this |
|---|---|
| Login | Log in through the real route and follow its redirect |
client.sign_in(user) alone | It writes only user_id, so a member page answers 401 |
| Expiry | Move expires_at into the past with update! |
| Replay | Save 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 password | It must get the same page as a wrong one |
| Account creation | It must make its creator a member |
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
endrequire "../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
endThe 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.
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
endThe 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
frappe check
frappe corretto
frappe lintPassing: 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.
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.