CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 1. A client can now register, log in, manage its devices and set its profile; an operator can create accounts and flip the runtime switches. There are still no rooms and no real /sync. userapi: accounts, argon2id passwords in PHC format, devices, access and refresh tokens stored as SHA-256 hashes, account data and the admin flag. Its storage interface is storage.UserStore, implemented once in sqlstore with `?` placeholders; the postgres driver supplies a rebind function and unique-violation recognition rather than keeping a second copy of thirty statements. Timestamps in these tables are int64 milliseconds, the unit the Client-Server API uses on the wire. Client-Server API: register (user-interactive auth with m.login.dummy and optional registration tokens), register/available, token validity, login with m.login.password, logout, logout/all, refresh, account/whoami, capabilities, devices/* with password-gated deletion, profile/*, global and per-room account data. GET /sync is a deliberate placeholder: it answers a valid token with an empty but well-formed response and refuses a revoked one. Phase 1 has no rooms, so that is the whole truth, and it is what makes session revocation observable. Phase 4 replaces it with the syncapi. Auth middleware resolves a bearer token (header or the legacy query parameter) to a device and user, answering an expired token with the soft logout the spec asks for. Rate limiting is a token bucket per endpoint class keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms. adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix token, GET/PUT /federation and /registration driving internal/settings and reporting a config-pinned switch as a 409, GET/POST /users, GET /stats. warp create-account [--admin] creates the first account straight in the database, so a new install with registration off has a way in. Complement is wired up for the federation=off profile: an image meeting its contract, an entrypoint that configures Warp entirely from WARP_* variables, and a runner that builds, clones and executes the target set (TestRegistration, TestLogin, TestDeviceManagement). It has not been run yet -- there is no container runtime on the development machine -- so the three tests' assertions were read from the Complement sources and pinned as httptest tests instead, and CI gained a job that runs the real thing. Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern, meant for the built binary, also matched the directory server/cmd/warp and silently excluded the entire command package from the repository. The binary patterns are now anchored to the repository root, and cmd/warp is committed for the first time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
227 lines
7.7 KiB
Go
227 lines
7.7 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
)
|
|
|
|
// Errors the user store returns for the cases a caller has to tell apart.
|
|
// Everything else comes back wrapped and becomes a 500.
|
|
var (
|
|
// ErrUserExists is returned by CreateAccount for a taken localpart.
|
|
ErrUserExists = errors.New("user already exists")
|
|
|
|
// ErrNoSuchUser is returned for an unknown localpart.
|
|
ErrNoSuchUser = errors.New("no such user")
|
|
|
|
// ErrNoSuchDevice is returned for an unknown device of a known user.
|
|
ErrNoSuchDevice = errors.New("no such device")
|
|
|
|
// ErrNoSuchToken is returned for an access or refresh token that is
|
|
// unknown or has already been rotated away.
|
|
ErrNoSuchToken = errors.New("no such token")
|
|
|
|
// ErrNoSuchAccountData is returned for an account data type the user
|
|
// has never set.
|
|
ErrNoSuchAccountData = errors.New("no such account data")
|
|
)
|
|
|
|
// Account is a local user.
|
|
//
|
|
// Only the localpart is stored: the server name is fixed for the lifetime of
|
|
// a deployment — it is baked into every ID — so repeating it on every row
|
|
// would buy nothing. Virtual hosting, if it ever happens, is a migration.
|
|
//
|
|
// Timestamps throughout the user store are milliseconds since the Unix
|
|
// epoch, the unit the Client-Server API uses on the wire. Nothing needs
|
|
// converting on the way in or out, and both SQL drivers hold the same value.
|
|
type Account struct {
|
|
Localpart string
|
|
DisplayName string
|
|
AvatarURL string
|
|
Admin bool
|
|
Deactivated bool
|
|
CreatedAtMS int64
|
|
}
|
|
|
|
// NewAccount describes an account to create.
|
|
type NewAccount struct {
|
|
Localpart string
|
|
|
|
// PasswordHash is an encoded argon2id hash, or empty for an account
|
|
// that cannot log in with a password.
|
|
PasswordHash string
|
|
|
|
DisplayName string
|
|
Admin bool
|
|
CreatedAtMS int64
|
|
}
|
|
|
|
// Device is one login session's device record.
|
|
type Device struct {
|
|
Localpart string
|
|
ID string
|
|
DisplayName string
|
|
CreatedAtMS int64
|
|
|
|
LastSeenMS int64
|
|
LastSeenIP string
|
|
UserAgent string
|
|
}
|
|
|
|
// NewSession is a device plus the tokens issued for it: one login or one
|
|
// registration. The store writes all of it in a single transaction, so a
|
|
// client never sees a device without a working token, or the reverse.
|
|
//
|
|
// Tokens are stored as hashes. They are bearer credentials that by default
|
|
// never expire; hashing costs one SHA-256 per request and keeps a leaked
|
|
// database dump from handing over live sessions.
|
|
type NewSession struct {
|
|
Localpart string
|
|
DeviceID string
|
|
DisplayName string
|
|
CreatedAtMS int64
|
|
|
|
AccessTokenHash string
|
|
|
|
// AccessTokenExpiresAtMS is 0 for a token that never expires.
|
|
AccessTokenExpiresAtMS int64
|
|
|
|
// RefreshTokenHash is empty when the client did not ask for one.
|
|
RefreshTokenHash string
|
|
RefreshTokenExpiresAtMS int64
|
|
|
|
LastSeenIP string
|
|
UserAgent string
|
|
}
|
|
|
|
// RefreshedSession rotates the token pair of an existing device.
|
|
type RefreshedSession struct {
|
|
// RefreshTokenHash identifies the token being spent.
|
|
RefreshTokenHash string
|
|
|
|
NewAccessTokenHash string
|
|
AccessTokenExpiresAtMS int64
|
|
NewRefreshTokenHash string
|
|
RefreshTokenExpiresAtMS int64
|
|
RefreshedAtMS int64
|
|
}
|
|
|
|
// TokenInfo is everything the auth middleware needs from an access token, in
|
|
// one query: who owns it, on which device, and whether the account may still
|
|
// be used.
|
|
type TokenInfo struct {
|
|
Localpart string
|
|
DeviceID string
|
|
Admin bool
|
|
Deactivated bool
|
|
|
|
// ExpiresAtMS is 0 for a token that never expires.
|
|
ExpiresAtMS int64
|
|
}
|
|
|
|
// AccountFilter narrows and pages the admin user list.
|
|
type AccountFilter struct {
|
|
// Search matches a substring of the localpart or the display name,
|
|
// case-insensitively. Empty matches everything.
|
|
Search string
|
|
|
|
// Limit caps the returned rows; 0 means the store's own default.
|
|
Limit int
|
|
|
|
// Offset skips rows, for paging.
|
|
Offset int
|
|
}
|
|
|
|
// Stats are the counts the admin dashboard shows.
|
|
type Stats struct {
|
|
Users int
|
|
Admins int
|
|
Deactivated int
|
|
Devices int
|
|
}
|
|
|
|
// UserStore persists accounts, devices, tokens and account data: the
|
|
// storage half of the userapi component.
|
|
//
|
|
// Every method takes a localpart the caller has already normalised
|
|
// (lower-cased and validated); the store does no interpretation of its own.
|
|
type UserStore interface {
|
|
// CreateAccount inserts an account, returning ErrUserExists when the
|
|
// localpart is taken.
|
|
CreateAccount(ctx context.Context, acc NewAccount) (Account, error)
|
|
|
|
// Account reads one account, returning ErrNoSuchUser if it is absent.
|
|
Account(ctx context.Context, localpart string) (Account, error)
|
|
|
|
// PasswordHash returns the stored argon2id hash, which is empty for an
|
|
// account without a password.
|
|
PasswordHash(ctx context.Context, localpart string) (string, error)
|
|
|
|
// SetPasswordHash replaces the password hash.
|
|
SetPasswordHash(ctx context.Context, localpart, hash string) error
|
|
|
|
// SetAdmin grants or revokes the admin flag.
|
|
SetAdmin(ctx context.Context, localpart string, admin bool) error
|
|
|
|
// SetDisplayName updates the profile display name.
|
|
SetDisplayName(ctx context.Context, localpart, displayName string) error
|
|
|
|
// SetAvatarURL updates the profile avatar.
|
|
SetAvatarURL(ctx context.Context, localpart, avatarURL string) error
|
|
|
|
// ListAccounts returns a page of accounts ordered by localpart, plus
|
|
// the total number of accounts matching the filter.
|
|
ListAccounts(ctx context.Context, filter AccountFilter) ([]Account, int, error)
|
|
|
|
// Stats returns the aggregate counts for the admin dashboard.
|
|
Stats(ctx context.Context) (Stats, error)
|
|
|
|
// CreateSession creates or replaces a device and issues its tokens.
|
|
// Re-using an existing device ID revokes the tokens that device had,
|
|
// which is what the spec requires of a login naming a device already
|
|
// in use.
|
|
CreateSession(ctx context.Context, s NewSession) (Device, error)
|
|
|
|
// RefreshSession spends a refresh token and issues a new pair,
|
|
// returning the owner of the device. It returns ErrNoSuchToken if the
|
|
// token is unknown or has already been spent.
|
|
RefreshSession(ctx context.Context, r RefreshedSession) (TokenInfo, error)
|
|
|
|
// TokenInfo resolves an access token hash to its owner, returning
|
|
// ErrNoSuchToken when there is no such token. An expired token is
|
|
// returned with its expiry rather than rejected here, so the caller can
|
|
// answer with the soft logout the spec asks for.
|
|
TokenInfo(ctx context.Context, tokenHash string) (TokenInfo, error)
|
|
|
|
// TouchDevice records the last-seen timestamp, address and user agent
|
|
// of a device. It runs on authenticated requests, so it has to stay a
|
|
// single cheap statement.
|
|
TouchDevice(ctx context.Context, localpart, deviceID, ip, userAgent string, seenAtMS int64) error
|
|
|
|
// Device reads one device, returning ErrNoSuchDevice if it is absent.
|
|
Device(ctx context.Context, localpart, deviceID string) (Device, error)
|
|
|
|
// Devices lists a user's devices, ordered by device ID.
|
|
Devices(ctx context.Context, localpart string) ([]Device, error)
|
|
|
|
// SetDeviceDisplayName renames a device, returning ErrNoSuchDevice if
|
|
// it is absent.
|
|
SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error
|
|
|
|
// DeleteDevices removes devices and every token issued to them,
|
|
// returning how many existed. Unknown device IDs are ignored.
|
|
DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) (int, error)
|
|
|
|
// DeleteAllDevices removes every device of a user and their tokens.
|
|
DeleteAllDevices(ctx context.Context, localpart string) (int, error)
|
|
|
|
// AccountData reads one account data blob; roomID is empty for global
|
|
// account data. It returns ErrNoSuchAccountData when unset.
|
|
AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error)
|
|
|
|
// SetAccountData writes one account data blob, overwriting any previous
|
|
// value.
|
|
SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error
|
|
}
|