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
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:
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)
|
||||
);
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user