sessions and sign-in
this guide covers how senro remembers a visitor between requests and carries a notice to the next page, how the guestbook makes accounts and signs people in, what senro does to keep a password safe, how it limits sign-in attempts, locks an account that too many wrong passwords reach, keeps where each sign-in came from, and asks for a signed-in visitor, how a visitor changes a password or chooses a new one after losing it, how an account confirms its address, and how senro refuses a request another site sent.
the session
a session is a map of strings the browser keeps in a cookie and sends back with each request. the cookie holds the map as JSON beside an HMAC-SHA-256 of it under the app's secret, so a session the browser altered, or one another app signed, reads as an empty map. an action reads it with web/session req secret and keeps a changed one by answering web/kept response secret session. an answer that would set the session the request already carried sets none, so a page the server read before a sign-out cannot sign the visitor back in when its answer reaches the browser last.
web/signed_in req response secret session id bound keeps the session with id as its viewer. bound is what the sign-in rests on, which the section on pages that ask for a sign-in describes. web/viewer req secret reads the viewer back, or "" for a visitor who has not signed in. a sign-in lasts web/session_days, fourteen. the server stamps each request with the time it arrived, and a request later than that reads the session without its viewer; whatever else it keeps survives.
the cookie itself lasts as long as the browser, so closing the browser signs the visitor out. a session made web/lasting keeps its cookie for those fourteen days instead, which is Devise's "remember me". the mark is in the session itself, so every later response that keeps the session keeps the cookie as long. the guestbook's sign-in form has a "Keep me signed in" box, and entered below marks the session when it is ticked. web/signed_out session forgets the sign-in, the mark with it, and keeps the rest.
notices
an action that redirects often leaves a line for the page the browser lands on, such as "Signed out." or "Thank you for signing." web/noticed response message answers the response with that notice, as Rails' redirect_to path, notice: does, and web/notice req reads it on the next request, or "" when there is none. once that request is answered the server forgets the notice: an answer to a request that carried one, and that leaves none of its own, sets the notice's cookie empty. the notice has a cookie of its own rather than a place in the session, so forgetting it never writes the session.
# A notice is shown by the request after the one that left it, and then it
# is gone, as Rails' flash is: an answer to a request that carried a notice,
# and leaves none of its own, tells the browser to forget it. The notice has
# a cookie of its own, so forgetting it never sets the session, and a page
# read before a sign-out cannot put the sign-in back by clearing one.
fn notice_shown req r
return r if cookie req.headers["cookie"] notice_cookie == ""
return r if list/any? r.headers leaves_notice?
headed r "set-cookie: {notice_cookie}=; Path=/; Max-Age=0"
notice_cookie = "senro_notice"
fn leaves_notice? h
text/starts_with? h "set-cookie: {notice_cookie}="
# The answer `r`, leaving `message` for the request after it to show, as
# Rails' `redirect_to path, notice: message` does:
#
# web/noticed (web/redirect "/") "Thank you for signing."
#
# The next request reads it with `notice`, and the server forgets it once
# that request is answered.
pub fn noticed r message
kept_lines = list/to_list (list/reject r.headers leaves_notice?)
line = "set-cookie: {notice_cookie}={escaped message}; Path=/; HttpOnly"
http/respond r.status (push kept_lines "{line}; SameSite=Lax") r.body
# The notice the request carries, which the answer before it left with
# `noticed`, or "" when it carries none.
pub fn notice req
percent_decoded (cookie req.headers["cookie"] notice_cookie)
making an account
an account is a model like any other: a name, a digest of the password, an email address, and when that address was confirmed. the guestbook's account module is a singular resource, as Devise's registrations are, since a visitor only ever has one: account/new answers GET /account/new and account/create answers POST /account. its create action looks up the name and the address and checks the form with rules before it does any expensive work, so a sign-up that cannot succeed costs no hashing. a new account is not signed in: it is mailed a link that confirms its address first, as described under confirming an address below.
import "../../models"
import "../../passwords"
import "../../pg"
import "../../web"
import "./account_mailer"
import "./entries"
import "./views"
import "std/io"
import "std/list"
import "std/text"
# What a sign-up form asked for.
type signup
name
email
password
# An account, as the `accounts` table stores it: the name its holder signs
# with, a digest of the password, the address its mail goes to, and when
# that address was confirmed. The password itself is never stored.
pub type account
id
name
digest
email
confirmed
# One address may try the current password ten times in three minutes, as
# it may try to sign in.
pub rate_limits = { "update":"10 per 3 minutes" }
# Anyone may make an account. Changing its password asks for a sign-in.
pub without_sign_in = ["create" "new"]
pub fn create? _ _
true
pub fn new req _
effect (opened req "" "" {} 200)
# An account needs a name and an address nobody holds, and a password of
# eight characters or more. Both are looked up before the password is
# stretched, so a sign-up that cannot succeed costs no hashing.
pub fn create req db
f = web/form req.body
name = text/trim (web/value f "name")
email = text/lowercase (text/trim (web/value f "email"))
password = web/value f "password"
asked = signup name email password
pg/query db held [name]
.> (n -> pg/query db addressed [email] .> (m -> joined req db asked n m))
held = "select id from accounts where name = $1"
addressed = "select id from accounts where email = $1"
fn joined req db asked by_name by_email
name = asked.name
email = asked.email
password = asked.password
short = "Use eight characters or more."
taken = "That address has an account."
found = models/problems [
(models/rule "name" (name == "") "Choose a name.")
(models/rule "name" (length by_name.rows > 0) "That name is taken.")
(models/email_address "email" email)
(models/rule "email" (length by_email.rows > 0) taken)
(models/rule "password" (length password < 8) short)
]
return effect (opened req name email found 422) if models/problems? found
fields = { "email":email "name":name }
passwords/digested password
.> (d -> models/created "" db account_model (put fields "digest" d))
.> (w -> made req db w)
# A new account is not signed in: its holder confirms the address first,
# through the link the mail carries, as Devise's `confirmable` asks. A mail
# that cannot be queued is logged, as the worker logs one it cannot send,
# and the account can ask again by signing in.
fn made _ db kept:account
told = "Welcome, {kept.name}. Confirm your address with the link we sent."
welcomed = web/noticed (web/redirect "/") told
account_mailer/confirmation db kept.id kept.email
.? (u -> io/write_err "guestbook: no confirmation mail: {u.reason}\n")
.> (_ -> effect welcomed)
fn made _ _ _
effect web/denied
# The account of whoever is signed in, as Devise's registrations edit it:
# a new password, given the current one. The account is a singular
# resource, since a visitor only ever edits their own.
# The page says how often the account has signed in and where the sign-in
# before this one came from, so a holder can see one that was not theirs.
pub fn edit req db
name = web/viewer req entries/secret
pg/query db tracked_by [name]
.> (r -> effect (changing req {} 200 (arrivals (list/first r.rows))))
tracked_by = """
select sign_in_count, last_sign_in_at, last_sign_in_ip
from accounts where name = $1
"""
fn arrivals none
""
fn arrivals row
count = times "{models/cell row "sign_in_count"}"
at = models/cell row "last_sign_in_at"
return "You have signed in {count}." if at == ""
from = models/cell row "last_sign_in_ip"
"You have signed in {count}. The time before this one was {at}, from {from}."
fn times "1"
"once"
fn times n
"{n} times"
# The current password must match, and the new one is eight characters or
# more. Changing it ends every other sign-in, since a sign-in rests on the
# digest, and this browser is signed in again on the new one so that it
# stays.
pub fn update req db
f = web/form req.body
name = web/viewer req entries/secret
current = web/value f "current"
password = web/value f "password"
pg/query db digest_by [name]
.> (r -> changed req db name current password (list/first r.rows))
digest_by = "select digest from accounts where name = $1"
fn changed _ _ _ _ _ none
effect web/denied
fn changed req db name current password row
wrong = { "current":"That is not your password." }
short = { "password":"Use eight characters or more." }
kept = models/cell row "digest"
right = passwords/matches? current kept
return effect (changing req wrong 422 "") if not right
return effect (changing req short 422 "") if length password < 8
passwords/digested password
.> (d -> pg/query db set_digest [d name] .> (_ -> kept_on req name d))
set_digest = "update accounts set digest = $1 where name = $2"
fn kept_on req name digest
s = web/session req entries/secret
home = web/noticed (web/redirect "/") "Your password is changed."
effect (web/signed_in req home entries/secret s name digest)
pub fn over_limit req
wait = { "current":"Too many tries. Try again in a few minutes." }
effect (changing req wait 429 "")
fn changing req problems code seen
page = views/account_view "/account" problems seen
framed = views/layout_view "Your account · Guestbook" page
views/answer_as req code framed
# The sign-up form shows the name and address as typed and the problem with
# each field, if any. A password is never sent back.
fn opened req name email problems code
heading = "Make an account"
to = "/account"
page = views/door_view heading to "Make it" name email problems true false
framed = views/layout_view "Make an account · Guestbook" page
views/answer_as req code framed
the password is never stored. passwords/digested stretches it with PBKDF2-SHA-256 under a sixteen-byte salt from the operating system's secure source and answers text that names everything a later check needs:
pbkdf2-sha256$<rounds>$<salt, base64>$<digest, base64>
because each digest names its own rounds, the default can rise without breaking the digests already kept. it is 100,000 rounds, a sixth of what OWASP asks, because a release build of kanso stretches 100,000 in about a fifth of a second and the full count would hold a sign-in for more than a second. SENRO_PASSWORD_ROUNDS lowers it for development, where the development tier is about seven times slower.
signing in
the session is a singular resource: session/new is the form, session/create signs in, and session/destroy signs out.
import "../../models"
import "../../passwords"
import "../../pg"
import "../../web"
import "./account_mailer"
import "./entries"
import "./views"
import "std/io"
import "std/list"
import "std/text"
import "std/time"
# What a sign-in checks of the account a name holds. A name with no account
# holds the stand-in digest, which no password matches.
type holder
id
digest
email
confirmed
locked
# One address may try to sign in ten times in three minutes, as Rails 8's
# generated sessions controller allows. Past that, `over_limit` answers the
# sign-in page again, saying why.
pub rate_limits = { "create":"10 per 3 minutes" }
pub without_sign_in = ["create" "new"]
# Three wrong passwords in a row lock an account, as Devise's `lockable`
# does, for an hour or until the link mailed to its address unlocks it. A
# right password ends the run of wrong ones.
pub locks_after = 3
pub locked_minutes = 60
# What a sign-in rests on: the digest the account keeps now. A new password
# changes it, so a password reset signs out every session made before it.
pub signed_against = (db name -> pg/query db digest_by [name] .> kept_of)
digest_by = "select digest from accounts where name = $1"
fn kept_of r
effect (digest_in (list/first r.rows))
fn digest_in none
""
fn digest_in row
models/cell row "digest"
# Signing in and out. The session is the signed cookie `web` keeps, and
# signing in writes the account's name into it as the viewer.
pub fn new req _
effect (opened req "" {} 200)
# A name with no account is checked against nothing at the same cost, so a
# refusal takes as long whether or not the name exists, and the answer is
# the same: a name and a password that do not match.
pub fn create req db
f = web/form req.body
name = text/trim (web/value f "name")
password = web/value f "password"
remember = web/value f "remember" == "yes"
pg/query db held_by [name]
.> (r -> held (list/first r.rows))
.> (a -> time/now .> (t -> entered req db name a password remember t))
held_by = """
select id, digest, email, confirmed, locked
from accounts where name = $1
"""
fn held none
passwords/stand_in .> (d -> effect (holder "" d "" "" ""))
fn held row
id = "{models/cell row "id"}"
kept = models/cell row "digest"
email = models/cell row "email"
locked = models/cell row "locked"
effect (holder id kept email (models/cell row "confirmed") locked)
# A locked account answers a wrong password as any other wrong password is
# answered, and tells only the holder of the right one that it is locked.
fn entered req db name a password remember t
right = passwords/matches? password a.digest
return shut req name right if locked? a t
return missed req db name a t if not right
return unconfirmed req db name a if a.confirmed == ""
arrived = [a.id "{t}" (web/peer req)]
pg/query db cleared arrived .> (_ -> signed req name a remember)
# The right password ends a run of wrong ones, and the sign-in is counted and
# stamped, the one before it kept beside it, in the same statement. The time
# is the app's clock, written as UTC to the minute.
cleared = """
update accounts set failures = 0, locked = '',
sign_in_count = sign_in_count + 1,
last_sign_in_at = current_sign_in_at,
current_sign_in_at = to_char(
to_timestamp($2::bigint / 1000) at time zone 'UTC',
'YYYY-MM-DD HH24:MI "UTC"'),
last_sign_in_ip = current_sign_in_ip,
current_sign_in_ip = $3
where id = $1
"""
fn locked? a t
return false if a.locked == ""
t < stamp_of (text/to_int a.locked) + locked_minutes * 60 * 1000
fn stamp_of none
0
fn stamp_of n
n
fn shut req name true
told = "This account is locked. Follow the link we mailed to unlock it."
effect (opened req name { "password":told } 403)
fn shut req name false
refused req name
# A wrong password for an account counts against it, and the one that makes
# the run long enough locks it and mails the link that unlocks it. A lock
# that has run out counts as none, so the count starts again.
fn missed req db name a t
return refused req name if a.id == ""
pg/query db counted [a.id "{locks_after}" "{t}"]
.> (r -> locking req db name a (list/first r.rows))
counted = """
update accounts set
failures = case when locked = '' then failures + 1 else 1 end,
locked = case
when (case when locked = '' then failures + 1 else 1 end) >= $2::int
then $3 else '' end
where id = $1
returning locked
"""
fn locking req _ name _ none
refused req name
fn locking req db name a row
stamp = models/cell row "locked"
return refused req name if stamp == ""
account_mailer/unlock db a.id a.email stamp
.? (u -> io/write_err "guestbook: no unlock mail: {u.reason}\n")
.> (_ -> refused req name)
fn signed req name a remember
s = web/session req entries/secret
back = web/return_to req entries/secret "/"
signing = kept_if (delete s "return_to") remember
home = web/noticed (web/redirect back) "Signed in as {name}."
effect (web/signed_in req home entries/secret signing name a.digest)
# A visitor who ticked "Keep me signed in" keeps the sign-in after the
# browser closes.
fn kept_if s true
web/lasting s
fn kept_if s false
s
# The right password for an address not yet confirmed: the link is sent
# again, and the visitor is told to follow it. Only the holder of the
# password learns that the account exists.
fn unconfirmed req db name a
first = "Confirm your address first. We have sent the link again."
account_mailer/confirmation db a.id a.email
.? (u -> io/write_err "guestbook: no confirmation mail: {u.reason}\n")
.> (_ -> effect (opened req name { "password":first } 403))
fn refused req name
wrong = { "password":"That name and password do not match." }
effect (opened req name wrong 422)
pub fn over_limit req
wait = { "password":"Too many tries. Try again in a few minutes." }
effect (opened req "" wait 429)
# Signing out keeps the rest of the session and forgets the sign-in.
pub fn destroy req _
s = web/signed_out (web/session req entries/secret)
r = web/noticed (web/redirect "/") "Signed out."
effect (web/kept r entries/secret s)
fn opened req name problems code
to = "/session"
page = views/door_view "Sign in" to "Sign in" name "" problems false true
views/answer_as req code (views/layout_view "Sign in · Guestbook" page)
two details keep a refusal from saying more than it should. a name with no account is checked against passwords/stand_in, a digest no password matches, at the same rounds, so a refusal takes as long whether or not the name exists. and passwords/matches? compares the two digests over every byte, whichever differs first, so the time a refusal takes says nothing about how much of a guess was right. the answer is the same in both cases: a name and a password that do not match. only after the password matches does the action look at whether the address is confirmed, so only the holder of the password learns that an unconfirmed account exists.
too many tries
a module limits how often one client may call an action by naming it in rate_limits, with a count per a window of seconds, minutes, hours or days. the guestbook's session module allows ten sign-in attempts in three minutes. senro tells clients apart by the address at the other end of the connection, which kanso's net/peer reads, and keeps the times of each client's calls to each limited action in the server's memory. a restart clears them, and two servers count separately. once the server holds more than web/sweep_past clients, 1,024, it forgets every one whose calls have all aged out of their window, so a server that hears from many addresses keeps only the ones still being counted. web/remembered says how many it holds.
a call past its limit is answered by the module's over_limit when it has one, and by a bare 429 when it does not. the guestbook's shows the sign-in form again, with a 429 and a line asking the visitor to wait. a name in rate_limits that is not an action of the module, or a limit senro cannot read, is refused when the app compiles:
error[derive]: `5 a minute` is not a count per a window, such as "10 per 3 minutes"
--> ./door.kso:1:5
1 | pub rate_limits = { "create":"5 a minute" }
^
too many wrong passwords
the rate limit counts by address, so it does nothing against guesses spread over many addresses. the guestbook also locks an account after three wrong passwords in a row, as devise's lockable does (session/locks_after). two columns hold the state: failures, the length of the run, and locked, the moment the run became long enough. one update statement counts a wrong password and, on the third, stamps the lock and returns it, and the action then mails the account a link to unlock it.
a locked account answers a wrong password the way every wrong password is answered, so a guesser learns nothing from it. only the right password is told that the account is locked, the same rule that keeps an unconfirmed account's existence to the holder of its password. the lock ends by itself after an hour (session/locked_minutes), and a right password, or a password reset, ends a run of wrong ones.
the link leads to the unlocks resource, which follows confirmations: GET shows a button and changes nothing, and the button's PATCH clears the lock. the token is bound to the moment of the lock, so it works once.
import "../../models"
import "../../pg"
import "../../tokens"
import "../../web"
import "./account_mailer"
import "./views"
import "std/list"
import "std/time"
# The link in an unlock mail leads here. As with confirming an address,
# following it shows a button and the button does the work, so a mail
# scanner that fetches every link it sees unlocks nothing. The `id` the
# member routes take is the token `account_mailer/unlock` signed over the
# moment of the lock.
pub without_sign_in = ["edit" "update"]
pub fn edit req db id
held db id .> (good -> offered req id good)
fn offered req id true
effect (shown req id)
fn offered _ _ false
effect (told stale)
stale = "That link has expired or was used."
# Unlocking clears the lock and the count of wrong passwords, which also
# retires the link: it was bound to the moment of the lock.
pub fn update req db id
held db id .> (good -> opened req db id good)
fn opened _ _ _ false
effect (told stale)
fn opened _ db token true
id = id_text (tokens/id_of token)
pg/query db cleared [id]
.> (_ -> effect (told "Your account is unlocked. Sign in."))
cleared = "update accounts set failures = 0, locked = '' where id = $1"
# Whether `token` is good now for the account it names.
fn held db token
id = id_text (tokens/id_of token)
pg/query db looked [id]
.> (r -> time/now .> (t -> effect (good? token (list/first r.rows) t)))
looked = "select locked from accounts where id = $1"
fn good? _ none _
false
fn good? token row t
account_mailer/unlocks? token (models/cell row "locked") t
fn id_text none
"0"
fn id_text n
"{n}"
fn told notice
web/noticed (web/redirect "/") notice
fn shown req id
page = views/unlock_offered_view "/unlocks/{id}"
framed = views/layout_view "Unlock your account · Guestbook" page
views/answer_as req 200 framed
where sign-ins came from
the guestbook keeps what Devise's trackable keeps: how many times an account has signed in, and when and from which address the latest sign-in and the one before it came. five columns hold them, each added by a migration of its own. the statement that ends a run of wrong passwords also counts the sign-in, moves the latest time and address to the last_ columns and stamps the new ones, so a sign-in is recorded in one write. the time is the app's clock, written as UTC to the minute. the address is web/peer req, the address the connection came from, which behind a proxy is the proxy's.
the account page shows the count and, from the second sign-in on, when and from where the one before came. a holder who does not recognize that time or address has reason to change the password, which ends every other sign-in. cleared in the session panel above writes the columns, and arrivals in the account panel reads them.
pages that ask for a sign-in
every action asks for a signed-in visitor unless its module names it in without_sign_in, as devise's authenticate_user! does for a Rails controller. the guestbook opens its home page, its list of entries, and the forms for making an account and signing in; writing an entry needs a signed-in visitor, and its action no longer checks for one itself. the entry asks for this once, when it serves its routes:
import "../../jobs"
import "../../kata"
import "../../mail"
import "../../models"
import "../../pg"
import "../../routes"
import "../../web"
import "./account"
import "./account_mailer"
import "./confirmations"
import "./entries"
import "./home"
import "./migrations"
import "./removal"
import "./resets"
import "./session"
import "./signatures"
import "./unlocks"
import "./views"
# The components whose actions run on the server.
served = { "removal":(web/component removal/act removal/view) }
acting = web/components routes entries/secret served
bound_of = session/signed_against
served_routes = web/requiring_sign_in acting entries/secret bound_of
web/requiring_sign_in routes secret bound_of wraps every action in the table, and a component's action is one of them, so the table goes through web/components first. an action that is not open asks for a sign-in. a visitor who has not signed in is sent to /session/new, and on a GET the path they asked for is kept in the session as return_to. once the password matches, session/create sends them back there with web/return_to req secret fallback, which answers the fallback when nothing was kept. a name in without_sign_in that is not an action of the module is refused when the app compiles.
a sign-in rests on something the account holds, which the guestbook takes to be the password's digest. session/create hands it to web/signed_in, which keeps an HMAC of it in the session beside the viewer's name. at every action, bound_of db name reads what the account holds now, and the guestbook's session/signed_against reads the digest. when the HMAC does not match it, the sign-in is over. an action that asks for a sign-in sends the visitor to the sign-in form. an open one, such as the book's front page or a component's remove button, runs as if nobody had signed in, and its answer tells the browser to forget the sign-in, so a stolen cookie cannot act through an open action either. setting a new password changes the digest, so it ends every sign-in made before it, on every browser, as devise does. an app whose sign-ins rest on nothing passes web/unbound as bound_of and "" to web/signed_in.
changing a password
a signed-in visitor changes the password from the account page, as Devise's registrations edit lets them. account/edit answers GET /account/edit with a form that asks for the current password and a new one, and account/update answers PATCH /account. it checks the current password with passwords/matches?, ten tries in three minutes like signing in, and sets the new digest. that ends every sign-in that rested on the old digest. the browser that made the change is signed in again on the new digest, keeping whatever else its session held, so it stays signed in and the others do not.
sending mail
senro's mail module sends plain-text mail. mail/composed from to subject body makes a message, and mail/delivered message sends it the way the SENRO_MAIL environment variable says:
unset or "" standard error, for development
dir:<path> a file of its own under <path>
smtp://user:pass@host:587 SMTP, after STARTTLS
smtps://user:pass@host:465 SMTP inside TLS from the first byte
smtp+insecure://host:1025 SMTP in the clear, for a mail catcher
smtp:// refuses a server that does not offer STARTTLS rather than send in the clear, because a reset link read off the wire is as good as a password. a header that holds a line break is refused before anything is sent, so a subject typed into a form cannot add headers of its own. a subject that is not ASCII travels as RFC 2047 encoded words, and a body that is not ASCII, or has a line longer than SMTP allows, travels as base64.
the guestbook does not send its mail during the request. it queues each letter with mail/delivered_later, which refuses what mail/delivered would refuse and writes the rest to the jobs table, and a worker beside the server sends it a moment later. the background jobs guide covers the table and the worker.
a lost password
a visitor who has lost a password follows "Forgot your password?" on the sign-in form to the guestbook's resets resource, which plays the part of Rails 8's generated passwords controller. resets/new asks for an address, and resets/create mails a link to it when an account has that address. the answer is the same either way, and so is the answer when the mail cannot be queued, so the form never says which addresses have accounts. the link leads to resets/edit, and resets/update sets the new password.
import "../../models"
import "../../passwords"
import "../../pg"
import "../../web"
import "./account_mailer"
import "./entries"
import "./views"
import "std/io"
import "std/list"
import "std/text"
import "std/time"
# A visitor who has lost a password asks for a link by email, follows it,
# and chooses a new one, as Rails 8's generated PasswordsController has it.
# The link carries a token `passwords/reset_token` signs over the account
# and the digest it keeps, so nothing is stored, and a link works once and
# for fifteen minutes.
pub without_sign_in = ["create" "edit" "new" "update"]
# Asking is limited as signing in is, so the form cannot be used to fill
# someone's inbox.
pub rate_limits = { "create":"10 per 3 minutes" }
pub fn new req _
effect (asked req 200)
# The answer is the same whether or not the address has an account, and
# whether or not the mail could be queued, so the form says nothing about
# which addresses are registered. A failure to queue goes to standard error,
# and the worker logs a failure to send.
pub fn create req db
f = web/form req.body
email = text/lowercase (text/trim (web/value f "email"))
pg/query db holder [email]
.> (r -> sent db (list/first r.rows) email)
.? (u -> io/write_err "guestbook: no reset mail: {u.reason}\n")
.> (_ -> effect (told "/" on_its_way))
holder = "select id, digest from accounts where email = $1"
on_its_way = "If that address has an account, a link is on its way to it."
fn sent _ none _
effect ""
fn sent db row email
id = models/cell row "id"
account_mailer/reset db id email (models/cell row "digest")
# The `id` a reset's member routes take is the token itself.
pub fn edit req db id
checked db id .> (good -> offered req id good)
fn offered req token true
effect (chosen req token {} 200)
fn offered _ _ false
effect (told "/resets/new" expired)
expired = "That link has expired or was used. Ask for another."
# A new password of eight characters or more, set through a link that is
# still good. Setting it changes the digest, which is what retires the link.
pub fn update req db id
password = web/value (web/form req.body) "password"
checked db id .> (good -> chose req db id password good)
fn chose _ _ _ _ false
effect (told "/resets/new" expired)
fn chose req db token password true
short = { "password":"Use eight characters or more." }
return effect (chosen req token short 422) if length password < 8
id = id_of (passwords/reset_id token)
passwords/digested password
.> (d -> time/now .> (t -> pg/query db reset [d id "{t}"]))
.> (_ -> effect (told "/" changed))
# A reset link proves the address as well as a confirmation link does, so
# setting a password through one confirms an address that was not yet, and
# it unlocks an account that wrong passwords locked, as Devise's does.
reset = """
update accounts set digest = $1,
confirmed = case when coalesce(confirmed, '') = '' then $3 else confirmed end,
failures = 0, locked = ''
where id = $2
"""
changed = "Your password is changed. Sign in with it."
# Whether `token` is good now for the account it names.
fn checked db token
id = id_of (passwords/reset_id token)
pg/query db digest_by_id [id]
.> (r -> time/now .> (t -> effect (good? token (list/first r.rows) t)))
digest_by_id = "select digest from accounts where id = $1"
fn good? _ none _
false
fn good? token row t
kept = models/cell row "digest"
passwords/reset_valid? entries/secret token kept t
fn id_of none
"0"
fn id_of n
"{n}"
# A redirect carrying a notice for the page it lands on.
fn told to notice
web/noticed (web/redirect to) notice
fn asked req code
page = views/reset_asked_view (web/notice req) {}
framed = views/layout_view "Forgot your password · Guestbook" page
views/answer_as req code framed
fn chosen req token problems code
page = views/reset_chosen_view "/resets/{token}" problems
framed = views/layout_view "A new password · Guestbook" page
views/answer_as req code framed
the link carries a token from passwords/reset_token: the account's id, the moment the token stops working, and an HMAC-SHA-256 over both and the digest the account keeps now. it stands in the route as the member's id. nothing is stored. setting the new password changes the digest, so the link works once, and it stops working fifteen minutes after it was made. passwords/reset_valid? checks a token against the digest the account holds when the link is followed, comparing the signatures over every byte.
the guestbook's mail is written in account_mailer.kso, which holds each letter's text and builds its link with mail/url_for. a module whose name ends in _mailer makes the app's mail, as a Rails mailer does, and none of its functions is a route, so several resources can call it. the link starts with SENRO_URL and never with the request's Host header. whoever sends a request chooses that header, and a reset link built from it would carry the token to a site of their choosing.
confirming an address
a new account confirms its address before it can sign in, as devise's confirmable asks. sign-up mails a link and sends the visitor to the front page with a notice. signing in with the right password before following it is answered with a 403 and a request to confirm first, and the link is sent again.
import "../../mail"
import "../../passwords"
import "../../tokens"
import "./entries"
import "std/text"
import "std/time"
# The guestbook's mail, as a Rails mailer holds it: what each letter says,
# and the signed link it carries, which starts with `mail/url_for`. Each
# letter is queued on `db` and sent by the worker beside the server. First, a
# link to choose a new password, bound to the digest the account keeps, so
# it works once.
pub fn reset db id email kept
time/now
.> (t -> mail/url_for "/resets/{reset_token id kept t}/edit")
.> (link -> mail/delivered_later db (reset_letter email link))
fn reset_token id kept t
passwords/reset_token entries/secret id kept t
fn reset_letter email link
lines = [
"Someone asked to reset the password of your guestbook account."
""
"Choose a new one here, within fifteen minutes:"
""
link
""
"If it was not you, there is nothing to do: your password is unchanged."
]
subject = "Reset your guestbook password"
mail/composed entries/sender email subject (text/join lines "\n")
# A link to confirm the address an account was made with. It is bound to the
# address and to the account's `confirmed` column, which is empty until the
# link is followed, so it works once, and not at all once the address
# changes. It lasts a day.
pub fn confirmation db id email
time/now
.> (t -> mail/url_for "/confirmations/{confirm_token id email t}/edit")
.> (link -> mail/delivered_later db (confirm_letter email link))
fn confirm_token id email t
tokens/made "confirm" entries/secret id (bound email "") t 1440
# Whether `token` confirms the account that holds `email` and `confirmed`
# now, at `t`.
pub fn confirms? token email confirmed t
tokens/valid? "confirm" entries/secret token (bound email confirmed) t
fn bound email confirmed
"{email} {confirmed}"
fn confirm_letter email link
lines = [
"Welcome to the guestbook."
""
"Confirm your address here, within a day, and then sign in:"
""
link
""
"If you did not make an account, there is nothing to do."
]
subject = "Confirm your guestbook address"
mail/composed entries/sender email subject (text/join lines "\n")
# A link to unlock an account that too many wrong passwords locked. It is
# bound to the moment of the lock, so it works once, and not at all once the
# lock has ended.
pub fn unlock db id email stamp
time/now
.> (t -> mail/url_for "/unlocks/{unlock_token id stamp t}/edit")
.> (link -> mail/delivered_later db (unlock_letter email link))
fn unlock_token id stamp t
tokens/made "unlock" entries/secret id stamp t 1440
# Whether `token` unlocks the account whose lock began at `stamp`, at `t`.
pub fn unlocks? token stamp t
tokens/valid? "unlock" entries/secret token stamp t
fn unlock_letter email link
lines = [
"Your guestbook account was locked after too many wrong passwords."
""
"It unlocks by itself within the hour, or at once here:"
""
link
""
"If the wrong passwords were not yours, someone may be guessing it."
]
subject = "Unlock your guestbook account"
mail/composed entries/sender email subject (text/join lines "\n")
the link leads to confirmations/edit, which shows a button and changes nothing. mail scanners fetch every link in a message to check it, and a link that confirmed on a GET would be confirmed by the scanner. the button sends confirmations/update, which stamps the account's confirmed column.
import "../../models"
import "../../pg"
import "../../tokens"
import "../../web"
import "./account_mailer"
import "./views"
import "std/list"
import "std/time"
# An account whose address a good token would confirm.
type unconfirmed
id
email
# The link in a confirmation mail leads here. Following it shows a button,
# and the button confirms the address: a GET changes nothing, so a mail
# scanner that fetches every link it sees confirms nobody. The `id` the
# member routes take is the token `account_mailer/confirmation` signed.
pub without_sign_in = ["edit" "update"]
pub fn edit req db id
held db id .> (a -> offered req id a)
fn offered _ _ none
effect (told stale)
fn offered req id a
effect (shown req id a.email)
stale = "That link has expired or was used. Sign in to have it sent again."
# Confirming stamps the account with the moment, which also retires the link:
# it was bound to the column being empty.
pub fn update req db id
held db id .> (a -> confirmed req db a)
fn confirmed _ _ none
effect (told stale)
fn confirmed _ db a
time/now
.> (t -> pg/query db stamped ["{t}" a.id])
.> (_ -> effect (told "Your address is confirmed. Sign in with it."))
stamped = "update accounts set confirmed = $1 where id = $2"
# The account a token confirms now, or none when the token is no good for
# the account it names.
fn held db token
id = id_text (tokens/id_of token)
pg/query db looked [id]
.> (r -> time/now .> (t -> effect (checked token id (list/first r.rows) t)))
looked = "select email, confirmed from accounts where id = $1"
fn checked _ _ none _
none
fn checked token id row t
email = models/cell row "email"
was = models/cell row "confirmed"
good = account_mailer/confirms? token email was t
if good (unconfirmed id email) none
fn id_text none
"0"
fn id_text n
"{n}"
fn told notice
web/noticed (web/redirect "/") notice
fn shown req id email
page = views/confirm_offered_view "/confirmations/{id}" email
framed = views/layout_view "Confirm your address · Guestbook" page
views/answer_as req 200 framed
the token is made by tokens/made, the signing a reset token uses too, for the purpose "confirm" and bound to the address and to the confirmed column being empty. so it works once, it stops working if the address changes, and it lasts a day. a token made for one purpose opens nothing else. a password set through a reset link confirms the address as well, since following that link proves the visitor reads its mail.
requests from other sites
a page on another site can hold a form that posts to the app, and a visitor's browser would send it with their session. senro refuses such a request before any action sees it. any request but a GET or a HEAD is answered with a 403 when the browser says another site sent it: its Sec-Fetch-Site header is anything but same-origin or none, or, from a browser too old to send that header, its Origin is not the app's own. the session cookie is also SameSite=Lax, so a browser should not carry it on such a request at all. this is the check Rails' protect_from_forgery makes, done with the headers a browser sets instead of a token in every form. a request with neither header comes from a program, which has no visitor's cookie to borrow, and is let through.
every answer also carries the headers Rails sends by default. x-frame-options: SAMEORIGIN keeps another site from showing the app's pages in a frame, where a visitor could be tricked into clicking them. x-content-type-options: nosniff has the browser take a body as the type it was sent as, referrer-policy: strict-origin-when-cross-origin keeps a link to another site from telling it the path it was followed from, and x-permitted-cross-domain-policies: none and x-xss-protection: 0 turn off two old browser features. an action that sets one of these itself, such as a page meant to be framed, keeps its own.