accountsaddon
Who a connection is, settled before anything it sends reaches a truth.
Using it
The transport's seat function is wrapped, so a connection is checked before anything it sends reaches a truth. Each connection is seated on the entry scene at once and sent its packets from the first step, so a login screen can be drawn. What it sends is held back: a request to log in is answered by the addon and never passed on, and every other message is dropped, with no answer, until the connection is logged in. Three lines name the transport:
// on the server
serve(port, accounts(accept(row), store, rules))
// on a page that dials the server
link(signin(dial(url), keep))
// on a page that holds the server in its own tab
link(signin(loopback(local(accept(row)))))
The rules are the manager scene's accounts prefab, handed to accounts whole. Every child scene inherits it, so a page reads the same rules from plugins.wire.scene.prefabs.accounts and can tell a player a name is too short before asking. Nothing in them is secret.
"prefabs": {
"accounts": {
"name": { "min": 3, "max": 20, "pattern": "^[a-z0-9_]+$" },
"secret": { "min": 8, "max": 128 },
"scrypt": { "N": 16384, "r": 8, "p": 1, "keylen": 64, "salt": 16 },
"token": { "bytes": 32, "ttl": 2592000000 },
"limit": { "connection": 20, "name": 5, "window": 60000 }
}
}
Properties
Read from the accounts prefab. Every value is checked when the server starts.
.name : { min, max, pattern }
The length of a name and a regular expression it must match. Both apply to the folded name: a name is converted to NFKC form and lower case before anything reads it, so Bill and bill are one account, and the folded name is the one kept. min and max are whole numbers with min at least 1 and max at least min. The pattern is read with the unicode flag.
.secret : { min, max }
The length of a secret, as whole numbers with min at least 1 and max at least min.
.scrypt : { N, r, p, keylen, salt }
How a secret is hashed.
| Key | Meaning |
|---|---|
N | The cost. A power of two, 2 or more. |
r, p | The block size and the parallelism. |
keylen, salt | The bytes in the hash and in the salt. |
Each account keeps the numbers it was hashed with, so raising these later still checks every older account. All are whole numbers of 1 or more.
.token : { bytes, ttl }
bytes is the random bytes in a token, 16 or more. ttl is the milliseconds a session lasts after it is made, a whole number of 1 or more.
.limit : { connection, name, window }
The most asks one connection may make, and the most failed logins one name may have, in any window milliseconds. Every kind of ask counts toward the connection's. A name's failures count from any connections. Both are counted before any hashing, so a flood costs no hashing. An ask past a limit is refused with slow. The counts are kept in the server process, and a limit per address belongs to the transport. A name being guessed at is shut to secrets for a window, but a page that kept its token still logs in with it.
What a page sends
The page numbers each ask with ask, and the answer names it. The page makes one ask at a time, as the server answers one at a time.
| Ask | Meaning |
|---|---|
{ ask, register: { name, secret } } | Makes an account and logs the connection in as it. |
{ ask, login: { name, secret } } | Logs the connection in. |
{ ask, token } | Logs in again with the token of an earlier login. |
A register or login whose name or secret is not a string is dropped, as any malformed message is.
What the server answers
{ signed: { ask, account, token } } says the connection is logged in as the account's uid. The token logs the page back in later. A token logs in once: that login ends its session and hands back a new token, so a stolen token and the page's own copy cannot both be used. Each token is random, stored only as a SHA-256 hash, and lasts token.ttl milliseconds. Expired sessions are removed when the server starts and whenever the account logs in again.
{ refused: { ask, why, wait? } } says the connection is not logged in:
| why | Meaning |
|---|---|
wrong | No account has that name and secret. One answer covers both, so a login never says which names exist. |
taken | Register: an account has the name. Two registers of one name at once make one account. |
name | Register: the name does not fit name's length or pattern. |
secret | Register: the secret's length is outside secret's. |
token | The token is unknown or expired. The page forgets it. |
slow | Too many asks. wait is the milliseconds until one more is let through. |
busy | An ask of this connection's is still being answered. |
signed | The connection is logged in already. |
down | The store failed. The error is logged, never the ask. |
elsewhere | Sent with ask 0 to a connection whose account has just logged in on another. Its seat is let go and it is logged out, so one account is never seated twice. It may log in again and is seated on the entry scene anew. |
Accounts and secrets
An account is a record of kind account, keyed by a uid, in the same store the persist addon uses. It keeps the name, a salt and the scrypt hash of the secret, and the scrypt numbers used. A session is a record of kind session keyed by the token's hash. No game names either kind. A game's own records name an account by its uid, never by its name.
The secret is read off the ask, hashed and let go. It never enters a truth, a row, a record, a log or an error. A login with an unknown name takes as long as one with a wrong secret. A secret longer than secret.max is no account's.
The plain WebSocket carries the secret as it was typed, to anyone on the path. The server serves wss when it is given a certificate, and a page on it dials wss://.
Methods
From #addons/accounts/accounts.addon.ts.
The server's side
accounts( accept, store, rules, logged? ) : Accept
Wraps the server's side of a seat, accept from the loopback addon, and returns one of the same shape for a transport to serve. store is the persist addon's, and the server hands the same one to both. rules is the accounts prefab. logged(uid, seat) is called the moment a connection is logged in.
account( seat ) : string | undefined
The account uid a seat's connection is logged in as, or undefined before a login. The seat is the one the wrapped accept made.
LOCAL : 'local'
The account every connection has under local below.
local( accept, logged? ) : Accept
For a page that runs the server and every truth in its own tab. There is no password and no store. Every connection is logged in at once as the one local profile, account local, and every ask is answered with it. The profile belongs to the tab, as its IndexedDB does, so every record a game files under it is that browser's. This is a function of the accounts addon, not the local plugin.
The page's side
signin( wire, keep? ) : Wire
Wraps the page's wire before link is given it. The answers are taken off the wire, so view is only ever handed the manager's messages, and the link gets a door for the four calls below. keep is where the token lives between loads of the page: { get(): string | null, set(token: string | null) }. Each login's token is set there, and a token refusal sets null. Without keep no token is kept.
login( link, name, secret ) : Promise<Result>
Logs in. The promise gives { account } or { why, wait? }. why is a refusal above, or closed if the link closed first.
register( link, name, secret ) : Promise<Result>
Makes an account and logs in as it. It gives the same result as login.
relogin( link ) : Promise<Result>
Logs in with the token keep.get() returns. With no token kept, it resolves at once to { why: 'token' }.
who( link ) : string | undefined
The account uid the page is logged in as, or undefined. It is cleared by an elsewhere refusal.
Needs
- loopbackThe seat function it wraps and the wire
signinwraps. - persistThe store its accounts and sessions are kept in.
- scenemanagerThe link it puts the door on. The manager reads a connection's account from the seat.