Implements PLAN.md Phase 8. internal/pushrules is the rule language: the spec's default ruleset, all six condition kinds, glob and word-boundary matching, intentional mentions switching off the legacy body rules, and rule ordering as Synapse has it. internal/pushserver keeps the rules, pushers and notifications and talks to push gateways. Push rules are the m.push_rules account data, written only by the push server; reads keep the user's own rules and the enabled/actions of each default and take the rest from the current defaults, so new default rules reach every user without a migration. PUT account_data/m.push_rules is refused with 405. The push server listens on the roomserver's emit stage and evaluates each event on its own worker for every local member and an invite's target. Notifications and per-room unread counts are stored in migration 0010; counts are a seventh sync token stream, so /sync reports unread_notifications and picks up count changes made after the event or by a receipt from another device. m.read and m.read.private receipts clear the counts; leaving a room clears it. Pushers (http kind only) push through a bounded sender pool with retries, honour event_id_only, drop push keys the gateway rejects, and go away with their device. GET /notifications pages the history. New config section push: enabled, include_content, gateway_timeout, notification_retention. Account data writes now wake the user's blocked syncs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
567 lines
18 KiB
Go
567 lines
18 KiB
Go
// 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()
|
|
}
|