Manage Passwords
The shop wants passwords of at least twelve characters, a sign-up form that says at once that a
username is taken, an account page where a customer changes the password and sees the ways they
can sign in, and a limit on guessing. All of it is configuration and requests to resources the
gateway already serves under /identity/basic/.
§Set the rules for usernames and passwords
# context.toa.yaml
configuration:
identity.basic:
username:
- ^S{3,64}$
password:
- ^.{12,64}$
- d Each key holds a list of regular expressions, and a value must match every one of them. Without the setting, a username is 1 to 128 characters and a password 8 to 32. The Context is read when the application is restarted:
$ npm run restart A request to the sign-up route with a password that is too short is refused:
POST /accounts/ HTTP/1.1
host: api.store.example
authorization: Basic Ym9iOnNob3J0
content-type: application/yaml
accept: application/yaml
name: Bob 422 Unprocessable Entity
code: INVALID_PASSWORD A username that breaks the rules is refused with the code INVALID_USERNAME. A password may
contain colons.
§Tell whether a username is free
GET /identity/basic/usernames/Ym9i/ HTTP/1.1
host: api.store.example The last segment is the username, Base64 URL encoded. The reply is 204 No Content when the
username is taken and 404 Not Found when it is free. Send no credentials with it.
§Change the password
PATCH /identity/basic/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Basic Ym9iOnNlY3JldCMxMjM0
content-type: application/yaml
password: new-secret#5678 200 OK The body holds username, password or both. The request is authenticated with the current
password: have the user type it. A token is not enough:
403 Forbidden
Basic authentication scheme is required to access this resource - The identity must be the one in the path, or hold the role
system:identity:basic. Anyone else is answered403. - A value that breaks the rules is answered
422. - The change signs the user out on every device: tokens issued before it stop working within
refreshseconds, and custom tokens of the identity are revoked.
§Add a password to an identity
A guest, or a user who signed in with Google or a passkey, adds a username and password to their identity:
POST /identity/basic/332017649c814649b25ee466c1fe4534/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
content-type: application/yaml
username: alice
password: secret#1234 201 Created Any credentials of that identity admit the request. An identity has one username and password: a
second POST is answered 422 with the code EXISTS.
§Remove the password
DELETE /identity/basic/332017649c814649b25ee466c1fe4534/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi... 204 No Content A new one can be added afterwards.
§List the ways to sign in
The account page shows what the user can sign in with:
GET /identity/credentials/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
accept: application/yaml basic:
username: bob
federation:
- id: 09200d0bcc2448c5863e3f50ae18af84
iss: https://accounts.google.com
CREATED: 1754899200000
passkeys:
- id: 54ed430804324e81b39756ea4708b546
aid: adce0002-35bc-c60a-648b-0b25f1f05503
synced: true
label: Personal iPhone
CREATED: 1754899200000 Only the identity itself may read it. basic is null when there is no password; federation and passkeys are always lists. Nothing secret is returned.
federation[].issis the provider. Map issuers to names and logos in the client.passkeys[].aididentifies the model of the authenticator (its AAGUID), which a client can look up in a catalog to show a name and an icon.syncedsays the passkey is backed up and likely available on more than one device.- Each
idis what theDELETEroutes of providers and passkeys take.
Tokens and emailed codes are not listed.
§Limit password guessing
Failed sign-ins can be counted per client address, whatever the way of signing in. It is off until the Context asks for it:
# context.toa.yaml
exposition:
ip: cf-connecting-ip
bouncer:
attempts: 20
interval: 60 An address may fail attempts times in a row and earns them back at attempts per interval seconds. With nothing left, a request that carries credentials is answered:
429 Too Many Requests
retry-after: 30 attemptsdefaults to20andintervalto60;bouncer: {}takes both.bouncerrequiresip, the name of the header the client address is read from. See the client address.- A request without credentials is not counted, and neither is one whose address is unknown.
§Test it
The steps are the ones toa create wrote, which Write a Test explains.
The background signs a customer up at the sign-up route.
# features/passwords.feature
Feature: Passwords
Background:
When the request is sent:
"""
POST /accounts/ HTTP/1.1
authorization: Basic #{{ basic | set bob.basic }}
content-type: application/yaml
accept: application/yaml
name: Bob
"""
Then the response is received:
"""
201 Created
authorization: Token ${{ bob.token }}
id: ${{ bob.id }}
"""
Scenario: The password is changed with the current password
When the request is sent:
"""
PATCH /identity/basic/${{ bob.id }}/ HTTP/1.1
authorization: Token ${{ bob.token }}
content-type: application/yaml
password: new-secret#5678
"""
Then the response is received:
"""
403 Forbidden
"""
When the request is sent:
"""
PATCH /identity/basic/${{ bob.id }}/ HTTP/1.1
authorization: Basic ${{ bob.basic }}
content-type: application/yaml
password: new-secret#5678
"""
Then the response is received:
"""
200 OK
"""
When the request is sent:
"""
GET /identity/ HTTP/1.1
authorization: Basic ${{ bob.basic }}
"""
Then the response is received:
"""
401 Unauthorized
"""
Scenario: A password that breaks the rules is refused
When the request is sent:
"""
PATCH /identity/basic/${{ bob.id }}/ HTTP/1.1
authorization: Basic ${{ bob.basic }}
content-type: application/yaml
password: short
"""
Then the response is received:
"""
422 Unprocessable Entity
""" $ npm run restart
$ npm run features §An identity with a password and no account
POST /identity/basic/ creates an identity from a username and a password in the body. It takes
no credentials:
POST /identity/basic/ HTTP/1.1
host: api.store.example
content-type: application/yaml
accept: application/yaml
username: carol
password: secret#1234 201 Created
id: 9c1f0a6d5b7e4f3a8d2c1b0a9e8f7d6c The reply carries no token; the client signs in next with GET /identity/. A customer signs up
through the shop’s own sign-up route, which creates the account as
well.
| Reply | When |
|---|---|
201 Created | The identity is created. |
409 Conflict | The username is taken. |
422, code INVALID_USERNAME | The username breaks the rules. |
422, code INVALID_PASSWORD | The password breaks the rules. |
§The first administrator
A new deployment has no users, so nobody can give anybody a role. Name the username whose identity
receives the system role the moment it is created:
# context.toa.yaml
configuration:
identity.basic:
principal:
authority: local
username: root authority is the name of the domain the
username is created under; local is the name the generated Context gives localhost:8000.
Create it with POST /identity/ or POST /identity/basic/, and
continue with Give Staff Wider Access. The principal’s
username cannot be changed afterwards: the attempt is answered 422 with the code PRINCIPAL_LOCKED.
§How passwords are stored
Passwords are hashed with bcrypt. Two settings:
# context.toa.yaml
configuration:
identity.basic:
rounds: 10
pepper: $IDENTITY_BASIC_PEPPER pepper is a secret: write it as a $NAME reference and provide the value as Configuration describes. Set it before the first user signs
up, and do not change it: stored passwords are checked with it.
§Reference
§identity.basic
| Key | Meaning | Default |
|---|---|---|
username | Regular expressions a username must match, all of them. | ^.{1,128}$ |
password | Regular expressions a password must match, all of them. | ^.{8,32}$ |
rounds | bcrypt cost, 4 to 31. | 10 |
pepper | A secret mixed into every hash. | none |
principal | authority and username of the identity that is given system. | none |
§Ways to authenticate a request
authorization: <Scheme> <credentials> | Scheme | Credentials | See |
|---|---|---|
Basic | base64(username:password) | Sign Users Up and In |
Token | a token the gateway issued | Keep Users Signed In |
Bearer | an id_token of a trusted provider, or a token the gateway issued | Google or Apple, other apps |
Code | an authorization code of a trusted provider | Google or Apple |
OTP | base64(username:code) | emailed codes |
The scheme name is not case-sensitive. A passkey has no scheme: it signs in through a request of its own.
Whatever the scheme, the response carries a Token, and the client continues with that. Whatever
the scheme, a failure looks the same: credentials that are malformed, unknown, wrong or expired
leave the request without an identity, and a route that needs one answers 401 Unauthorized. So
does a scheme the gateway does not know.