Phase 1: accounts and sessions
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>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-10-15 18:18:37 +02:00
1 parent b4fb60a8ab
commit 6004bb01de
68 files changed
+9729 -66

No files matched your search

@@ -51,6 +51,9 @@ func (*Database) PendingMigrations(context.Context) ([]string, error) {
// Settings implements storage.Database.
func (*Database) Settings() storage.SettingsStore { return notImplementedSettings{} }
// Users implements storage.Database.
func (*Database) Users() storage.UserStore { return notImplementedUsers{} }
// Close implements storage.Database.
func (*Database) Close() error { return nil }
@@ -75,5 +78,93 @@ func (notImplementedSettings) Delete(context.Context, string) error {
return storage.ErrNotImplemented
}
// Compile-time proof that the stub keeps up with the interface.
var _ storage.Database = (*Database)(nil)
// notImplementedUsers satisfies storage.UserStore the same way: a
// half-wired PocketBase deployment must not accept a registration it cannot
// store, or hand out an access token it cannot look up again.
type notImplementedUsers struct{}
func (notImplementedUsers) CreateAccount(context.Context, storage.NewAccount) (storage.Account, error) {
return storage.Account{}, storage.ErrNotImplemented
}
func (notImplementedUsers) Account(context.Context, string) (storage.Account, error) {
return storage.Account{}, storage.ErrNotImplemented
}
func (notImplementedUsers) PasswordHash(context.Context, string) (string, error) {
return "", storage.ErrNotImplemented
}
func (notImplementedUsers) SetPasswordHash(context.Context, string, string) error {
return storage.ErrNotImplemented
}
func (notImplementedUsers) SetAdmin(context.Context, string, bool) error {
return storage.ErrNotImplemented
}
func (notImplementedUsers) SetDisplayName(context.Context, string, string) error {
return storage.ErrNotImplemented
}
func (notImplementedUsers) SetAvatarURL(context.Context, string, string) error {
return storage.ErrNotImplemented
}
func (notImplementedUsers) ListAccounts(context.Context, storage.AccountFilter) ([]storage.Account, int, error) {
return nil, 0, storage.ErrNotImplemented
}
func (notImplementedUsers) Stats(context.Context) (storage.Stats, error) {
return storage.Stats{}, storage.ErrNotImplemented
}
func (notImplementedUsers) CreateSession(context.Context, storage.NewSession) (storage.Device, error) {
return storage.Device{}, storage.ErrNotImplemented
}
func (notImplementedUsers) RefreshSession(context.Context, storage.RefreshedSession) (storage.TokenInfo, error) {
return storage.TokenInfo{}, storage.ErrNotImplemented
}
func (notImplementedUsers) TokenInfo(context.Context, string) (storage.TokenInfo, error) {
return storage.TokenInfo{}, storage.ErrNotImplemented
}
func (notImplementedUsers) TouchDevice(context.Context, string, string, string, string, int64) error {
return storage.ErrNotImplemented
}
func (notImplementedUsers) Device(context.Context, string, string) (storage.Device, error) {
return storage.Device{}, storage.ErrNotImplemented
}
func (notImplementedUsers) Devices(context.Context, string) ([]storage.Device, error) {
return nil, storage.ErrNotImplemented
}
func (notImplementedUsers) SetDeviceDisplayName(context.Context, string, string, string) error {
return storage.ErrNotImplemented
}
func (notImplementedUsers) DeleteDevices(context.Context, string, []string) (int, error) {
return 0, storage.ErrNotImplemented
}
func (notImplementedUsers) DeleteAllDevices(context.Context, string) (int, error) {
return 0, storage.ErrNotImplemented
}
func (notImplementedUsers) AccountData(context.Context, string, string, string) ([]byte, error) {
return nil, storage.ErrNotImplemented
}
func (notImplementedUsers) SetAccountData(context.Context, string, string, string, []byte) error {
return storage.ErrNotImplemented
}
// Compile-time proof that the stub keeps up with the interfaces.
var (
_ storage.Database = (*Database)(nil)
_ storage.UserStore = notImplementedUsers{}
)
@@ -0,0 +1,44 @@
package postgres
import (
"errors"
"strconv"
"strings"
"github.com/jackc/pgx/v5/pgconn"
)
// uniqueViolation is the SQLSTATE class PostgreSQL raises for a duplicate
// key, whichever constraint caught it.
const uniqueViolation = "23505"
// isUniqueViolation reports whether err is PostgreSQL complaining about a
// duplicate key.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation
}
// rebind turns the `?` placeholders the shared component stores are written
// with into PostgreSQL's numbered form. It is a straight scan rather than a
// SQL parser, which is safe because the statements it sees are constants in
// this repository: none of them contains a `?` inside a string literal, and
// none is built from user input beyond a run of placeholders.
func rebind(query string) string {
if !strings.Contains(query, "?") {
return query
}
var b strings.Builder
b.Grow(len(query) + 8)
n := 0
for i := range len(query) {
if query[i] != '?' {
b.WriteByte(query[i])
continue
}
n++
b.WriteByte('$')
b.WriteString(strconv.Itoa(n))
}
return b.String()
}
@@ -0,0 +1,51 @@
package postgres
import "testing"
// TestRebind covers the translation the shared component stores depend on.
// It runs without a database, which matters: it is the one part of the
// PostgreSQL driver that can be tested on a machine without PostgreSQL, and
// a mistake here breaks every statement at once.
func TestRebind(t *testing.T) {
for name, tc := range map[string]struct{ in, want string }{
"no placeholders": {
in: `SELECT COUNT(*) FROM user_accounts`,
want: `SELECT COUNT(*) FROM user_accounts`,
},
"one placeholder": {
in: `SELECT password_hash FROM user_accounts WHERE localpart = ?`,
want: `SELECT password_hash FROM user_accounts WHERE localpart = $1`,
},
"numbered in order": {
in: `INSERT INTO user_accounts (localpart, admin) VALUES (?, ?)`,
want: `INSERT INTO user_accounts (localpart, admin) VALUES ($1, $2)`,
},
"past nine": {
in: `VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
want: `VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
},
"leaves the escape clause alone": {
in: `WHERE localpart LIKE ? ESCAPE '!'`,
want: `WHERE localpart LIKE $1 ESCAPE '!'`,
},
} {
t.Run(name, func(t *testing.T) {
if got := rebind(tc.in); got != tc.want {
t.Errorf("rebind(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestIsUniqueViolationIgnoresOtherErrors(t *testing.T) {
if isUniqueViolation(nil) {
t.Error("nil is not a unique violation")
}
if isUniqueViolation(errOther{}) {
t.Error("an unrelated error was reported as a unique violation")
}
}
type errOther struct{}
func (errOther) Error() string { return "something else went wrong" }
@@ -0,0 +1,69 @@
-- Accounts, devices, tokens and account data: the userapi tables.
--
-- Timestamps are milliseconds since the Unix epoch, the unit the
-- Client-Server API uses on the wire, so values go in and out unconverted
-- and both SQL drivers store the same numbers.
--
-- Tokens are stored as SHA-256 hashes, never in the clear: they are bearer
-- credentials that by default never expire, and one hash per request is
-- cheap next to what a leaked database dump would otherwise hand over.
CREATE TABLE IF NOT EXISTS user_accounts (
localpart TEXT NOT NULL PRIMARY KEY,
-- An empty hash means the account has no password and cannot use
-- m.login.password; it is not a hash that anything can match.
password_hash TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
admin BOOLEAN NOT NULL DEFAULT FALSE,
deactivated BOOLEAN NOT NULL DEFAULT FALSE,
created_at_ms BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_devices (
localpart TEXT NOT NULL REFERENCES user_accounts (localpart) ON DELETE CASCADE,
device_id TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
last_seen_ms BIGINT NOT NULL DEFAULT 0,
last_seen_ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
PRIMARY KEY (localpart, device_id)
);
CREATE TABLE IF NOT EXISTS user_access_tokens (
token_hash TEXT NOT NULL PRIMARY KEY,
localpart TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
-- 0 means the token never expires.
expires_at_ms BIGINT NOT NULL DEFAULT 0,
FOREIGN KEY (localpart, device_id)
REFERENCES user_devices (localpart, device_id) ON DELETE CASCADE
);
-- Revoking a device deletes its tokens by this index, not by table scan.
CREATE INDEX IF NOT EXISTS user_access_tokens_device_idx
ON user_access_tokens (localpart, device_id);
CREATE TABLE IF NOT EXISTS user_refresh_tokens (
token_hash TEXT NOT NULL PRIMARY KEY,
localpart TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
expires_at_ms BIGINT NOT NULL DEFAULT 0,
FOREIGN KEY (localpart, device_id)
REFERENCES user_devices (localpart, device_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS user_refresh_tokens_device_idx
ON user_refresh_tokens (localpart, device_id);
CREATE TABLE IF NOT EXISTS user_account_data (
localpart TEXT NOT NULL REFERENCES user_accounts (localpart) ON DELETE CASCADE,
-- Empty for global account data; a room ID for per-room account data.
room_id TEXT NOT NULL DEFAULT '',
data_type TEXT NOT NULL,
content TEXT NOT NULL,
PRIMARY KEY (localpart, room_id, data_type)
);
+9 -7
View File
@@ -69,13 +69,15 @@ func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Da
}
store, err := sqlstore.New(sqlstore.Options{
DriverName: storage.DriverPostgres,
DB: db,
Dialect: dialect,
MigrationsFS: migrationFiles,
MigrationsDir: "migrations",
Settings: settingsQueries,
Logger: log,
DriverName: storage.DriverPostgres,
DB: db,
Dialect: dialect,
MigrationsFS: migrationFiles,
MigrationsDir: "migrations",
Settings: settingsQueries,
Rebind: rebind,
IsUniqueViolation: isUniqueViolation,
Logger: log,
})
if err != nil {
_ = db.Close()
+31
View File
@@ -0,0 +1,31 @@
package sqlite
import (
"errors"
sqlite3 "modernc.org/sqlite"
)
// SQLite extended result codes for the two constraint failures that mean
// "that row is already there". The driver reports them numerically and does
// not re-export the constants, so they are named here rather than left as
// magic numbers at the call site.
const (
constraintUnique = 2067 // SQLITE_CONSTRAINT_UNIQUE
constraintPrimaryKey = 1555 // SQLITE_CONSTRAINT_PRIMARYKEY
)
// isUniqueViolation reports whether err is SQLite complaining about a
// duplicate key.
func isUniqueViolation(err error) bool {
var sqlErr *sqlite3.Error
if !errors.As(err, &sqlErr) {
return false
}
switch sqlErr.Code() {
case constraintUnique, constraintPrimaryKey:
return true
default:
return false
}
}
@@ -0,0 +1,60 @@
package sqlite
import (
"database/sql"
"fmt"
"path/filepath"
"testing"
)
// TestIsUniqueViolation checks the error recognition against a real SQLite,
// because it turns on an extended result code the driver does not export as
// a constant: a version bump that changed it would otherwise turn "that name
// is taken" into a 500.
func TestIsUniqueViolation(t *testing.T) {
path := filepath.ToSlash(filepath.Join(t.TempDir(), "test.db"))
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if _, err := db.ExecContext(t.Context(),
`CREATE TABLE t (pk TEXT PRIMARY KEY, uniq TEXT UNIQUE)`); err != nil {
t.Fatal(err)
}
insert := func(pk, uniq string) error {
_, err := db.ExecContext(t.Context(), `INSERT INTO t (pk, uniq) VALUES (?, ?)`, pk, uniq)
return err
}
if err := insert("a", "one"); err != nil {
t.Fatal(err)
}
for name, err := range map[string]error{
"duplicate primary key": insert("a", "two"),
"duplicate unique key": insert("b", "one"),
} {
t.Run(name, func(t *testing.T) {
if err == nil {
t.Fatal("the insert should have failed")
}
if !isUniqueViolation(err) {
t.Errorf("isUniqueViolation(%v) = false, want true", err)
}
})
}
// Something that is an error but not a constraint failure.
_, err = db.ExecContext(t.Context(), `INSERT INTO nonexistent (x) VALUES (1)`)
if err == nil {
t.Fatal("inserting into a missing table should have failed")
}
if isUniqueViolation(err) {
t.Errorf("isUniqueViolation(%v) = true, want false", err)
}
if isUniqueViolation(nil) || isUniqueViolation(fmt.Errorf("unrelated")) {
t.Error("isUniqueViolation accepted something that is not a SQLite error")
}
}
@@ -0,0 +1,69 @@
-- Accounts, devices, tokens and account data: the userapi tables.
--
-- Timestamps are milliseconds since the Unix epoch, the unit the
-- Client-Server API uses on the wire, so values go in and out unconverted
-- and both SQL drivers store the same numbers.
--
-- Tokens are stored as SHA-256 hashes, never in the clear: they are bearer
-- credentials that by default never expire, and one hash per request is
-- cheap next to what a leaked database dump would otherwise hand over.
CREATE TABLE IF NOT EXISTS user_accounts (
localpart TEXT NOT NULL PRIMARY KEY,
-- An empty hash means the account has no password and cannot use
-- m.login.password; it is not a hash that anything can match.
password_hash TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
admin BOOLEAN NOT NULL DEFAULT 0,
deactivated BOOLEAN NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_devices (
localpart TEXT NOT NULL REFERENCES user_accounts (localpart) ON DELETE CASCADE,
device_id TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
last_seen_ms BIGINT NOT NULL DEFAULT 0,
last_seen_ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
PRIMARY KEY (localpart, device_id)
);
CREATE TABLE IF NOT EXISTS user_access_tokens (
token_hash TEXT NOT NULL PRIMARY KEY,
localpart TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
-- 0 means the token never expires.
expires_at_ms BIGINT NOT NULL DEFAULT 0,
FOREIGN KEY (localpart, device_id)
REFERENCES user_devices (localpart, device_id) ON DELETE CASCADE
);
-- Revoking a device deletes its tokens by this index, not by table scan.
CREATE INDEX IF NOT EXISTS user_access_tokens_device_idx
ON user_access_tokens (localpart, device_id);
CREATE TABLE IF NOT EXISTS user_refresh_tokens (
token_hash TEXT NOT NULL PRIMARY KEY,
localpart TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
expires_at_ms BIGINT NOT NULL DEFAULT 0,
FOREIGN KEY (localpart, device_id)
REFERENCES user_devices (localpart, device_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS user_refresh_tokens_device_idx
ON user_refresh_tokens (localpart, device_id);
CREATE TABLE IF NOT EXISTS user_account_data (
localpart TEXT NOT NULL REFERENCES user_accounts (localpart) ON DELETE CASCADE,
-- Empty for global account data; a room ID for per-room account data.
room_id TEXT NOT NULL DEFAULT '',
data_type TEXT NOT NULL,
content TEXT NOT NULL,
PRIMARY KEY (localpart, room_id, data_type)
);
+4 -1
View File
@@ -93,7 +93,10 @@ func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Da
MigrationsFS: migrationFiles,
MigrationsDir: "migrations",
Settings: settingsQueries,
Logger: log,
// SQLite already uses the `?` placeholders the shared component
// stores are written with, so there is nothing to rebind.
IsUniqueViolation: isUniqueViolation,
Logger: log,
})
if err != nil {
_ = db.Close()
@@ -35,6 +35,16 @@ type Options struct {
// Settings holds the statements for the server_settings table.
Settings SettingsQueries
// Rebind translates the `?` placeholders that the shared component
// stores are written with into this driver's style. SQLite needs no
// translation and leaves it nil; PostgreSQL turns them into $1, $2...
Rebind func(query string) string
// IsUniqueViolation recognises this driver's unique-constraint error, so
// that a taken user name can be told apart from a database failure
// without a check-then-insert race.
IsUniqueViolation func(error) bool
// Logger is used for migration progress.
Logger *slog.Logger
}
@@ -46,6 +56,7 @@ type Store struct {
db *sql.DB
log *slog.Logger
settings *Settings
users *Users
}
// New builds a Store. It does not touch the database.
@@ -61,6 +72,7 @@ func New(opts Options) (*Store, error) {
db: opts.DB,
log: opts.Logger,
settings: NewSettings(opts.DB, opts.Settings),
users: NewUsers(opts.DB, opts.Rebind, opts.IsUniqueViolation),
}, nil
}
@@ -79,6 +91,9 @@ func (s *Store) Close() error { return s.db.Close() }
// Settings implements storage.Database.
func (s *Store) Settings() storage.SettingsStore { return s.settings }
// Users implements storage.Database.
func (s *Store) Users() storage.UserStore { return s.users }
// Migrate implements storage.Database.
func (s *Store) Migrate(ctx context.Context) error {
ms, err := s.migrations()
+644
View File
@@ -0,0 +1,644 @@
package sqlstore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Users is the shared SQL implementation of storage.UserStore.
//
// Unlike the settings store, whose four statements each driver writes in its
// own style, the user store has around thirty. Keeping two copies of them
// differing only in `?` versus `$1` would be a standing invitation to fix a
// bug in one and not the other, so the SQL is written once with `?` and the
// driver supplies a Rebind function. The statements themselves use only
// syntax both engines agree on: ON CONFLICT ... DO UPDATE with `excluded`,
// LIMIT/OFFSET, and comparisons against bound values rather than boolean
// literals.
type Users struct {
db *sql.DB
rebind func(string) string
isUnique func(error) bool
}
// NewUsers builds a user store. rebind translates the `?` placeholders into
// the driver's own style, and isUnique recognises its unique-constraint
// error; nil means "no translation" and "never", respectively.
func NewUsers(db *sql.DB, rebind func(string) string, isUnique func(error) bool) *Users {
if rebind == nil {
rebind = func(query string) string { return query }
}
if isUnique == nil {
isUnique = func(error) bool { return false }
}
return &Users{db: db, rebind: rebind, isUnique: isUnique}
}
// defaultAccountPageSize bounds an admin user listing that asks for no limit,
// so a forgotten ?limit= cannot stream the whole user table.
const defaultAccountPageSize = 100
// maxAccountPageSize caps what a caller may ask for.
const maxAccountPageSize = 1000
// q rebinds a statement for this driver.
func (u *Users) q(query string) string { return u.rebind(query) }
// tx runs fn in a transaction, rolling back on any error. The rollback after
// a successful commit is a no-op, which is why it can be deferred blindly.
func (u *Users) tx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := u.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := fn(tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
const insertAccountSQL = `
INSERT INTO user_accounts (localpart, password_hash, display_name, admin, created_at_ms)
VALUES (?, ?, ?, ?, ?)`
// CreateAccount implements storage.UserStore.
//
// Taken localparts are detected by inserting and letting the primary key
// complain, rather than by checking first: the check-then-insert version has
// a race that two simultaneous registrations of the same name would walk
// straight into.
func (u *Users) CreateAccount(ctx context.Context, acc storage.NewAccount) (storage.Account, error) {
_, err := u.db.ExecContext(ctx, u.q(insertAccountSQL),
acc.Localpart, acc.PasswordHash, acc.DisplayName, acc.Admin, acc.CreatedAtMS)
if err != nil {
if u.isUnique(err) {
return storage.Account{}, fmt.Errorf("create account %q: %w", acc.Localpart, storage.ErrUserExists)
}
return storage.Account{}, fmt.Errorf("create account %q: %w", acc.Localpart, err)
}
return storage.Account{
Localpart: acc.Localpart,
DisplayName: acc.DisplayName,
Admin: acc.Admin,
CreatedAtMS: acc.CreatedAtMS,
}, nil
}
const selectAccountSQL = `
SELECT localpart, display_name, avatar_url, admin, deactivated, created_at_ms
FROM user_accounts WHERE localpart = ?`
// Account implements storage.UserStore.
func (u *Users) Account(ctx context.Context, localpart string) (storage.Account, error) {
var acc storage.Account
err := u.db.QueryRowContext(ctx, u.q(selectAccountSQL), localpart).Scan(
&acc.Localpart, &acc.DisplayName, &acc.AvatarURL,
&acc.Admin, &acc.Deactivated, &acc.CreatedAtMS)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.Account{}, fmt.Errorf("account %q: %w", localpart, storage.ErrNoSuchUser)
case err != nil:
return storage.Account{}, fmt.Errorf("account %q: %w", localpart, err)
}
return acc, nil
}
const selectPasswordHashSQL = `SELECT password_hash FROM user_accounts WHERE localpart = ?`
// PasswordHash implements storage.UserStore.
func (u *Users) PasswordHash(ctx context.Context, localpart string) (string, error) {
var hash string
err := u.db.QueryRowContext(ctx, u.q(selectPasswordHashSQL), localpart).Scan(&hash)
switch {
case errors.Is(err, sql.ErrNoRows):
return "", fmt.Errorf("password for %q: %w", localpart, storage.ErrNoSuchUser)
case err != nil:
return "", fmt.Errorf("password for %q: %w", localpart, err)
}
return hash, nil
}
// SetPasswordHash implements storage.UserStore.
func (u *Users) SetPasswordHash(ctx context.Context, localpart, hash string) error {
return u.updateAccount(ctx, localpart,
`UPDATE user_accounts SET password_hash = ? WHERE localpart = ?`, hash)
}
// SetAdmin implements storage.UserStore.
func (u *Users) SetAdmin(ctx context.Context, localpart string, admin bool) error {
return u.updateAccount(ctx, localpart,
`UPDATE user_accounts SET admin = ? WHERE localpart = ?`, admin)
}
// SetDisplayName implements storage.UserStore.
func (u *Users) SetDisplayName(ctx context.Context, localpart, displayName string) error {
return u.updateAccount(ctx, localpart,
`UPDATE user_accounts SET display_name = ? WHERE localpart = ?`, displayName)
}
// SetAvatarURL implements storage.UserStore.
func (u *Users) SetAvatarURL(ctx context.Context, localpart, avatarURL string) error {
return u.updateAccount(ctx, localpart,
`UPDATE user_accounts SET avatar_url = ? WHERE localpart = ?`, avatarURL)
}
// updateAccount runs a single-column update and turns "nothing changed" into
// ErrNoSuchUser, so callers do not have to look the account up first.
func (u *Users) updateAccount(ctx context.Context, localpart, query string, value any) error {
res, err := u.db.ExecContext(ctx, u.q(query), value, localpart)
if err != nil {
return fmt.Errorf("update account %q: %w", localpart, err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update account %q: %w", localpart, err)
}
if affected == 0 {
return fmt.Errorf("update account %q: %w", localpart, storage.ErrNoSuchUser)
}
return nil
}
// likeEscape is the escape character the search patterns use.
//
// It is deliberately not a backslash: a backslash in a SQL string literal
// means different things depending on PostgreSQL's standard_conforming_
// strings setting, and "!" means exactly one thing everywhere.
const likeEscape = '!'
const (
countAccountsSQL = `
SELECT COUNT(*) FROM user_accounts
WHERE LOWER(localpart) LIKE ? ESCAPE '!' OR LOWER(display_name) LIKE ? ESCAPE '!'`
listAccountsSQL = `
SELECT localpart, display_name, avatar_url, admin, deactivated, created_at_ms
FROM user_accounts
WHERE LOWER(localpart) LIKE ? ESCAPE '!' OR LOWER(display_name) LIKE ? ESCAPE '!'
ORDER BY localpart
LIMIT ? OFFSET ?`
)
// ListAccounts implements storage.UserStore.
//
// The search is a LOWER(...) LIKE rather than ILIKE, which is PostgreSQL
// only, and the pattern is escaped so that searching for "_" does not
// quietly match every single-character name.
func (u *Users) ListAccounts(ctx context.Context, filter storage.AccountFilter) ([]storage.Account, int, error) {
pattern := likeContains(filter.Search)
limit := filter.Limit
switch {
case limit <= 0:
limit = defaultAccountPageSize
case limit > maxAccountPageSize:
limit = maxAccountPageSize
}
offset := max(filter.Offset, 0)
var total int
if err := u.db.QueryRowContext(ctx, u.q(countAccountsSQL), pattern, pattern).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count accounts: %w", err)
}
rows, err := u.db.QueryContext(ctx, u.q(listAccountsSQL), pattern, pattern, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("list accounts: %w", err)
}
defer rows.Close()
accounts := []storage.Account{}
for rows.Next() {
var acc storage.Account
if err := rows.Scan(&acc.Localpart, &acc.DisplayName, &acc.AvatarURL,
&acc.Admin, &acc.Deactivated, &acc.CreatedAtMS); err != nil {
return nil, 0, fmt.Errorf("scan account: %w", err)
}
accounts = append(accounts, acc)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("list accounts: %w", err)
}
return accounts, total, nil
}
// likeContains turns a search term into a LIKE pattern matching it
// anywhere, escaping the two wildcards and the escape character itself. The
// statements using it declare ESCAPE explicitly, because SQLite has no
// default escape character at all.
func likeContains(search string) string {
search = strings.ToLower(strings.TrimSpace(search))
var b strings.Builder
b.Grow(len(search) + 2)
b.WriteByte('%')
for _, r := range search {
if r == '%' || r == '_' || r == likeEscape {
b.WriteByte(likeEscape)
}
b.WriteRune(r)
}
b.WriteByte('%')
return b.String()
}
const statsSQL = `
SELECT
(SELECT COUNT(*) FROM user_accounts),
(SELECT COUNT(*) FROM user_accounts WHERE admin = ?),
(SELECT COUNT(*) FROM user_accounts WHERE deactivated = ?),
(SELECT COUNT(*) FROM user_devices)`
// Stats implements storage.UserStore.
func (u *Users) Stats(ctx context.Context) (storage.Stats, error) {
var s storage.Stats
// The flags are compared against bound values rather than TRUE, which
// SQLite only learned in 3.23 and which PostgreSQL spells differently
// from an integer.
err := u.db.QueryRowContext(ctx, u.q(statsSQL), true, true).
Scan(&s.Users, &s.Admins, &s.Deactivated, &s.Devices)
if err != nil {
return storage.Stats{}, fmt.Errorf("user stats: %w", err)
}
return s, nil
}
const (
selectDeviceForUpdateSQL = `
SELECT display_name, created_at_ms FROM user_devices
WHERE localpart = ? AND device_id = ?`
insertDeviceSQL = `
INSERT INTO user_devices
(localpart, device_id, display_name, created_at_ms, last_seen_ms, last_seen_ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?)`
touchDeviceSQL = `
UPDATE user_devices SET last_seen_ms = ?, last_seen_ip = ?, user_agent = ?
WHERE localpart = ? AND device_id = ?`
deleteDeviceTokensSQL = `DELETE FROM user_access_tokens WHERE localpart = ? AND device_id = ?`
deleteDeviceRefreshTokensSQL = `DELETE FROM user_refresh_tokens WHERE localpart = ? AND device_id = ?`
insertAccessTokenSQL = `
INSERT INTO user_access_tokens (token_hash, localpart, device_id, created_at_ms, expires_at_ms)
VALUES (?, ?, ?, ?, ?)`
insertRefreshTokenSQL = `
INSERT INTO user_refresh_tokens (token_hash, localpart, device_id, created_at_ms, expires_at_ms)
VALUES (?, ?, ?, ?, ?)`
accountExistsSQL = `SELECT 1 FROM user_accounts WHERE localpart = ?`
)
// CreateSession implements storage.UserStore.
//
// Logging in on a device ID that already exists keeps the device — and its
// display name, because initial_device_display_name only applies to a device
// being created — but revokes the tokens it held, which is what the spec
// requires.
func (u *Users) CreateSession(ctx context.Context, s storage.NewSession) (storage.Device, error) {
device := storage.Device{
Localpart: s.Localpart,
ID: s.DeviceID,
DisplayName: s.DisplayName,
CreatedAtMS: s.CreatedAtMS,
LastSeenMS: s.CreatedAtMS,
LastSeenIP: s.LastSeenIP,
UserAgent: s.UserAgent,
}
err := u.tx(ctx, func(tx *sql.Tx) error {
var exists int
switch err := tx.QueryRowContext(ctx, u.q(accountExistsSQL), s.Localpart).Scan(&exists); {
case errors.Is(err, sql.ErrNoRows):
return fmt.Errorf("create session for %q: %w", s.Localpart, storage.ErrNoSuchUser)
case err != nil:
return fmt.Errorf("create session for %q: %w", s.Localpart, err)
}
var existingName string
var existingCreated int64
err := tx.QueryRowContext(ctx, u.q(selectDeviceForUpdateSQL), s.Localpart, s.DeviceID).
Scan(&existingName, &existingCreated)
switch {
case errors.Is(err, sql.ErrNoRows):
if _, err := tx.ExecContext(ctx, u.q(insertDeviceSQL),
s.Localpart, s.DeviceID, s.DisplayName, s.CreatedAtMS,
s.CreatedAtMS, s.LastSeenIP, s.UserAgent); err != nil {
return fmt.Errorf("create device %q: %w", s.DeviceID, err)
}
case err != nil:
return fmt.Errorf("read device %q: %w", s.DeviceID, err)
default:
device.DisplayName = existingName
device.CreatedAtMS = existingCreated
if _, err := tx.ExecContext(ctx, u.q(touchDeviceSQL),
s.CreatedAtMS, s.LastSeenIP, s.UserAgent, s.Localpart, s.DeviceID); err != nil {
return fmt.Errorf("update device %q: %w", s.DeviceID, err)
}
for _, query := range []string{deleteDeviceTokensSQL, deleteDeviceRefreshTokensSQL} {
if _, err := tx.ExecContext(ctx, u.q(query), s.Localpart, s.DeviceID); err != nil {
return fmt.Errorf("revoke tokens of device %q: %w", s.DeviceID, err)
}
}
}
if _, err := tx.ExecContext(ctx, u.q(insertAccessTokenSQL),
s.AccessTokenHash, s.Localpart, s.DeviceID,
s.CreatedAtMS, s.AccessTokenExpiresAtMS); err != nil {
return fmt.Errorf("store access token: %w", err)
}
if s.RefreshTokenHash != "" {
if _, err := tx.ExecContext(ctx, u.q(insertRefreshTokenSQL),
s.RefreshTokenHash, s.Localpart, s.DeviceID,
s.CreatedAtMS, s.RefreshTokenExpiresAtMS); err != nil {
return fmt.Errorf("store refresh token: %w", err)
}
}
return nil
})
if err != nil {
return storage.Device{}, err
}
return device, nil
}
const (
selectRefreshTokenSQL = `
SELECT localpart, device_id, expires_at_ms FROM user_refresh_tokens WHERE token_hash = ?`
deleteRefreshTokenSQL = `DELETE FROM user_refresh_tokens WHERE token_hash = ?`
selectAccountFlagsSQL = `SELECT admin, deactivated FROM user_accounts WHERE localpart = ?`
)
// RefreshSession implements storage.UserStore.
//
// The spent refresh token and the access token it had minted both go away
// immediately. The spec allows keeping the old access token alive for a grace
// period; refusing it at once is the stricter reading and means a stolen
// token stops working the moment the real client refreshes.
func (u *Users) RefreshSession(ctx context.Context, r storage.RefreshedSession) (storage.TokenInfo, error) {
var info storage.TokenInfo
err := u.tx(ctx, func(tx *sql.Tx) error {
var expiresAt int64
err := tx.QueryRowContext(ctx, u.q(selectRefreshTokenSQL), r.RefreshTokenHash).
Scan(&info.Localpart, &info.DeviceID, &expiresAt)
switch {
case errors.Is(err, sql.ErrNoRows):
return fmt.Errorf("refresh token: %w", storage.ErrNoSuchToken)
case err != nil:
return fmt.Errorf("refresh token: %w", err)
}
if expiresAt != 0 && expiresAt <= r.RefreshedAtMS {
// Clean the dead token up on the way past; nothing else sweeps.
if _, err := tx.ExecContext(ctx, u.q(deleteRefreshTokenSQL), r.RefreshTokenHash); err != nil {
return fmt.Errorf("delete expired refresh token: %w", err)
}
return fmt.Errorf("refresh token has expired: %w", storage.ErrNoSuchToken)
}
if _, err := tx.ExecContext(ctx, u.q(deleteRefreshTokenSQL), r.RefreshTokenHash); err != nil {
return fmt.Errorf("spend refresh token: %w", err)
}
if _, err := tx.ExecContext(ctx, u.q(deleteDeviceTokensSQL), info.Localpart, info.DeviceID); err != nil {
return fmt.Errorf("revoke previous access token: %w", err)
}
if _, err := tx.ExecContext(ctx, u.q(insertAccessTokenSQL),
r.NewAccessTokenHash, info.Localpart, info.DeviceID,
r.RefreshedAtMS, r.AccessTokenExpiresAtMS); err != nil {
return fmt.Errorf("store access token: %w", err)
}
if r.NewRefreshTokenHash != "" {
if _, err := tx.ExecContext(ctx, u.q(insertRefreshTokenSQL),
r.NewRefreshTokenHash, info.Localpart, info.DeviceID,
r.RefreshedAtMS, r.RefreshTokenExpiresAtMS); err != nil {
return fmt.Errorf("store refresh token: %w", err)
}
}
if err := tx.QueryRowContext(ctx, u.q(selectAccountFlagsSQL), info.Localpart).
Scan(&info.Admin, &info.Deactivated); err != nil {
return fmt.Errorf("read account %q: %w", info.Localpart, err)
}
info.ExpiresAtMS = r.AccessTokenExpiresAtMS
return nil
})
if err != nil {
return storage.TokenInfo{}, err
}
return info, nil
}
const selectTokenInfoSQL = `
SELECT t.localpart, t.device_id, t.expires_at_ms, a.admin, a.deactivated
FROM user_access_tokens t
JOIN user_accounts a ON a.localpart = t.localpart
WHERE t.token_hash = ?`
// TokenInfo implements storage.UserStore.
func (u *Users) TokenInfo(ctx context.Context, tokenHash string) (storage.TokenInfo, error) {
var info storage.TokenInfo
err := u.db.QueryRowContext(ctx, u.q(selectTokenInfoSQL), tokenHash).Scan(
&info.Localpart, &info.DeviceID, &info.ExpiresAtMS, &info.Admin, &info.Deactivated)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.TokenInfo{}, storage.ErrNoSuchToken
case err != nil:
return storage.TokenInfo{}, fmt.Errorf("look up access token: %w", err)
}
return info, nil
}
// TouchDevice implements storage.UserStore. A device that has gone away
// between authenticating and recording the visit is not an error: the
// request is being answered either way.
func (u *Users) TouchDevice(ctx context.Context, localpart, deviceID, ip, userAgent string, seenAtMS int64) error {
if _, err := u.db.ExecContext(ctx, u.q(touchDeviceSQL),
seenAtMS, ip, userAgent, localpart, deviceID); err != nil {
return fmt.Errorf("touch device %q: %w", deviceID, err)
}
return nil
}
const (
selectDeviceSQL = `
SELECT localpart, device_id, display_name, created_at_ms, last_seen_ms, last_seen_ip, user_agent
FROM user_devices WHERE localpart = ? AND device_id = ?`
selectDevicesSQL = `
SELECT localpart, device_id, display_name, created_at_ms, last_seen_ms, last_seen_ip, user_agent
FROM user_devices WHERE localpart = ? ORDER BY device_id`
updateDeviceDisplayNameSQL = `
UPDATE user_devices SET display_name = ? WHERE localpart = ? AND device_id = ?`
)
// Device implements storage.UserStore.
func (u *Users) Device(ctx context.Context, localpart, deviceID string) (storage.Device, error) {
var d storage.Device
err := u.db.QueryRowContext(ctx, u.q(selectDeviceSQL), localpart, deviceID).Scan(
&d.Localpart, &d.ID, &d.DisplayName, &d.CreatedAtMS,
&d.LastSeenMS, &d.LastSeenIP, &d.UserAgent)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.Device{}, fmt.Errorf("device %q: %w", deviceID, storage.ErrNoSuchDevice)
case err != nil:
return storage.Device{}, fmt.Errorf("device %q: %w", deviceID, err)
}
return d, nil
}
// Devices implements storage.UserStore.
func (u *Users) Devices(ctx context.Context, localpart string) ([]storage.Device, error) {
rows, err := u.db.QueryContext(ctx, u.q(selectDevicesSQL), localpart)
if err != nil {
return nil, fmt.Errorf("list devices of %q: %w", localpart, err)
}
defer rows.Close()
devices := []storage.Device{}
for rows.Next() {
var d storage.Device
if err := rows.Scan(&d.Localpart, &d.ID, &d.DisplayName, &d.CreatedAtMS,
&d.LastSeenMS, &d.LastSeenIP, &d.UserAgent); err != nil {
return nil, fmt.Errorf("scan device: %w", err)
}
devices = append(devices, d)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list devices of %q: %w", localpart, err)
}
return devices, nil
}
// SetDeviceDisplayName implements storage.UserStore.
func (u *Users) SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error {
res, err := u.db.ExecContext(ctx, u.q(updateDeviceDisplayNameSQL), displayName, localpart, deviceID)
if err != nil {
return fmt.Errorf("rename device %q: %w", deviceID, err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rename device %q: %w", deviceID, err)
}
if affected == 0 {
return fmt.Errorf("rename device %q: %w", deviceID, storage.ErrNoSuchDevice)
}
return nil
}
// DeleteDevices implements storage.UserStore.
//
// Tokens are deleted explicitly rather than left to ON DELETE CASCADE: SQLite
// only enforces foreign keys when the connection asks it to, so relying on
// the cascade would make revocation depend on a DSN pragma.
func (u *Users) DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) (int, error) {
if len(deviceIDs) == 0 {
return 0, nil
}
args := make([]any, 0, len(deviceIDs)+1)
args = append(args, localpart)
for _, id := range deviceIDs {
args = append(args, id)
}
in := "(" + strings.TrimSuffix(strings.Repeat("?,", len(deviceIDs)), ",") + ")"
var deleted int
err := u.tx(ctx, func(tx *sql.Tx) error {
for _, table := range []string{"user_access_tokens", "user_refresh_tokens"} {
query := "DELETE FROM " + table + " WHERE localpart = ? AND device_id IN " + in
if _, err := tx.ExecContext(ctx, u.q(query), args...); err != nil {
return fmt.Errorf("revoke tokens: %w", err)
}
}
res, err := tx.ExecContext(ctx,
u.q("DELETE FROM user_devices WHERE localpart = ? AND device_id IN "+in), args...)
if err != nil {
return fmt.Errorf("delete devices: %w", err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete devices: %w", err)
}
deleted = int(affected)
return nil
})
return deleted, err
}
// DeleteAllDevices implements storage.UserStore.
func (u *Users) DeleteAllDevices(ctx context.Context, localpart string) (int, error) {
var deleted int
err := u.tx(ctx, func(tx *sql.Tx) error {
for _, table := range []string{"user_access_tokens", "user_refresh_tokens"} {
if _, err := tx.ExecContext(ctx,
u.q("DELETE FROM "+table+" WHERE localpart = ?"), localpart); err != nil {
return fmt.Errorf("revoke tokens of %q: %w", localpart, err)
}
}
res, err := tx.ExecContext(ctx,
u.q("DELETE FROM user_devices WHERE localpart = ?"), localpart)
if err != nil {
return fmt.Errorf("delete devices of %q: %w", localpart, err)
}
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete devices of %q: %w", localpart, err)
}
deleted = int(affected)
return nil
})
return deleted, err
}
const (
selectAccountDataSQL = `
SELECT content FROM user_account_data
WHERE localpart = ? AND room_id = ? AND data_type = ?`
upsertAccountDataSQL = `
INSERT INTO user_account_data (localpart, room_id, data_type, content)
VALUES (?, ?, ?, ?)
ON CONFLICT (localpart, room_id, data_type)
DO UPDATE SET content = excluded.content`
)
// AccountData implements storage.UserStore.
func (u *Users) AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error) {
var content string
err := u.db.QueryRowContext(ctx, u.q(selectAccountDataSQL), localpart, roomID, dataType).Scan(&content)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, fmt.Errorf("account data %q: %w", dataType, storage.ErrNoSuchAccountData)
case err != nil:
return nil, fmt.Errorf("account data %q: %w", dataType, err)
}
return []byte(content), nil
}
// SetAccountData implements storage.UserStore. The content is stored as text
// rather than a JSON column type: the two engines disagree on what JSON
// normalisation does, and account data has to come back out byte for byte.
func (u *Users) SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error {
if _, err := u.db.ExecContext(ctx, u.q(upsertAccountDataSQL),
localpart, roomID, dataType, string(content)); err != nil {
return fmt.Errorf("set account data %q: %w", dataType, err)
}
return nil
}
// Compile-time proof that the shared implementation satisfies the interface.
var _ storage.UserStore = (*Users)(nil)
+3
View File
@@ -61,6 +61,9 @@ type Database interface {
// settings service (federation and registration switches).
Settings() SettingsStore
// Users is the userapi store: accounts, devices, tokens, account data.
Users() UserStore
// Close releases the connection pool.
Close() error
}
+226
View File
@@ -0,0 +1,226 @@
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
}
+494
View File
@@ -0,0 +1,494 @@
package storage_test
import (
"database/sql"
"errors"
"os"
"testing"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// migratedTestDB opens a driver, brings its schema up to date and empties
// the userapi tables, which is what every user-store test needs before it
// can touch one.
func migratedTestDB(t *testing.T, driver string) storage.Database {
t.Helper()
db := openTestDB(t, driver)
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("Migrate: %v", err)
}
resetUserTables(t, driver)
return db
}
// resetUserTables empties the userapi tables so that each test starts from a
// known state.
//
// SQLite gets a fresh file per test and needs nothing. The PostgreSQL tests
// all share the one database the environment points at, so without this the
// second test to create "alice" would collide with the first — and would do
// so only in CI, where PostgreSQL is available.
func resetUserTables(t *testing.T, driver string) {
t.Helper()
if driver != storage.DriverPostgres {
return
}
// The storage interface has no "delete an account", on purpose:
// deactivation is Phase 9 and deletion may never exist. Clearing the
// tables is a test concern, so the test does it directly.
db, err := sql.Open("pgx", os.Getenv("WARP_TEST_POSTGRES_DSN"))
if err != nil {
t.Fatalf("open postgres to reset the user tables: %v", err)
}
defer func() { _ = db.Close() }()
if _, err := db.ExecContext(t.Context(), `TRUNCATE
user_account_data, user_refresh_tokens, user_access_tokens,
user_devices, user_accounts`); err != nil {
t.Fatalf("reset the user tables: %v", err)
}
}
// newAccount is the minimum CreateAccount needs.
func newAccount(localpart string) storage.NewAccount {
return storage.NewAccount{
Localpart: localpart,
PasswordHash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaA",
CreatedAtMS: 1_700_000_000_000,
}
}
func TestUserAccounts(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.Account(ctx, "nobody"); !errors.Is(err, storage.ErrNoSuchUser) {
t.Errorf("Account of an unknown user = %v, want ErrNoSuchUser", err)
}
acc, err := users.CreateAccount(ctx, newAccount("alice"))
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if acc.Localpart != "alice" || acc.Admin || acc.Deactivated {
t.Errorf("CreateAccount returned %+v", acc)
}
// A taken localpart must be reported as such, not as a generic
// database failure: registration turns it into M_USER_IN_USE.
if _, err := users.CreateAccount(ctx, newAccount("alice")); !errors.Is(err, storage.ErrUserExists) {
t.Errorf("duplicate CreateAccount = %v, want ErrUserExists", err)
}
stored, err := users.Account(ctx, "alice")
if err != nil {
t.Fatalf("Account: %v", err)
}
if stored.CreatedAtMS != 1_700_000_000_000 {
t.Errorf("created_at_ms = %d, want 1700000000000", stored.CreatedAtMS)
}
hash, err := users.PasswordHash(ctx, "alice")
if err != nil || hash == "" {
t.Fatalf("PasswordHash = %q, %v", hash, err)
}
if err := users.SetPasswordHash(ctx, "alice", "newhash"); err != nil {
t.Fatalf("SetPasswordHash: %v", err)
}
if hash, _ = users.PasswordHash(ctx, "alice"); hash != "newhash" {
t.Errorf("PasswordHash after update = %q", hash)
}
if err := users.SetAdmin(ctx, "alice", true); err != nil {
t.Fatalf("SetAdmin: %v", err)
}
if err := users.SetDisplayName(ctx, "alice", "Alice"); err != nil {
t.Fatalf("SetDisplayName: %v", err)
}
if err := users.SetAvatarURL(ctx, "alice", "mxc://warp.example/abc"); err != nil {
t.Fatalf("SetAvatarURL: %v", err)
}
stored, err = users.Account(ctx, "alice")
if err != nil {
t.Fatalf("Account: %v", err)
}
if !stored.Admin {
t.Error("the admin flag did not survive the round trip")
}
if stored.DisplayName != "Alice" || stored.AvatarURL != "mxc://warp.example/abc" {
t.Errorf("profile = %+v", stored)
}
// Updating an absent account is ErrNoSuchUser, so that callers
// do not have to look it up first.
if err := users.SetDisplayName(ctx, "nobody", "x"); !errors.Is(err, storage.ErrNoSuchUser) {
t.Errorf("SetDisplayName on an unknown user = %v, want ErrNoSuchUser", err)
}
})
}
}
func TestUserAccountListingAndStats(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
for _, localpart := range []string{"amy", "bob", "bobby", "zoe_x"} {
if _, err := users.CreateAccount(ctx, newAccount(localpart)); err != nil {
t.Fatalf("CreateAccount(%s): %v", localpart, err)
}
}
if err := users.SetAdmin(ctx, "amy", true); err != nil {
t.Fatalf("SetAdmin: %v", err)
}
if err := users.SetDisplayName(ctx, "zoe_x", "Zoe"); err != nil {
t.Fatalf("SetDisplayName: %v", err)
}
all, total, err := users.ListAccounts(ctx, storage.AccountFilter{})
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
if total != 4 || len(all) != 4 {
t.Fatalf("ListAccounts returned %d of %d, want 4 of 4", len(all), total)
}
if all[0].Localpart != "amy" || all[3].Localpart != "zoe_x" {
t.Errorf("accounts are not ordered by localpart: %v", all)
}
page, total, err := users.ListAccounts(ctx, storage.AccountFilter{Limit: 2, Offset: 1})
if err != nil {
t.Fatalf("ListAccounts paged: %v", err)
}
if total != 4 || len(page) != 2 || page[0].Localpart != "bob" {
t.Errorf("paged listing = %v (total %d)", page, total)
}
found, total, err := users.ListAccounts(ctx, storage.AccountFilter{Search: "BOB"})
if err != nil {
t.Fatalf("ListAccounts search: %v", err)
}
if total != 2 || len(found) != 2 {
t.Errorf("case-insensitive search for bob found %d, want 2", total)
}
// A display name is searchable too.
if _, total, err = users.ListAccounts(ctx, storage.AccountFilter{Search: "zoe"}); err != nil {
t.Fatalf("ListAccounts: %v", err)
} else if total != 1 {
t.Errorf("searching display names found %d, want 1", total)
}
// "_" is a LIKE wildcard; escaping it is what keeps this from
// matching every three-letter localpart.
if _, total, err = users.ListAccounts(ctx, storage.AccountFilter{Search: "e_x"}); err != nil {
t.Fatalf("ListAccounts: %v", err)
} else if total != 1 {
t.Errorf("searching for a literal underscore found %d, want 1", total)
}
stats, err := users.Stats(ctx)
if err != nil {
t.Fatalf("Stats: %v", err)
}
if stats.Users != 4 || stats.Admins != 1 || stats.Deactivated != 0 || stats.Devices != 0 {
t.Errorf("Stats = %+v", stats)
}
})
}
}
func TestUserSessions(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
session := storage.NewSession{
Localpart: "alice",
DeviceID: "DEV1",
DisplayName: "first login",
CreatedAtMS: 1000,
AccessTokenHash: "hash-a",
LastSeenIP: "10.0.0.1",
UserAgent: "warp-test",
}
device, err := users.CreateSession(ctx, session)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if device.ID != "DEV1" || device.DisplayName != "first login" {
t.Errorf("CreateSession returned %+v", device)
}
// A session for an unknown account must fail loudly rather than
// leave a device with no owner.
orphan := session
orphan.Localpart, orphan.AccessTokenHash = "nobody", "hash-orphan"
if _, err := users.CreateSession(ctx, orphan); !errors.Is(err, storage.ErrNoSuchUser) {
t.Errorf("CreateSession for an unknown user = %v, want ErrNoSuchUser", err)
}
info, err := users.TokenInfo(ctx, "hash-a")
if err != nil {
t.Fatalf("TokenInfo: %v", err)
}
if info.Localpart != "alice" || info.DeviceID != "DEV1" || info.ExpiresAtMS != 0 {
t.Errorf("TokenInfo = %+v", info)
}
if _, err := users.TokenInfo(ctx, "nope"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Errorf("TokenInfo of an unknown token = %v, want ErrNoSuchToken", err)
}
// Logging in again on the same device keeps the device and its
// display name, and revokes the token it had.
again := session
again.AccessTokenHash = "hash-b"
again.DisplayName = "ignored on an existing device"
again.CreatedAtMS = 2000
device, err = users.CreateSession(ctx, again)
if err != nil {
t.Fatalf("CreateSession (same device): %v", err)
}
if device.DisplayName != "first login" {
t.Errorf("display name = %q; initial_device_display_name applies to new devices only", device.DisplayName)
}
if device.CreatedAtMS != 1000 {
t.Errorf("created_at_ms = %d; re-login must not reset it", device.CreatedAtMS)
}
if _, err := users.TokenInfo(ctx, "hash-a"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Error("the previous access token of a re-used device was not revoked")
}
if _, err := users.TokenInfo(ctx, "hash-b"); err != nil {
t.Errorf("the new access token does not work: %v", err)
}
stored, err := users.Device(ctx, "alice", "DEV1")
if err != nil {
t.Fatalf("Device: %v", err)
}
if stored.LastSeenMS != 2000 || stored.LastSeenIP != "10.0.0.1" {
t.Errorf("device = %+v", stored)
}
if _, err := users.Device(ctx, "alice", "GONE"); !errors.Is(err, storage.ErrNoSuchDevice) {
t.Errorf("Device of an unknown device = %v, want ErrNoSuchDevice", err)
}
if err := users.TouchDevice(ctx, "alice", "DEV1", "10.0.0.2", "warp-test/2", 3000); err != nil {
t.Fatalf("TouchDevice: %v", err)
}
if stored, err = users.Device(ctx, "alice", "DEV1"); err != nil {
t.Fatal(err)
} else if stored.LastSeenMS != 3000 || stored.LastSeenIP != "10.0.0.2" {
t.Errorf("TouchDevice did not record the visit: %+v", stored)
}
if err := users.SetDeviceDisplayName(ctx, "alice", "DEV1", "renamed"); err != nil {
t.Fatalf("SetDeviceDisplayName: %v", err)
}
if err := users.SetDeviceDisplayName(ctx, "alice", "GONE", "x"); !errors.Is(err, storage.ErrNoSuchDevice) {
t.Errorf("renaming an unknown device = %v, want ErrNoSuchDevice", err)
}
second := storage.NewSession{
Localpart: "alice",
DeviceID: "DEV2",
CreatedAtMS: 4000,
AccessTokenHash: "hash-c",
}
if _, err := users.CreateSession(ctx, second); err != nil {
t.Fatalf("CreateSession (second device): %v", err)
}
devices, err := users.Devices(ctx, "alice")
if err != nil {
t.Fatalf("Devices: %v", err)
}
if len(devices) != 2 || devices[0].ID != "DEV1" || devices[1].ID != "DEV2" {
t.Errorf("Devices = %v", devices)
}
deleted, err := users.DeleteDevices(ctx, "alice", []string{"DEV1", "NEVER-EXISTED"})
if err != nil {
t.Fatalf("DeleteDevices: %v", err)
}
if deleted != 1 {
t.Errorf("DeleteDevices reported %d, want 1", deleted)
}
if _, err := users.TokenInfo(ctx, "hash-b"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Error("deleting a device must revoke its access tokens")
}
deleted, err = users.DeleteAllDevices(ctx, "alice")
if err != nil {
t.Fatalf("DeleteAllDevices: %v", err)
}
if deleted != 1 {
t.Errorf("DeleteAllDevices reported %d, want 1", deleted)
}
if _, err := users.TokenInfo(ctx, "hash-c"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Error("logout/all must revoke every token")
}
if devices, err = users.Devices(ctx, "alice"); err != nil {
t.Fatal(err)
} else if len(devices) != 0 {
t.Errorf("devices survived DeleteAllDevices: %v", devices)
}
})
}
}
func TestUserRefreshTokens(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := users.CreateSession(ctx, storage.NewSession{
Localpart: "alice",
DeviceID: "DEV1",
CreatedAtMS: 1000,
AccessTokenHash: "access-1",
AccessTokenExpiresAtMS: 5000,
RefreshTokenHash: "refresh-1",
RefreshTokenExpiresAtMS: 0,
}); err != nil {
t.Fatalf("CreateSession: %v", err)
}
info, err := users.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: "refresh-1",
NewAccessTokenHash: "access-2",
AccessTokenExpiresAtMS: 9000,
NewRefreshTokenHash: "refresh-2",
RefreshedAtMS: 4000,
})
if err != nil {
t.Fatalf("RefreshSession: %v", err)
}
if info.Localpart != "alice" || info.DeviceID != "DEV1" {
t.Errorf("RefreshSession returned %+v", info)
}
// Both halves of the old pair are gone: a replayed refresh token
// is the signal that one of the two copies is a thief.
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: "refresh-1",
NewAccessTokenHash: "access-3",
NewRefreshTokenHash: "refresh-3",
RefreshedAtMS: 4100,
}); !errors.Is(err, storage.ErrNoSuchToken) {
t.Errorf("replaying a spent refresh token = %v, want ErrNoSuchToken", err)
}
if _, err := users.TokenInfo(ctx, "access-1"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Error("refreshing must invalidate the previous access token")
}
if info, err = users.TokenInfo(ctx, "access-2"); err != nil {
t.Fatalf("the refreshed access token does not work: %v", err)
} else if info.ExpiresAtMS != 9000 {
t.Errorf("expires_at_ms = %d, want 9000", info.ExpiresAtMS)
}
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: "nonexistent",
NewAccessTokenHash: "access-4",
RefreshedAtMS: 4200,
}); !errors.Is(err, storage.ErrNoSuchToken) {
t.Errorf("unknown refresh token = %v, want ErrNoSuchToken", err)
}
})
}
}
func TestUserRefreshTokenExpiry(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := users.CreateSession(ctx, storage.NewSession{
Localpart: "alice",
DeviceID: "DEV1",
CreatedAtMS: 1000,
AccessTokenHash: "access-1",
RefreshTokenHash: "refresh-1",
RefreshTokenExpiresAtMS: 2000,
}); err != nil {
t.Fatalf("CreateSession: %v", err)
}
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: "refresh-1",
NewAccessTokenHash: "access-2",
RefreshedAtMS: 2001,
}); !errors.Is(err, storage.ErrNoSuchToken) {
t.Errorf("expired refresh token = %v, want ErrNoSuchToken", err)
}
})
}
}
func TestUserAccountData(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := users.AccountData(ctx, "alice", "", "m.push_rules"); !errors.Is(err, storage.ErrNoSuchAccountData) {
t.Errorf("unset account data = %v, want ErrNoSuchAccountData", err)
}
global := []byte(`{"theme":"dark"}`)
if err := users.SetAccountData(ctx, "alice", "", "im.warp.prefs", global); err != nil {
t.Fatalf("SetAccountData: %v", err)
}
got, err := users.AccountData(ctx, "alice", "", "im.warp.prefs")
if err != nil {
t.Fatalf("AccountData: %v", err)
}
if string(got) != string(global) {
t.Errorf("account data came back as %s, want %s", got, global)
}
// Overwriting replaces rather than conflicting.
if err := users.SetAccountData(ctx, "alice", "", "im.warp.prefs", []byte(`{"theme":"light"}`)); err != nil {
t.Fatalf("SetAccountData (overwrite): %v", err)
}
if got, err = users.AccountData(ctx, "alice", "", "im.warp.prefs"); err != nil {
t.Fatal(err)
} else if string(got) != `{"theme":"light"}` {
t.Errorf("overwritten account data = %s", got)
}
// Room account data is a separate key space from global data of
// the same type.
if err := users.SetAccountData(ctx, "alice", "!room:warp.example", "im.warp.prefs", []byte(`{"muted":true}`)); err != nil {
t.Fatalf("SetAccountData (room): %v", err)
}
if got, err = users.AccountData(ctx, "alice", "!room:warp.example", "im.warp.prefs"); err != nil {
t.Fatal(err)
} else if string(got) != `{"muted":true}` {
t.Errorf("room account data = %s", got)
}
if got, err = users.AccountData(ctx, "alice", "", "im.warp.prefs"); err != nil {
t.Fatal(err)
} else if string(got) != `{"theme":"light"}` {
t.Errorf("room account data overwrote the global value: %s", got)
}
})
}
}