// Package userapi owns accounts, passwords, devices, tokens and account // data: everything that identifies a local user and their sessions. // // It is the component the Client-Server API handlers call for anything // to do with who is making a request. The HTTP shapes live in clientapi; // what is here is the part that would be the same behind any transport — // which is also what makes `warp create-account` able to create an account // without starting a server. // // Nothing in this package knows about rooms. A device's encryption keys // are the keyserver's and its pushers the push server's, and both hear // about a device going away through the DeviceObserver. Push rules are // account data, but the push server is their only writer. package userapi import ( "context" "errors" "log/slog" "sync" "time" "github.com/dariuszjaniak/warp/server/internal/storage" ) // Errors callers have to tell apart. The storage sentinels are re-exported // rather than translated: there is one error value with two names, so // errors.Is works whichever package a caller reaches for. var ( ErrUserExists = storage.ErrUserExists ErrNoSuchUser = storage.ErrNoSuchUser ErrNoSuchDevice = storage.ErrNoSuchDevice ErrNoSuchToken = storage.ErrNoSuchToken ErrNoSuchAccountData = storage.ErrNoSuchAccountData // ErrTokenExpired is an access token that was valid and has timed out. // It is distinct from ErrNoSuchToken because the spec answers it with a // soft logout: the client may keep its encryption state and log in // again. ErrTokenExpired = errors.New("access token has expired") // ErrAccountDeactivated is a token belonging to an account that has // been deactivated. ErrAccountDeactivated = errors.New("account has been deactivated") // ErrWrongPassword is a failed password check. It is deliberately the // same error whether the account exists or not, so that callers cannot // turn it into a user-enumeration oracle by accident. ErrWrongPassword = errors.New("invalid password") // ErrNoPassword is an account that has no password set and so cannot // use m.login.password. ErrNoPassword = errors.New("account has no password") // ErrInvalidDeviceID is a client-supplied device ID that is empty, too // long or contains control characters. ErrInvalidDeviceID = errors.New("invalid device ID") ) // API is the userapi component. type API struct { serverName string store storage.UserStore log *slog.Logger now func() time.Time accessTokenLifetime time.Duration refreshTokenLifetime time.Duration // lastSeen throttles the last-seen bookkeeping; see RecordActivity. lastSeen sync.Map // observer hears about deleted and renamed devices; nil hears nothing. observer DeviceObserver } // DeviceObserver is told about changes to devices that something else // keeps state for: the keyserver, whose keys for a deleted device have to // go with it. *keyserver.API satisfies it. // // It is called after the change has been made, on the request path, and // must not fail it: the device is already gone. type DeviceObserver interface { // DevicesDeleted is called with the deleted device IDs, or with none // when every device of the user was deleted. DevicesDeleted(ctx context.Context, localpart string, deviceIDs []string) // DeviceRenamed is called when a device's display name changes. DeviceRenamed(ctx context.Context, localpart, deviceID string) } // SetDeviceObserver connects the observer. It is a setter because the // keyserver is built after the userapi it reads devices from. func (a *API) SetDeviceObserver(o DeviceObserver) { a.observer = o } // Options configure the component. type Options struct { // ServerName is the Matrix server name; it becomes the domain part of // every user ID this component hands out. ServerName string // Store is the persistence layer. Store storage.UserStore // AccessTokenLifetime expires issued access tokens. Zero issues tokens // that never expire. AccessTokenLifetime time.Duration // RefreshTokenLifetime bounds how long a refresh token stays usable. // Zero means no expiry. RefreshTokenLifetime time.Duration Logger *slog.Logger // Now is the clock, so tests can control token expiry. Nil means // time.Now. Now func() time.Time } // New builds the component. func New(opts Options) (*API, error) { if opts.ServerName == "" { return nil, errors.New("userapi: ServerName must not be empty") } if opts.Store == nil { return nil, errors.New("userapi: Store must not be nil") } if opts.Logger == nil { opts.Logger = slog.Default() } if opts.Now == nil { opts.Now = time.Now } return &API{ serverName: opts.ServerName, store: opts.Store, log: opts.Logger, now: opts.Now, accessTokenLifetime: opts.AccessTokenLifetime, refreshTokenLifetime: opts.RefreshTokenLifetime, }, nil } // ServerName returns the server name user IDs are built with. func (a *API) ServerName() string { return a.serverName } // nowMS is the current time in the unit the store uses. func (a *API) nowMS() int64 { return a.now().UnixMilli() } // NewAccountRequest describes an account to create. type NewAccountRequest struct { // Localpart is normalised and validated by CreateAccount. Localpart string // Password may be empty, which creates an account that cannot log in // with a password. `warp create-account` uses that for a placeholder // account; the admin API does not offer it. Password string DisplayName string Admin bool } // CreateAccount validates the request, hashes the password and stores the // account. It returns ErrUserExists if the name is taken and // ErrInvalidLocalpart if the name is not usable. func (a *API) CreateAccount(ctx context.Context, req NewAccountRequest) (storage.Account, error) { localpart := NormaliseLocalpart(req.Localpart) if err := ValidateLocalpart(localpart, a.serverName); err != nil { return storage.Account{}, err } var hash string if req.Password != "" { var err error if hash, err = HashPassword(req.Password); err != nil { return storage.Account{}, err } } acc, err := a.store.CreateAccount(ctx, storage.NewAccount{ Localpart: localpart, PasswordHash: hash, DisplayName: req.DisplayName, Admin: req.Admin, CreatedAtMS: a.nowMS(), }) if err != nil { return storage.Account{}, err } a.log.Info("created account", "user_id", a.UserID(localpart), "admin", req.Admin) return acc, nil } // Account reads an account by normalised localpart. func (a *API) Account(ctx context.Context, localpart string) (storage.Account, error) { return a.store.Account(ctx, localpart) } // AccountExists reports whether a localpart is taken. It is the answer // behind /register/available, and deliberately does not distinguish a // deactivated account: the name stays reserved. func (a *API) AccountExists(ctx context.Context, localpart string) (bool, error) { switch _, err := a.store.Account(ctx, localpart); { case err == nil: return true, nil case errors.Is(err, ErrNoSuchUser): return false, nil default: return false, err } } // SetAdmin grants or revokes the admin flag. func (a *API) SetAdmin(ctx context.Context, localpart string, admin bool) error { return a.store.SetAdmin(ctx, localpart, admin) } // SetPassword replaces an account's password. func (a *API) SetPassword(ctx context.Context, localpart, password string) error { hash, err := HashPassword(password) if err != nil { return err } return a.store.SetPasswordHash(ctx, localpart, hash) } // CheckPassword verifies a password against an account. // // Every failure path costs one argon2id hash, including an account that does // not exist or has no password, so the time a rejection takes says nothing // about which user names are real. func (a *API) CheckPassword(ctx context.Context, localpart, password string) error { hash, err := a.store.PasswordHash(ctx, localpart) switch { case errors.Is(err, ErrNoSuchUser): _, _ = VerifyPassword(dummyPasswordHash, password) return ErrWrongPassword case err != nil: return err } if hash == "" { _, _ = VerifyPassword(dummyPasswordHash, password) return ErrNoPassword } ok, err := VerifyPassword(hash, password) switch { case errors.Is(err, ErrPasswordTooLong): return ErrWrongPassword case err != nil: // A malformed hash is an operational problem, not a wrong password. a.log.Error("stored password hash is unusable", "localpart", localpart, "error", err) return err case !ok: return ErrWrongPassword } return nil } // SessionRequest asks for a new device and its tokens. type SessionRequest struct { // Localpart must already be normalised and must exist. Localpart string // DeviceID is the client's choice. Empty means generate one. Naming a // device that already exists re-uses it and revokes its old tokens. DeviceID string // DisplayName is initial_device_display_name: used only when the device // is created. DisplayName string // WithRefreshToken issues a refresh token alongside the access token. WithRefreshToken bool IP string UserAgent string } // Session is the result of a login, a registration or a token refresh: what // the client needs to start making requests. type Session struct { UserID string Localpart string DeviceID string AccessToken string // RefreshToken is empty unless the client asked for one. RefreshToken string // ExpiresInMS is how long the access token is good for, or 0 when it // does not expire. ExpiresInMS int64 } // CreateSession issues a device and tokens for an existing account. func (a *API) CreateSession(ctx context.Context, req SessionRequest) (Session, error) { deviceID := req.DeviceID if deviceID == "" { var err error if deviceID, err = GenerateDeviceID(); err != nil { return Session{}, err } } else if err := ValidateDeviceID(deviceID); err != nil { return Session{}, err } accessToken, err := GenerateAccessToken() if err != nil { return Session{}, err } var refreshToken string if req.WithRefreshToken { if refreshToken, err = GenerateRefreshToken(); err != nil { return Session{}, err } } now := a.nowMS() session := storage.NewSession{ Localpart: req.Localpart, DeviceID: deviceID, DisplayName: req.DisplayName, CreatedAtMS: now, AccessTokenHash: HashToken(accessToken), AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime), RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime), LastSeenIP: req.IP, UserAgent: req.UserAgent, } if refreshToken != "" { session.RefreshTokenHash = HashToken(refreshToken) } device, err := a.store.CreateSession(ctx, session) if err != nil { return Session{}, err } return Session{ UserID: a.UserID(req.Localpart), Localpart: req.Localpart, DeviceID: device.ID, AccessToken: accessToken, RefreshToken: refreshToken, ExpiresInMS: lifetimeMS(a.accessTokenLifetime), }, nil } // RefreshSession spends a refresh token and issues a new pair. It returns // ErrNoSuchToken if the token is unknown or already spent. func (a *API) RefreshSession(ctx context.Context, refreshToken string) (Session, error) { accessToken, err := GenerateAccessToken() if err != nil { return Session{}, err } newRefreshToken, err := GenerateRefreshToken() if err != nil { return Session{}, err } now := a.nowMS() info, err := a.store.RefreshSession(ctx, storage.RefreshedSession{ RefreshTokenHash: HashToken(refreshToken), NewAccessTokenHash: HashToken(accessToken), AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime), NewRefreshTokenHash: HashToken(newRefreshToken), RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime), RefreshedAtMS: now, }) if err != nil { return Session{}, err } if info.Deactivated { return Session{}, ErrAccountDeactivated } return Session{ UserID: a.UserID(info.Localpart), Localpart: info.Localpart, DeviceID: info.DeviceID, AccessToken: accessToken, RefreshToken: newRefreshToken, ExpiresInMS: lifetimeMS(a.accessTokenLifetime), }, nil } // Authenticated is the identity behind a request: what the auth middleware // resolves an access token into and what every authenticated handler is // given. type Authenticated struct { UserID string Localpart string DeviceID string Admin bool } // Authenticate resolves an access token. // // It returns ErrNoSuchToken for an unknown token, ErrTokenExpired for one // that has timed out, and ErrAccountDeactivated for a token whose account is // gone. The caller turns those into M_UNKNOWN_TOKEN, a soft logout, and // M_USER_DEACTIVATED respectively. func (a *API) Authenticate(ctx context.Context, accessToken string) (Authenticated, error) { if accessToken == "" { return Authenticated{}, ErrNoSuchToken } info, err := a.store.TokenInfo(ctx, HashToken(accessToken)) if err != nil { return Authenticated{}, err } if info.ExpiresAtMS != 0 && info.ExpiresAtMS <= a.nowMS() { return Authenticated{}, ErrTokenExpired } if info.Deactivated { return Authenticated{}, ErrAccountDeactivated } return Authenticated{ UserID: a.UserID(info.Localpart), Localpart: info.Localpart, DeviceID: info.DeviceID, Admin: info.Admin, }, nil } // lastSeenInterval is how stale the last-seen record of a device may get. // Recording it on every request would double the write load of the whole C-S // API to keep a field nothing reads more often than a user opens their // device list. const lastSeenInterval = time.Minute // RecordActivity updates a device's last-seen timestamp, address and user // agent, at most once per lastSeenInterval per device. A failure is logged // and swallowed: the request it belongs to has already been authorised, and // bookkeeping must not turn into a 500. func (a *API) RecordActivity(ctx context.Context, auth Authenticated, ip, userAgent string) { key := auth.Localpart + "\x00" + auth.DeviceID now := a.now() if previous, ok := a.lastSeen.Load(key); ok { if at, ok := previous.(time.Time); ok && now.Sub(at) < lastSeenInterval { return } } a.lastSeen.Store(key, now) if err := a.store.TouchDevice(ctx, auth.Localpart, auth.DeviceID, ip, userAgent, now.UnixMilli()); err != nil { a.log.Warn("recording device activity failed", "user_id", auth.UserID, "device_id", auth.DeviceID, "error", err) } } // Device reads one of a user's devices. func (a *API) Device(ctx context.Context, localpart, deviceID string) (storage.Device, error) { return a.store.Device(ctx, localpart, deviceID) } // Devices lists a user's devices. func (a *API) Devices(ctx context.Context, localpart string) ([]storage.Device, error) { return a.store.Devices(ctx, localpart) } // SetDeviceDisplayName renames a device. func (a *API) SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error { if err := a.store.SetDeviceDisplayName(ctx, localpart, deviceID, displayName); err != nil { return err } if a.observer != nil { a.observer.DeviceRenamed(ctx, localpart, deviceID) } return nil } // DeleteDevices revokes devices and their tokens. It is what /logout, // /delete_devices and DELETE /devices/{id} all end up calling. func (a *API) DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) error { if len(deviceIDs) == 0 { return nil } if _, err := a.store.DeleteDevices(ctx, localpart, deviceIDs); err != nil { return err } a.forgetLastSeen(localpart, deviceIDs...) if a.observer != nil { a.observer.DevicesDeleted(ctx, localpart, deviceIDs) } return nil } // DeleteAllDevices revokes every device of a user: /logout/all. func (a *API) DeleteAllDevices(ctx context.Context, localpart string) error { if _, err := a.store.DeleteAllDevices(ctx, localpart); err != nil { return err } a.forgetLastSeen(localpart) if a.observer != nil { a.observer.DevicesDeleted(ctx, localpart, nil) } return nil } // forgetLastSeen drops the throttle entries for devices that no longer // exist, so the map does not grow with every logout. With no device IDs it // clears every entry of the user. func (a *API) forgetLastSeen(localpart string, deviceIDs ...string) { if len(deviceIDs) > 0 { for _, id := range deviceIDs { a.lastSeen.Delete(localpart + "\x00" + id) } return } prefix := localpart + "\x00" a.lastSeen.Range(func(key, _ any) bool { if s, ok := key.(string); ok && len(s) > len(prefix) && s[:len(prefix)] == prefix { a.lastSeen.Delete(key) } return true }) } // SetDisplayName updates a user's profile display name. func (a *API) SetDisplayName(ctx context.Context, localpart, displayName string) error { return a.store.SetDisplayName(ctx, localpart, displayName) } // SetAvatarURL updates a user's profile avatar. func (a *API) SetAvatarURL(ctx context.Context, localpart, avatarURL string) error { return a.store.SetAvatarURL(ctx, localpart, avatarURL) } // AccountData reads one account data blob; roomID is empty for global data. func (a *API) AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error) { return a.store.AccountData(ctx, localpart, roomID, dataType) } // SetAccountData writes one account data blob. func (a *API) SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error { return a.store.SetAccountData(ctx, localpart, roomID, dataType, content) } // ListAccounts returns a page of accounts for the admin API. func (a *API) ListAccounts(ctx context.Context, filter storage.AccountFilter) ([]storage.Account, int, error) { return a.store.ListAccounts(ctx, filter) } // Stats returns the aggregate user counts for the admin API. func (a *API) Stats(ctx context.Context) (storage.Stats, error) { return a.store.Stats(ctx) } // expiryMS turns a lifetime into an absolute deadline in the store's unit, // or 0 for "never". func expiryMS(nowMS int64, lifetime time.Duration) int64 { if lifetime <= 0 { return 0 } return nowMS + lifetime.Milliseconds() } // lifetimeMS is the expires_in_ms a client is told, or 0 for "never". func lifetimeMS(lifetime time.Duration) int64 { if lifetime <= 0 { return 0 } return lifetime.Milliseconds() }