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 // SetDeactivated sets or clears the deactivated flag. SetDeactivated(ctx context.Context, localpart string, deactivated 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 }