Passwords
Passwords are optional. Turn them on when the app starts:
let assert Ok(identity) = auth.with_passwords(identity)
Without that call the password operations answer 403, and the password
pages do not exist. Email tokens keep working for everyone, including people
who have a password: the two are alternative ways in, not two factors.
For a walk through a whole app, see Username and password sign-in.
Registering
POST /password/register takes an address and a password:
{"email": "ada@example.com", "password": "a long and unusual passphrase"}
It answers 202 and emails a token. The account and its password only exist
once that token is exchanged at POST /session or POST /token, so an
account cannot be made for an address its owner does not control. Until
then only the password's hash is kept.
Registering an address that already has an account never replaces its
password, and never attaches one to an account that signs in by email. The
owner gets an AlreadyRegistered email instead; see
Sending the email.
Because this request cannot be answered by a token already in the inbox, repeating it for one address is slowed down instead: a 60 second wait after the first, doubling up to an hour.
Signing in
-
POST /password/sessionwith{"email":"…","password":"…"}answers with the user and sets the session cookie. Like/session, it requires yourorigin. -
POST /password/tokentakes the same body and answers with a bearer token.
A wrong password, an unknown address, an account without a password and a
suspended account all get the same 401, after the same amount of work, so
the answer does not reveal which addresses have accounts.
Setting, changing and resetting
One operation covers all three:
POST /password
{"password": "a new long and unusual passphrase"}
It only works from a session that was created by exchanging an email token
within the last ten minutes. A session made with a password, or an older
one, gets 403. So whether somebody has forgotten their password, wants a
new one, or signed up by email and now wants a password, the steps are the
same:
-
Ask for a sign-in token at
POST /login. -
Exchange it at
POST /session. -
Send the new password to
POST /password.
Every other session of that user is ended. The starter /auth/account page
does step three, and also lists sessions and signs out.
There is no "current password" check here, because the fresh email token is stronger proof than the password it replaces.
Changing with the current password
A user who knows their password can change it from any live session, without an email round trip:
POST /password/change
{"current": "the old passphrase", "password": "a new long and unusual passphrase"}
This also works for apps without email tokens. A wrong current password is
a 400, not a 401, since the session itself is fine. Each guess counts
against the same limits as a sign-in, so a borrowed session cannot search
for the password any faster than the sign-in form allows. An account with
no password gets 403, and uses POST /password instead.
Every other session and remembered device is ended, and
the address is sent a PasswordChanged notice, whose token is empty. Tell
the reader to reset their password from a fresh email sign-in if the
change was not theirs; see Sending the email.
What makes a password acceptable
-
At least 15 characters, counted in Unicode code points, and at most 1024
bytes. The minimum is
password_min_lengthin the policy, and cannot go below 8. - Not on the built-in list of common passwords, and not an obvious shape: a common word with decoration, one unit repeated, a keyboard run, or very few distinct characters.
- Passwords are never trimmed. They are normalized to Unicode NFC, so the same password typed on different keyboards matches.
A refusal is a 400 with a message you can show.
The built-in list is small. To check against a corpus of breached passwords, load one at startup:
import howdy/auth/password_check
let identity =
auth.with_password_check(identity, password_check.blocklist(corpus))
corpus is a list of passwords. The checker keeps only their SHA-256
digests, and sends nothing over the network. For anything else, pass your
own fn(String) -> service.Result(Nil); it receives the new password, and
an error refuses it. The check applies when a password is set, not when an
existing one is used to sign in.
Guessing
Each pair of address and client gets five attempts. After that, each
failure must wait: 60 seconds at first, doubling up to an hour. A success
clears the history for that pair only, and resetting the password clears it
for the address. A second, higher limit of 100 attempts a minute per
address catches an attacker who keeps changing client. All four numbers are
in the policy, and a refusal is a 429.
The limits follow the client the routes identify, so
behind a proxy tell them which header to read.
Failed attempts on real accounts are recorded in the audit trail as
login.failed.
Storage
Passwords are hashed with Argon2id: a fresh 16 byte salt, 19 MiB of memory, two iterations. Only the encoded hash is stored. A successful sign-in quietly upgrades a hash made with weaker settings.
Hashing protects the passwords. It does not encrypt email addresses, the audit trail, the database or its backups.
Without HTTP
-
auth.register_password_from(identity, email, password, client) -
auth.login_password_from(identity, email, password, client)returns the sameauth.Sessionasauth.exchange -
auth.set_password(identity, principal, password) -
auth.change_password_from(identity, principal, current:, new:, client:) -
auth.login_password_step, when second factors are on
As elsewhere, register_password and login_password without _from
share one client bucket between all callers.
