๐ค password
amadan.net/rastrillo/rastrillo/password
An email-and-password identity plugin over the sessions core. Storage, rendering and CSRF stay with the app.
Passwords is the guide.
New and Config
func New(cfg Config) (*Handlers, error)
New validates the configuration at boot, so you find out then rather
than on the first request. It errors when Sessions, Lookup or
RenderSignin is missing, and when Create is set without
RenderSignup.
type Config struct {
Sessions *sessions.Sessions
Lookup func(ctx context.Context, email string) (id int64, hash string, err error)
Create func(ctx context.Context, email, hash string) (int64, error)
SignedInPath string
SecondFactor func(w http.ResponseWriter, r *http.Request, sess sessions.Session) (done bool, err error)
RenderSignin func(w http.ResponseWriter, r *http.Request, d PageData)
RenderSignup func(w http.ResponseWriter, r *http.Request, d PageData)
Logger *slog.Logger
}
Lookup receives an email already lowercased and trimmed, and returns
sql.ErrNoRows for an unknown address โ treated identically to a wrong
password, verified against a decoy hash so the timing does not differ.
Create stores a new user. An error wrapping ErrRefused is a policy
refusal: Signup renders the refusal's own message verbatim at 403. Any other
error reads as a duplicate email, the only realistic failure for a
unique-email store. Leave it nil and signup is disabled entirely:
SignupPage and Signup both 404.
var ErrRefused = errors.New("rastrillo/password: signup refused")
func Refuse(msg string) error
Refuse builds a refusal carrying visitor copy. A membership layer is
the motivating caller: telling an uninvited visitor that their address
is already registered is simply false.
The 403 is not free. It is distinguishable from the 422, so a refused
address and a registered one no longer look the same to a prober โ the
duplicate message made them identical. That is the trade the seam
accepts: a true answer that can be told apart beats a false one that
cannot. An app that would rather not make the distinction leaves
Create nil and closes signup outright.
Write one refusal message that fits every refused address, and never
interpolate the submitted address into it. Nothing enforces that, and a
message that varies by address turns the 403 from a single outcome into
a finer oracle than the status alone. The message a wrapper adds is
never rendered โ only the string handed to Refuse โ and an empty one
renders the package's own generic copy rather than a blank page.
The refusal costs a rate-limiter unit, because Hash runs before
Create; it is charged to the refusal budget, not the shared one (see
Rate limiting below).
SignedInPath is where a fresh session lands absent a same-site
return_to; it defaults to /.
SecondFactor is called at the exact point a verified password would
mint the session, with the session that would be minted. Return
done=true when the hook took over the response, false when sign-in
should proceed unchanged. Nil is the plain behaviour.
passkey.Handlers.Gate is the shipped implementation.
Handlers
type Handlers struct{ /* unexported */ }
The verbs are enforced:
| Method | Verb | Notes |
|---|---|---|
Handlers.SigninPage |
GET | renders the form |
Handlers.Signin |
POST only | 405 otherwise |
Handlers.SignupPage |
GET | 404 when Create is nil |
Handlers.Signup |
POST only | 405 otherwise; 404 when Create is nil |
Handlers.Signout |
POST only | 405 otherwise |
Sign-out being POST-only is deliberate: a GET sign-out can be triggered by any image tag on any page.
PageData
type PageData struct {
Error string
Email string
ReturnTo string
}
What both render callbacks receive โ enough to re-render the form with the address still filled in and the problem stated.
Your callback must not write a status. password has already written
it before calling โ 422, 403 or 429, depending on the outcome.
Rate limiting
There are two per-email budgets, each 10 failures in 15 minutes, answering 429 until one ages out. A successful sign-up resets both; a successful sign-in resets the shared one.
The shared budget is spent by wrong credentials at sign-in and by the duplicate-email answer at sign-up, and it gates both doors. They share it on purpose: sign-up leaks the same fact sign-in does, whether an address is registered, so letting an attacker switch endpoints for a fresh allowance would defeat the limit.
The refusal budget is spent only by a Create policy refusal, and
gates sign-up alone. A refusal has to cost something โ Hash runs
before Create, so an unmetered refusal path is a PBKDF2 amplifier โ
but it must not cost the shared budget. A refused address is one nobody
has an account for yet; charging sign-in for it would let a stranger
who merely knows an invitee's address post ten refused signups and hold
that address at 429 on both doors, renewing it at about one request
every 90 seconds. Signin never consults it.
Both are in-memory, and so per-process. IP-level throttling belongs to the deployment.
Hashing
func Hash(password string) (string, error)
func Verify(encoded, password string) bool
func NeedsRehash(encoded string) bool
Hash and Verify handle the encoding, and the parameters are pinned
by a test so they cannot drift quietly. Verify answers false for a
garbage or truncated encoding instead of erroring, so a corrupt stored
hash is indistinguishable from a wrong password.
NeedsRehash tells you whether a stored hash predates the current
parameters, so you can upgrade it transparently at the next successful
sign-in, the only moment you have the plaintext.
Read this page as markdown โ exact, unstyled, and cheap for an agent to fetch.