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

+50
View File
@@ -97,6 +97,21 @@ jobs:
WARP_TEST_POSTGRES_DSN: postgres://warp:warp@localhost:5432/warp_test?sslmode=disable
run: go test -count=1 ./internal/storage/...
complement:
name: Complement (federation off)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
# Complement is a separate Go module with its own dependencies; the
# runner script clones it into .complement/.
- name: Run the Complement subset for the phases that are done
run: bash server/test/complement/run.sh
lint:
name: golangci-lint
runs-on: ubuntu-latest
@@ -154,6 +169,41 @@ jobs:
status="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8008/.well-known/matrix/server)"
test "$status" = "404"
# Registration is off in a fresh install, so the first account is
# made from the command line. Everything from here on is the
# Phase 1 surface, through the real binary.
./bin/warp create-account --username ci-admin --password hunter2hunter2 --admin
token="$(curl -fsS http://127.0.0.1:8008/_matrix/client/v3/login -d '{
"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": "ci-admin"},
"password": "hunter2hunter2"
}' | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')"
test -n "$token"
curl -fsS http://127.0.0.1:8008/_matrix/client/v3/account/whoami \
-H "Authorization: Bearer $token" | grep -q '@ci-admin:ci.example'
curl -fsS http://127.0.0.1:8008/_matrix/client/v3/capabilities \
-H "Authorization: Bearer $token" | grep -q 'm.room_versions'
curl -fsS http://127.0.0.1:8008/_warp/admin/v1/stats \
-H "Authorization: Bearer $token" | grep -q '"admins":1'
# An unauthenticated request must be refused, not served.
status="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8008/_warp/admin/v1/stats)"
test "$status" = "401"
# Registration is refused while the switch is off, and accepted
# once the admin API flips it — with no restart.
status="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
http://127.0.0.1:8008/_matrix/client/v3/register -d '{}')"
test "$status" = "403"
curl -fsS -X PUT http://127.0.0.1:8008/_warp/admin/v1/registration \
-H "Authorization: Bearer $token" -d '{"enabled":true}' | grep -q '"enabled":true'
status="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
http://127.0.0.1:8008/_matrix/client/v3/register -d '{}')"
test "$status" = "401"
# Graceful shutdown: SIGTERM must stop the process cleanly.
kill -TERM "$server_pid"
wait "$server_pid"
+8 -3
View File
@@ -1,8 +1,10 @@
# Build output
# Build output. The binary patterns are anchored to the repository root on
# purpose: unanchored, `warp` also matches the directory server/cmd/warp,
# which silently excluded the whole command package.
/bin/
/dist/
warp
warp.exe
/warp
/warp.exe
# Local configuration and state
/warp.yaml
@@ -12,6 +14,9 @@ warp.exe
*.db-wal
/pb_data/
# Complement checkout, cloned by server/test/complement/run.sh
/.complement/
# Go
/go.work.sum
*.test
+13 -3
View File
@@ -9,6 +9,8 @@ BIN_DIR := bin
BINARY := warp
SERVER_DIR := server
COMPLEMENT_IMAGE ?= complement-warp
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.0.0-dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
@@ -84,10 +86,18 @@ generate-keys: build ## Create the ed25519 signing key
migrate: build ## Apply pending database migrations
./$(BIN_DIR)/$(BINARY)$(EXE) migrate
.PHONY: create-account
create-account: build ## Create an account: make create-account ACCOUNT=alice ARGS=--admin
@test -n "$(ACCOUNT)" || { echo "set ACCOUNT, e.g. make create-account ACCOUNT=alice ARGS=--admin"; exit 1; }
./$(BIN_DIR)/$(BINARY)$(EXE) create-account --username "$(ACCOUNT)" $(ARGS)
.PHONY: complement-image
complement-image: ## Build the Complement homeserver image
docker build -t $(COMPLEMENT_IMAGE) -f $(SERVER_DIR)/test/complement/Dockerfile .
.PHONY: complement
complement: ## Run the Complement suite (Phase 1 onwards)
@echo "Complement is wired up in Phase 1, once registration and login exist."
@exit 1
complement: ## Run the Complement subset for the phases that are done
bash $(SERVER_DIR)/test/complement/run.sh
.PHONY: docker
docker: ## Build the container image
+45 -15
View File
@@ -9,26 +9,39 @@ what exists today.
## Status
**Phase 0 — bootstrap. Complete.** There is a server binary that boots,
serves its discovery endpoints and nothing else yet: no accounts, no rooms,
no sync. Those are Phases 1 to 4.
**Phase 1 — accounts and sessions. Complete.** A client can register, log
in, manage its devices and set its profile. There are no rooms and no real
`/sync` yet: those are Phases 3 and 4.
What works now:
Client-Server API:
| | |
|---|---|
| `GET /_matrix/client/versions` | spec versions this build claims |
| `GET /.well-known/matrix/client` | client discovery |
| `GET /.well-known/matrix/server` | server discovery — **only while federation is on** |
| `GET /healthz` | liveness, including a database ping |
| `GET /metrics` | Prometheus |
| `POST /register`, `GET /register/available` | sign-up, with `m.login.dummy` and optional registration tokens |
| `GET`, `POST /login` · `POST /logout`, `/logout/all` · `POST /refresh` | sessions, with `m.login.password` and refresh tokens |
| `GET /account/whoami` · `GET /capabilities` | who am I, and what this build can do |
| `GET /devices`, `GET`/`PUT`/`DELETE /devices/{id}` · `POST /delete_devices` | device management; deletion re-asks for the password |
| `GET /profile/{user}` and its `displayname` / `avatar_url` sub-resources | profiles — reads are public, writes are your own |
| `GET`/`PUT /user/{user}/account_data/{type}` and the per-room variant | account data, stored and returned byte for byte |
| `GET /sync` | a placeholder: an empty, well-formed response, because there is nothing to sync yet |
| `GET /_matrix/client/versions` · `/.well-known/matrix/client` | discovery |
| `/.well-known/matrix/server` | server discovery — **only while federation is on** |
| `GET /healthz` · `GET /metrics` | liveness with a database ping, and Prometheus |
| anything else under `/_matrix/` | `404 M_UNRECOGNIZED`, as the spec requires |
Also in place: YAML + environment configuration, structured logging,
graceful shutdown, the storage interfaces with a working SQLite and
PostgreSQL driver (and a PocketBase stub), the embedded migration runner,
the `server_settings` table with the runtime federation and registration
switches, and `warp generate-keys`.
Admin API under `/_warp/admin/v1`: the federation and registration
switches, user listing and creation, and the dashboard counters. See
[docs/admin-api.md](docs/admin-api.md).
Also in place: argon2id passwords, hashed access and refresh tokens,
per-endpoint rate limiting, YAML + environment configuration, structured
logging, graceful shutdown, the storage interfaces with a working SQLite and
PostgreSQL driver (and a PocketBase stub), the embedded migration runner and
the runtime settings service.
Complement, the official integration suite, is wired up for the
federation-off profile: `make complement` runs `TestRegistration`,
`TestLogin` and `TestDeviceManagement`.
## Quick start
@@ -41,6 +54,7 @@ cp warp.yaml.example warp.yaml
make build
./bin/warp generate-keys
./bin/warp create-account --username you --admin
./bin/warp serve
```
@@ -51,6 +65,10 @@ curl localhost:8008/healthz
curl localhost:8008/_matrix/client/versions
```
Registration is off in a fresh install, which is why the first account is
made from the command line. Turn it on from the admin API when you want
others to sign up.
Without `make`, the same from the `server` directory:
```sh
@@ -79,8 +97,14 @@ security-relevant setting should never be silently ignored.
| `warp serve` | run the homeserver |
| `warp migrate [--status]` | apply (or list) pending database migrations |
| `warp generate-keys [--output path] [--key-id id]` | create the ed25519 signing key |
| `warp create-account --username name [--admin]` | create a local account, straight in the database |
| `warp version` | version and compiled-in storage drivers |
`create-account` talks to the database rather than to a running server, so
the first account on a new install can be made before anything is listening
and without turning registration on. It prompts for the password twice;
`--password-file` and `--password` are there for scripts.
The signing key is written as a single line, the same format Synapse uses:
`ed25519 <key-id> <unpadded-base64-seed>`. `generate-keys` refuses to
overwrite an existing key — losing it would invalidate every event this
@@ -107,6 +131,7 @@ make test # unit tests (SQLite)
make lint # golangci-lint
make check # vet + test, what CI runs
make cover # coverage.html
make complement # the Complement subset for the phases that are done (needs Docker)
# the storage tests against PostgreSQL as well
WARP_TEST_POSTGRES_DSN='postgres://warp:warp@localhost/warp_test?sslmode=disable' make test-postgres
@@ -119,19 +144,24 @@ Linux.
```
server/ the homeserver (Go module)
cmd/warp/ the binary: serve, migrate, generate-keys, version
cmd/warp/ the binary: serve, migrate, generate-keys, create-account, version
internal/
config/ YAML + env configuration
logging/ metrics/ slog and Prometheus setup
httputil/ Matrix error envelope, CORS, router, middleware
ratelimit/ per-endpoint token buckets
federation/ the federation gate and allow/deny policy
settings/ runtime settings service (the switches)
userapi/ accounts, passwords, devices, tokens, account data
clientapi/ the Client-Server API handlers
adminapi/ /_warp/admin/v1
setup/ wiring: storage, routes, graceful shutdown
matrix/signing/ ed25519 server keys
storage/ interfaces + driver registry
migrations/ embedded migration runner
sqlstore/ shared database/sql implementation
sqlite/ postgres/ pocketbase/
test/complement/ the Complement homeserver image and runner
apps/ clients: ui, admin, web, desktop, mobile (Phase 1+)
docs/
```
+2 -1
View File
@@ -3,6 +3,7 @@
| Document | What |
|---|---|
| [configuration.md](configuration.md) | every setting, and its environment variable |
| [development.md](development.md) | building, testing, adding a migration |
| [development.md](development.md) | building, testing, Complement, adding a migration |
| [admin-api.md](admin-api.md) | `/_warp/admin/v1`: the operator API behind the admin panel |
The design and roadmap are in [PLAN.md](../PLAN.md) at the repository root.
+137
View File
@@ -0,0 +1,137 @@
# Admin API
Everything under `/_warp/admin/v1` is Warp's own, not Matrix. Clients use
the Client-Server API; this is the operator's surface and the backend for
the admin panel (`apps/admin`, PLAN.md section 8.1).
## Authentication
A normal Matrix access token belonging to an account with the admin flag.
The panel logs in the way any client does, so there is no second credential
to manage:
```sh
TOKEN=$(curl -s localhost:8008/_matrix/client/v3/login \
-d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"root"},"password":"..."}' \
| jq -r .access_token)
curl localhost:8008/_warp/admin/v1/stats -H "Authorization: Bearer $TOKEN"
```
The token must be in the `Authorization` header. Unlike the C-S API, the
`?access_token=` form is not accepted here: an admin token in a URL ends up
in every access log between the browser and the server.
A valid token without the admin flag gets `403 M_FORBIDDEN` rather than a
404. The caller is a legitimate user of this server who is not allowed here,
and pretending the endpoint does not exist only makes the panel harder to
debug.
Make the first admin from the command line:
```sh
./bin/warp create-account --username root --admin
```
## Endpoints
### `GET /federation`, `PUT /federation`
The runtime federation switch of PLAN.md section 3.
```json
{
"enabled": false,
"toggleable": true,
"allowlist": [],
"denylist": []
}
```
`PUT {"enabled": true}` takes effect on the next request, with no restart,
and answers with the same document. `toggleable` is `false` when
`federation.allow_runtime_toggle` pinned the setting in the config file; a
`PUT` then returns `409` and the panel should grey the switch out rather
than offer something that will bounce.
Editing the allow and deny lists is Phase 9. They are reported now because
the dashboard has to show which servers a running Warp will talk to.
### `GET /registration`, `PUT /registration`
The same shape, without the lists, for open registration.
### `GET /users`
```
GET /users?search=ali&limit=50&offset=0
```
Ordered by localpart. `search` matches a substring of the localpart or the
display name, case-insensitively. `total` is the number of users matching
the query rather than the number returned, so the panel can page.
```json
{
"users": [
{
"user_id": "@alice:warp.example",
"display_name": "Alice",
"admin": false,
"deactivated": false,
"created_at_ms": 1760000000000
}
],
"total": 1,
"limit": 50,
"offset": 0
}
```
### `POST /users`
```json
{"username": "alice", "password": "...", "display_name": "Alice", "admin": false}
```
`201` with the created user. This is how an operator adds an account to a
server with registration turned off — which is the normal state of a
private Warp — so it ignores the registration switch deliberately: it is
the admin's own action, not a public sign-up. `username` is normalised
(lower-cased) like any other localpart. A taken name is
`400 M_USER_IN_USE`, an unusable one `400 M_INVALID_USERNAME`.
### `GET /stats`
The dashboard payload.
```json
{
"server_name": "warp.example",
"version": "0.0.0-dev",
"database": "sqlite",
"users": 12,
"admins": 1,
"deactivated": 0,
"devices": 19,
"federation_enabled": false,
"registration_enabled": false
}
```
Room and event counters join it in Phase 3, when there is something to
count.
## Errors
The same envelope as the Client-Server API — `{"errcode": ..., "error":
...}` — so the panel needs one error path, not two. `409 M_FORBIDDEN` is
specific to this API: the request was well formed and the server's own
configuration is what refuses it.
## Not here yet
Phase 9 completes the admin API: deactivating users, resetting passwords,
revoking devices, granting the admin flag, registration token management,
the federation allow/deny list editor, server notices, media quotas and an
audit log of admin actions. Rooms arrive with the roomserver in Phase 3.
+31
View File
@@ -40,18 +40,49 @@ List-valued variables are comma-separated. A single `-` clears a list:
| `database.conn_max_lifetime` | `WARP_DB_CONN_MAX_LIFETIME` | `1h` | |
| `database.auto_migrate` | `WARP_DB_AUTO_MIGRATE` | `true` | `false` makes startup fail while migrations are pending. |
| `client_api.cors_allowed_origins` | `WARP_CORS_ALLOWED_ORIGINS` | `["*"]` | The spec expects any origin. |
| `client_api.trust_x_forwarded_for` | `WARP_TRUST_X_FORWARDED_FOR` | `false` | Only behind a proxy that overwrites the header; the rate limiter keys on the result. |
| `client_api.access_token_lifetime` | `WARP_ACCESS_TOKEN_LIFETIME` | `0s` | `0` = never expire. With a lifetime, clients may ask for a refresh token at login. |
| `client_api.refresh_token_lifetime` | `WARP_REFRESH_TOKEN_LIFETIME` | `0s` | `0` = never expire. |
| `client_api.rate_limit.enabled` | `WARP_RATE_LIMIT_ENABLED` | `true` | |
| `client_api.rate_limit.default.burst` / `.interval` | — | `30` / `100ms` | Every rate-limited endpoint without its own bucket. |
| `client_api.rate_limit.login.burst` / `.interval` | — | `10` / `1s` | `/login` and `/refresh`. |
| `client_api.rate_limit.register.burst` / `.interval` | — | `5` / `2s` | `/register` and `/register/available`. |
| `federation.enabled` | `WARP_FEDERATION_ENABLED` | `false` | |
| `federation.allowlist` | `WARP_FEDERATION_ALLOWLIST` | `[]` | Non-empty = federate only with these. |
| `federation.denylist` | `WARP_FEDERATION_DENYLIST` | `[]` | Always wins over the allowlist. |
| `federation.allow_runtime_toggle` | `WARP_FEDERATION_ALLOW_RUNTIME_TOGGLE` | `true` | See below. |
| `registration.enabled` | `WARP_REGISTRATION_ENABLED` | `false` | |
| `registration.allow_runtime_toggle` | `WARP_REGISTRATION_ALLOW_RUNTIME_TOGGLE` | `true` | |
| `registration.tokens` | `WARP_REGISTRATION_TOKENS` | `[]` | Non-empty = sign-up also needs one of these, as the `m.login.registration_token` stage. |
| `logging.level` | `WARP_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error`. |
| `logging.format` | `WARP_LOG_FORMAT` | `text` | `text` or `json`. |
| `logging.add_source` | `WARP_LOG_ADD_SOURCE` | `false` | File and line in every record. |
| `metrics.enabled` | `WARP_METRICS_ENABLED` | `true` | |
| `metrics.path` | `WARP_METRICS_PATH` | `/metrics` | Served on the same listener. |
The three rate limit buckets share one Go type, so per-bucket environment
variables would all collide on the same name; they are YAML only.
`WARP_RATE_LIMIT_ENABLED=false` turns the whole limiter off, which is what a
test harness wants.
## Rate limiting
Each bucket allows `burst` requests back to back and returns one slot every
`interval`, keyed per client address. Over the limit is
`429 M_LIMIT_EXCEEDED` with a `retry_after_ms` the client is expected to
honour.
Buckets are per endpoint class, so a client hammering `/login` cannot spend
the budget of `/profile`. The key is the client address and not the user,
because the limiter runs before the access token is resolved — a limiter
that had to query the database to find its own key would be a denial of
service of its own. Behind a reverse proxy, set
`client_api.trust_x_forwarded_for: true`, or every client shares one bucket.
The admin API under `/_warp/admin/v1` is not rate limited: it needs an admin
token, and bulk operations from the panel are exactly the traffic a limiter
would get in the way of.
## Settings that change at runtime
`federation.enabled` and `registration.enabled` are also stored in the
+52 -4
View File
@@ -101,13 +101,61 @@ says why.
## Tests
- Unit tests live next to the code. `internal/setup` holds the HTTP-level
tests; they build a whole server on an in-memory SQLite database.
- Unit tests live next to the code. Each API package holds its own
HTTP-level tests, built on a throwaway SQLite database with the real
components behind them rather than fakes; `internal/setup` holds the
smaller set that goes through the whole wrapped handler, to catch a route
that was written but never registered.
- Storage tests run against every driver that is available:
SQLite always, PostgreSQL when `WARP_TEST_POSTGRES_DSN` is set.
- CI runs `gofmt -l`, `go mod tidy` (as a diff check), `go vet`, the tests
on Linux and Windows, the race detector on Linux, `golangci-lint`, and a
smoke test that boots the real binary and curls it.
Complement, the official integration suite, is wired up in Phase 1 — there
is nothing it can test before registration exists.
## Complement
[Complement](https://github.com/matrix-org/complement) is the official Matrix
integration suite. It runs a container per homeserver per test and drives it
over the real HTTP API, which makes its pass count the honest measure of how
far along the server is — PLAN.md section 9 treats it as the primary progress
metric.
```sh
make complement # the current target set
server/test/complement/run.sh TestLogin # one test
COMPLEMENT_DEBUG=1 make complement # with the container logs
```
It needs Docker. The script builds the image, clones Complement into
`.complement/` (ignored by git) and runs the subset for the phases that are
done — currently `TestRegistration`, `TestLogin` and `TestDeviceManagement`,
with federation off. Extend `COMPLEMENT_TESTS` in
[run.sh](../server/test/complement/run.sh) as each phase lands, rather than
switching to the whole suite: a pass count that drops because a later phase
is missing is noise.
The image is [server/test/complement/Dockerfile](../server/test/complement/Dockerfile)
and the deployment is entirely `WARP_*` environment variables — see
[entrypoint.sh](../server/test/complement/entrypoint.sh). Two things there
are worth knowing: rate limiting is off, because the whole suite comes from
one address and would otherwise spend its time reading `M_LIMIT_EXCEEDED`;
and port 8448 is exposed but nothing listens on it yet. The federation
profile needs a TLS listener signed by the CA that Complement mounts at
`/complement/ca`, which arrives with federation in Phase 5.
## Creating the first account
A new install has registration off, so the first account is made from the
command line against the database directly — no server needs to be running:
```sh
./bin/warp create-account --username you --admin
```
It prompts for the password twice. `--password-file` reads it from a file and
`--password` takes it inline, for scripts that have already thought about the
shell history. From there, the admin API can turn registration on:
```sh
curl -X PUT http://localhost:8008/_warp/admin/v1/registration -H "Authorization: Bearer $TOKEN" -d '{"enabled": true}'
```
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/logging"
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
"github.com/dariuszjaniak/warp/server/internal/setup"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers" // link in the storage drivers
)
// defaultConfigPath is used when neither --config nor WARP_CONFIG is given
// and the file happens to exist next to the binary's working directory.
const defaultConfigPath = "warp.yaml"
// newFlagSet builds a flag set that prints usage to stdout on --help.
func newFlagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(os.Stdout)
return fs
}
// addConfigFlag registers --config and returns the resolver for it.
func addConfigFlag(fs *flag.FlagSet) func() (*config.Config, error) {
path := fs.String("config", "", "path to warp.yaml (default: $WARP_CONFIG, then ./warp.yaml)")
return func() (*config.Config, error) {
resolved := resolveConfigPath(*path)
cfg, err := config.Load(resolved)
if err != nil {
return nil, err
}
return cfg, nil
}
}
// resolveConfigPath implements the --config / $WARP_CONFIG / ./warp.yaml
// precedence. An empty result means "defaults plus environment", which is
// how a container with only WARP_* variables starts.
func resolveConfigPath(flagValue string) string {
if flagValue != "" {
return flagValue
}
if env := os.Getenv("WARP_CONFIG"); env != "" {
return env
}
if _, err := os.Stat(defaultConfigPath); err == nil {
return defaultConfigPath
}
return ""
}
// newLogger builds the logger described by the config.
func newLogger(cfg *config.Config) (*slog.Logger, error) {
return logging.New(os.Stderr, logging.Options{
Level: cfg.Logging.Level,
Format: cfg.Logging.Format,
AddSource: cfg.Logging.AddSource,
})
}
func runServe(ctx context.Context, args []string) error {
fs := newFlagSet("serve")
loadConfig := addConfigFlag(fs)
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := loadConfig()
if err != nil {
return err
}
log, err := newLogger(cfg)
if err != nil {
return err
}
slog.SetDefault(log)
if cfg.Path() == "" {
log.Warn("no configuration file; using defaults and WARP_* environment variables")
} else {
log.Info("loaded configuration", "path", cfg.Path())
}
server, err := setup.New(ctx, cfg, setup.Options{Logger: log})
if err != nil {
return err
}
defer func() {
if err := server.Close(); err != nil {
log.Error("closing storage failed", "error", err)
}
}()
return server.Run(ctx)
}
func runMigrate(ctx context.Context, args []string) error {
fs := newFlagSet("migrate")
loadConfig := addConfigFlag(fs)
statusOnly := fs.Bool("status", false, "list pending migrations without applying them")
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := loadConfig()
if err != nil {
return err
}
log, err := newLogger(cfg)
if err != nil {
return err
}
slog.SetDefault(log)
db, err := openDatabase(ctx, cfg, log)
if err != nil {
return err
}
defer func() { _ = db.Close() }()
pending, err := db.PendingMigrations(ctx)
if err != nil {
return err
}
if len(pending) == 0 {
fmt.Printf("%s: schema is up to date\n", db.Driver())
return nil
}
if *statusOnly {
fmt.Printf("%s: %d pending migration(s):\n", db.Driver(), len(pending))
for _, name := range pending {
fmt.Printf(" %s\n", name)
}
return nil
}
if err := db.Migrate(ctx); err != nil {
return err
}
fmt.Printf("%s: applied %d migration(s)\n", db.Driver(), len(pending))
return nil
}
func runGenerateKeys(_ context.Context, args []string) error {
fs := newFlagSet("generate-keys")
loadConfig := addConfigFlag(fs)
output := fs.String("output", "", "where to write the key (default: global.private_key from the config)")
keyID := fs.String("key-id", "", "key identifier without the \"ed25519:\" prefix (default: random)")
if err := fs.Parse(args); err != nil {
return err
}
path := *output
if path == "" {
cfg, err := loadConfig()
if err != nil {
return fmt.Errorf("%w\n(pass --output to generate a key without a configuration file)", err)
}
path = cfg.Global.PrivateKeyPath
}
key, err := signing.Generate(*keyID)
if err != nil {
return err
}
if err := key.Save(path); err != nil {
return err
}
fmt.Printf("wrote %s\n key ID: %s\n public key: %s\n", path, key.KeyID(), key.PublicBase64())
return nil
}
func runVersion(_ context.Context, args []string) error {
fs := newFlagSet("version")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() > 0 {
return errors.New("version takes no arguments")
}
fmt.Printf("warp %s\n", buildinfo.String())
fmt.Printf("storage drivers: %v\n", storage.Drivers())
return nil
}
+175
View File
@@ -0,0 +1,175 @@
package main
import (
"bufio"
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"golang.org/x/term"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// runCreateAccount implements `warp create-account`.
//
// It talks to the database directly rather than to a running server, so the
// first account on a fresh install can be made before anything is listening
// and without turning registration on. That is the whole point of it: a
// private homeserver with registration off still needs a first admin.
func runCreateAccount(ctx context.Context, args []string) error {
fs := newFlagSet("create-account")
loadConfig := addConfigFlag(fs)
username := fs.String("username", "", "localpart of the new user, e.g. alice")
password := fs.String("password", "", "password (prompted for if omitted)")
passwordFile := fs.String("password-file", "", "read the password from this file instead of prompting")
displayName := fs.String("display-name", "", "profile display name (default: none)")
admin := fs.Bool("admin", false, "give the account the server administrator flag")
if err := fs.Parse(args); err != nil {
return err
}
if *username == "" {
return errors.New("create-account: --username is required")
}
if *password != "" && *passwordFile != "" {
return errors.New("create-account: pass either --password or --password-file, not both")
}
cfg, err := loadConfig()
if err != nil {
return err
}
log, err := newLogger(cfg)
if err != nil {
return err
}
slog.SetDefault(log)
secret, err := resolvePassword(*password, *passwordFile)
if err != nil {
return err
}
db, err := openDatabase(ctx, cfg, log)
if err != nil {
return err
}
defer func() { _ = db.Close() }()
// The schema has to be there before an account can be written. Running
// the migrations here rather than erroring out means the documented
// first-run sequence works whatever order the operator does it in.
if err := db.Migrate(ctx); err != nil {
return fmt.Errorf("migrate database: %w", err)
}
users, err := userapi.New(userapi.Options{
ServerName: cfg.Global.ServerName,
Store: db.Users(),
Logger: log,
})
if err != nil {
return err
}
account, err := users.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: *username,
Password: secret,
DisplayName: *displayName,
Admin: *admin,
})
switch {
case errors.Is(err, userapi.ErrUserExists):
return fmt.Errorf("%s already exists", users.UserID(userapi.NormaliseLocalpart(*username)))
case err != nil:
return err
}
fmt.Printf("created %s\n", users.UserID(account.Localpart))
if account.Admin {
fmt.Println(" server administrator: yes")
}
if secret == "" {
fmt.Println(" no password set: this account cannot log in until one is")
}
return nil
}
// openDatabase opens the storage driver named in the config.
func openDatabase(ctx context.Context, cfg *config.Config, log *slog.Logger) (storage.Database, error) {
return storage.Open(ctx, storage.Config{
Driver: cfg.Database.Driver,
ConnectionString: cfg.Database.ConnectionString,
MaxOpenConns: cfg.Database.MaxOpenConns,
MaxIdleConns: cfg.Database.MaxIdleConns,
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
}, log)
}
// resolvePassword settles where the password comes from.
//
// An interactive prompt is the default because a password on the command
// line is a password in the shell history and in the process list of every
// other user on the machine. --password is kept for scripts that have
// already dealt with that, and --password-file for the ones that have not.
func resolvePassword(flagValue, file string) (string, error) {
if flagValue != "" {
return flagValue, nil
}
if file != "" {
data, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("read password file: %w", err)
}
secret := strings.TrimRight(string(data), "\r\n")
if secret == "" {
return "", fmt.Errorf("password file %s is empty", file)
}
return secret, nil
}
return promptForPassword()
}
// promptForPassword reads a password twice without echoing it.
//
// When stdin is not a terminal — a pipe in a container entrypoint, say —
// one line is read instead, with no confirmation, because there is nobody
// there to mistype it.
func promptForPassword() (string, error) {
if !term.IsTerminal(int(os.Stdin.Fd())) {
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return "", fmt.Errorf("read password from standard input: %w", err)
}
secret := strings.TrimRight(line, "\r\n")
if secret == "" {
return "", errors.New("no password given on standard input")
}
return secret, nil
}
fmt.Print("Password: ")
first, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("read password: %w", err)
}
fmt.Print("Confirm: ")
second, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("read password: %w", err)
}
if string(first) != string(second) {
return "", errors.New("the passwords do not match")
}
if len(first) == 0 {
return "", errors.New("the password must not be empty")
}
return string(first), nil
}
+78
View File
@@ -0,0 +1,78 @@
// Command warp is the Matrix homeserver: one binary, one process.
//
// Usage:
//
// warp serve [--config warp.yaml]
// warp migrate [--config warp.yaml] [--status]
// warp generate-keys [--config warp.yaml] [--output path] [--key-id id]
// warp create-account [--config warp.yaml] --username alice [--admin]
// warp version
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
)
// command is one subcommand.
type command struct {
name string
summary string
run func(ctx context.Context, args []string) error
}
func commands() []command {
return []command{
{"serve", "run the homeserver", runServe},
{"migrate", "apply pending database migrations", runMigrate},
{"generate-keys", "create the ed25519 signing key", runGenerateKeys},
{"create-account", "create a local user account", runCreateAccount},
{"version", "print the version and exit", runVersion},
}
}
func main() {
// Ctrl-C and SIGTERM cancel the context; the serve command turns that
// into a graceful shutdown.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, os.Args[1:]); err != nil {
if errors.Is(err, context.Canceled) {
return
}
fmt.Fprintf(os.Stderr, "warp: %v\n", err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string) error {
if len(args) == 0 {
usage(os.Stderr)
return errors.New("no command given")
}
switch args[0] {
case "-h", "--help", "help":
usage(os.Stdout)
return nil
}
for _, c := range commands() {
if c.name == args[0] {
return c.run(ctx, args[1:])
}
}
usage(os.Stderr)
return fmt.Errorf("unknown command %q", args[0])
}
func usage(w *os.File) {
fmt.Fprint(w, "warp — a Matrix homeserver\n\nUsage:\n warp <command> [flags]\n\nCommands:\n")
for _, c := range commands() {
fmt.Fprintf(w, " %-14s %s\n", c.name, c.summary)
}
fmt.Fprint(w, "\nRun `warp <command> --help` for the flags of a command.\n")
}
+5 -3
View File
@@ -5,6 +5,8 @@ go 1.26.4
require (
github.com/jackc/pgx/v5 v5.11.0
github.com/prometheus/client_golang v1.25.0
golang.org/x/crypto v0.58.0
golang.org/x/term v0.47.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.60.1
)
@@ -26,9 +28,9 @@ require (
github.com/prometheus/procfs v0.22.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
+12 -8
View File
@@ -60,16 +60,20 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+316
View File
@@ -0,0 +1,316 @@
// Package adminapi serves /_warp/admin/v1: the API behind the admin panel.
//
// It is deliberately not part of the Matrix spec. Everything a client needs
// goes through the Client-Server API; this is the operator's surface, and
// the only thing in Warp that is ours rather than Matrix's.
//
// Authentication is a normal Matrix access token belonging to an account
// with the admin flag, so the panel logs in the way any client does and
// there is no second credential to manage.
//
// Phase 1 is the skeleton PLAN.md section 7 asks for: the two runtime
// switches, user listing and creation, and the dashboard counters. Full user
// management, registration tokens, the federation allow/deny list editor and
// the audit log arrive in Phase 9.
package adminapi
import (
"errors"
"log/slog"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// Prefix is the path every admin endpoint lives under.
const Prefix = "/_warp/admin/v1"
// AdminAPI serves the admin endpoints.
type AdminAPI struct {
serverName string
users *userapi.API
settings *settings.Service
federation *federation.Gate
database storage.Database
log *slog.Logger
}
// Options configure the component.
type Options struct {
ServerName string
Users *userapi.API
Settings *settings.Service
Federation *federation.Gate
// Database is used for the driver name the dashboard shows and for the
// migration state; the admin API does not write to it directly.
Database storage.Database
Logger *slog.Logger
}
// New builds the component.
func New(opts Options) (*AdminAPI, error) {
switch {
case opts.ServerName == "":
return nil, errors.New("adminapi: ServerName must not be empty")
case opts.Users == nil:
return nil, errors.New("adminapi: Users must not be nil")
case opts.Settings == nil:
return nil, errors.New("adminapi: Settings must not be nil")
}
if opts.Federation == nil {
opts.Federation = federation.NewGate(nil, nil)
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
return &AdminAPI{
serverName: opts.ServerName,
users: opts.Users,
settings: opts.Settings,
federation: opts.Federation,
database: opts.Database,
log: opts.Logger,
}, nil
}
// RegisterRoutes adds the admin endpoints to the router.
func (a *AdminAPI) RegisterRoutes(r *httputil.Router) {
a.handle(r, "GET "+Prefix+"/federation", a.handleGetFederation)
a.handle(r, "PUT "+Prefix+"/federation", a.handlePutFederation)
a.handle(r, "GET "+Prefix+"/registration", a.handleGetRegistration)
a.handle(r, "PUT "+Prefix+"/registration", a.handlePutRegistration)
a.handle(r, "GET "+Prefix+"/users", a.handleGetUsers)
a.handle(r, "POST "+Prefix+"/users", a.handlePostUsers)
a.handle(r, "GET "+Prefix+"/stats", a.handleGetStats)
}
// adminHandler is a handler that has already been shown to be an admin.
type adminHandler func(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error
// handle registers an admin-only endpoint.
//
// Admin endpoints are not rate limited. They are reachable only with an
// admin token, and an operator bulk-loading users through the panel is
// exactly the traffic a limiter would get in the way of.
func (a *AdminAPI) handle(r *httputil.Router, pattern string, h adminHandler) {
r.HandleFunc(pattern, func(w http.ResponseWriter, req *http.Request) error {
auth, err := a.requireAdmin(req)
if err != nil {
return err
}
return h(w, req, auth)
})
}
// requireAdmin resolves the access token and insists on the admin flag.
//
// A non-admin token gets M_FORBIDDEN rather than a 404: the caller is a
// legitimate user of this server who is not allowed here, and pretending
// the endpoint does not exist would only make the panel harder to debug.
func (a *AdminAPI) requireAdmin(r *http.Request) (userapi.Authenticated, error) {
token := accessTokenFrom(r)
if token == "" {
return userapi.Authenticated{}, httputil.MissingToken()
}
auth, err := a.users.Authenticate(r.Context(), token)
switch {
case err == nil:
case errors.Is(err, userapi.ErrNoSuchToken):
return userapi.Authenticated{}, httputil.UnknownToken(false)
case errors.Is(err, userapi.ErrTokenExpired):
return userapi.Authenticated{}, httputil.UnknownToken(true)
case errors.Is(err, userapi.ErrAccountDeactivated):
return userapi.Authenticated{}, httputil.UserDeactivated()
default:
return userapi.Authenticated{}, err
}
if !auth.Admin {
return userapi.Authenticated{}, httputil.Forbidden("This endpoint requires a server administrator")
}
return auth, nil
}
// accessTokenFrom reads the bearer token. The admin panel is a normal
// Matrix client, so it sends the header form; the query parameter is not
// accepted here, because an admin token in a URL ends up in access logs.
func accessTokenFrom(r *http.Request) string {
header := r.Header.Get("Authorization")
const prefix = "Bearer "
if len(header) <= len(prefix) || !equalFoldASCII(header[:len(prefix)], prefix) {
return ""
}
return header[len(prefix):]
}
// equalFoldASCII compares two ASCII strings case-insensitively. The scheme
// of an Authorization header is case-insensitive per RFC 9110.
func equalFoldASCII(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := range len(a) {
x, y := a[i], b[i]
if 'A' <= x && x <= 'Z' {
x += 'a' - 'A'
}
if 'A' <= y && y <= 'Z' {
y += 'a' - 'A'
}
if x != y {
return false
}
}
return true
}
// switchResponse is the shape both runtime switches report.
type switchResponse struct {
Enabled bool `json:"enabled"`
// Toggleable is false when the config file pinned the setting, in
// which case the panel shows the switch greyed out rather than letting
// an operator flip something that will bounce.
Toggleable bool `json:"toggleable"`
}
// switchRequest is the body of either PUT.
type switchRequest struct {
Enabled *bool `json:"enabled"`
}
// federationResponse adds the allow and deny lists to the switch.
type federationResponse struct {
switchResponse
Allowlist []string `json:"allowlist"`
Denylist []string `json:"denylist"`
}
// handleGetFederation implements GET /_warp/admin/v1/federation.
func (a *AdminAPI) handleGetFederation(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
allowlist, denylist := a.federation.Policy().Lists()
httputil.WriteJSON(w, http.StatusOK, federationResponse{
switchResponse: switchResponse{
Enabled: a.settings.FederationEnabled(),
Toggleable: a.settings.FederationToggleable(),
},
// Editing these is Phase 9; they are reported now because the
// dashboard has to show which servers a running Warp will talk to.
Allowlist: allowlist,
Denylist: denylist,
})
return nil
}
// handlePutFederation implements PUT /_warp/admin/v1/federation, the switch
// PLAN.md section 3 describes: no restart, the gate picks it up on the next
// request.
func (a *AdminAPI) handlePutFederation(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
enabled, err := decodeSwitch(w, r)
if err != nil {
return err
}
if err := a.settings.SetFederationEnabled(r.Context(), enabled); err != nil {
return toggleError(err, "federation")
}
a.log.Info("federation switch changed through the admin API",
"enabled", enabled, "by", auth.UserID)
return a.handleGetFederation(w, r, auth)
}
// handleGetRegistration implements GET /_warp/admin/v1/registration.
func (a *AdminAPI) handleGetRegistration(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
httputil.WriteJSON(w, http.StatusOK, switchResponse{
Enabled: a.settings.RegistrationEnabled(),
Toggleable: a.settings.RegistrationToggleable(),
})
return nil
}
// handlePutRegistration implements PUT /_warp/admin/v1/registration.
func (a *AdminAPI) handlePutRegistration(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
enabled, err := decodeSwitch(w, r)
if err != nil {
return err
}
if err := a.settings.SetRegistrationEnabled(r.Context(), enabled); err != nil {
return toggleError(err, "registration")
}
a.log.Info("registration switch changed through the admin API",
"enabled", enabled, "by", auth.UserID)
return a.handleGetRegistration(w, r, auth)
}
// decodeSwitch reads {"enabled": bool}. The field is a pointer so that a
// body without it is a missing parameter rather than a silent "false".
func decodeSwitch(w http.ResponseWriter, r *http.Request) (bool, error) {
var req switchRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return false, err
}
if req.Enabled == nil {
return false, httputil.MissingParam("enabled is required")
}
return *req.Enabled, nil
}
// toggleError turns a refused toggle into the right answer: a setting the
// config file pinned is a 409, because the request was well formed and the
// server's configuration is what stands in the way.
func toggleError(err error, name string) error {
if errors.Is(err, settings.ErrNotToggleable) {
return httputil.NewError(http.StatusConflict, httputil.ErrCodeForbidden,
"The %s switch is pinned by the configuration file (%s.allow_runtime_toggle is false)",
name, name)
}
return err
}
// statsResponse is the dashboard payload: PLAN.md section 8.1 lists users,
// rooms, events, federation status, storage driver and version as the first
// thing the panel shows. The room and event counters join it in Phase 3,
// when there is something to count.
type statsResponse struct {
ServerName string `json:"server_name"`
Version string `json:"version"`
Database string `json:"database"`
Users int `json:"users"`
Admins int `json:"admins"`
Deactivated int `json:"deactivated"`
Devices int `json:"devices"`
FederationEnabled bool `json:"federation_enabled"`
RegistrationEnabled bool `json:"registration_enabled"`
}
// handleGetStats implements GET /_warp/admin/v1/stats.
func (a *AdminAPI) handleGetStats(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
stats, err := a.users.Stats(r.Context())
if err != nil {
return err
}
resp := statsResponse{
ServerName: a.serverName,
Version: buildinfo.Version,
Users: stats.Users,
Admins: stats.Admins,
Deactivated: stats.Deactivated,
Devices: stats.Devices,
FederationEnabled: a.settings.FederationEnabled(),
RegistrationEnabled: a.settings.RegistrationEnabled(),
}
if a.database != nil {
resp.Database = a.database.Driver()
}
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}
+412
View File
@@ -0,0 +1,412 @@
package adminapi_test
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/dariuszjaniak/warp/server/internal/adminapi"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
const testServerName = "warp.example"
// testServer is an admin API on a throwaway SQLite database.
type testServer struct {
t *testing.T
handler http.Handler
users *userapi.API
settings *settings.Service
}
// newTestServer builds the component. defaults are the config-file values
// the settings service starts from, which is how a test pins a switch.
func newTestServer(t *testing.T, defaults settings.Defaults) *testServer {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db"))
db, err := storage.Open(t.Context(), storage.Config{
Driver: storage.DriverSQLite,
ConnectionString: "file:" + path,
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("open storage: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("migrate: %v", err)
}
users, err := userapi.New(userapi.Options{
ServerName: testServerName,
Store: db.Users(),
Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatalf("userapi.New: %v", err)
}
settingsService, err := settings.New(t.Context(), db.Settings(), defaults, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("settings.New: %v", err)
}
api, err := adminapi.New(adminapi.Options{
ServerName: testServerName,
Users: users,
Settings: settingsService,
Federation: federation.NewGate(settingsService.FederationEnabled,
federation.NewPolicy([]string{"friend.example"}, []string{"spam.example"})),
Database: db,
Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatalf("adminapi.New: %v", err)
}
router := httputil.NewRouter(nil)
api.RegisterRoutes(router)
return &testServer{t: t, handler: router, users: users, settings: settingsService}
}
func (ts *testServer) do(method, path, token string, body any) *httptest.ResponseRecorder {
ts.t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
ts.t.Fatal(err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, reader)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
ts.handler.ServeHTTP(rec, req)
return rec
}
// admin creates an administrator and returns its access token.
func (ts *testServer) admin(localpart string) string {
ts.t.Helper()
return ts.account(localpart, true)
}
// user creates an ordinary account and returns its access token.
func (ts *testServer) user(localpart string) string {
ts.t.Helper()
return ts.account(localpart, false)
}
func (ts *testServer) account(localpart string, admin bool) string {
ts.t.Helper()
if _, err := ts.users.CreateAccount(ts.t.Context(), userapi.NewAccountRequest{
Localpart: localpart,
Password: "sUp3rs3kr1t",
Admin: admin,
}); err != nil {
ts.t.Fatalf("CreateAccount: %v", err)
}
session, err := ts.users.CreateSession(ts.t.Context(), userapi.SessionRequest{Localpart: localpart})
if err != nil {
ts.t.Fatalf("CreateSession: %v", err)
}
return session.AccessToken
}
func jsonBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("response is not JSON (%s): %v", rec.Body.String(), err)
}
return out
}
func expectStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) {
t.Helper()
if rec.Code != want {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, want, rec.Body.String())
}
}
func expectError(t *testing.T, rec *httptest.ResponseRecorder, status int, errcode string) {
t.Helper()
expectStatus(t, rec, status)
if got := jsonBody(t, rec)["errcode"]; got != errcode {
t.Errorf("errcode = %v, want %s (body: %s)", got, errcode, rec.Body.String())
}
}
const (
federationPath = adminapi.Prefix + "/federation"
registrationPath = adminapi.Prefix + "/registration"
usersPath = adminapi.Prefix + "/users"
statsPath = adminapi.Prefix + "/stats"
)
var toggleable = settings.Defaults{
FederationAllowRuntimeToggle: true,
RegistrationAllowRuntimeToggle: true,
}
// TestAdminAuth is the gate everything else here depends on.
func TestAdminAuth(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
ordinary := ts.user("alice")
for name, tc := range map[string]struct {
token string
status int
code string
}{
"no token": {"", http.StatusUnauthorized, "M_MISSING_TOKEN"},
"unknown token": {"not-a-token", http.StatusUnauthorized, "M_UNKNOWN_TOKEN"},
"ordinary user": {ordinary, http.StatusForbidden, "M_FORBIDDEN"},
} {
t.Run(name, func(t *testing.T) {
expectError(t, ts.do(http.MethodGet, statsPath, tc.token, nil), tc.status, tc.code)
})
}
expectStatus(t, ts.do(http.MethodGet, statsPath, admin, nil), http.StatusOK)
}
// TestAdminTokenNotAcceptedInQueryString: an admin token in a URL ends up in
// every access log between here and the browser.
func TestAdminTokenNotAcceptedInQueryString(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
expectError(t, ts.do(http.MethodGet, statsPath+"?access_token="+admin, "", nil),
http.StatusUnauthorized, "M_MISSING_TOKEN")
}
// TestFederationSwitch is the admin half of PLAN.md section 3: the switch
// flips at runtime and the gate follows.
func TestFederationSwitch(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
rec := ts.do(http.MethodGet, federationPath, admin, nil)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if body["enabled"] != false {
t.Errorf("federation should start disabled, got %v", body["enabled"])
}
if body["toggleable"] != true {
t.Errorf("toggleable = %v, want true", body["toggleable"])
}
// The dashboard needs to show which servers a running Warp will talk
// to, even though editing the lists is Phase 9.
if allowlist, _ := body["allowlist"].([]any); len(allowlist) != 1 || allowlist[0] != "friend.example" {
t.Errorf("allowlist = %v", body["allowlist"])
}
if denylist, _ := body["denylist"].([]any); len(denylist) != 1 || denylist[0] != "spam.example" {
t.Errorf("denylist = %v", body["denylist"])
}
rec = ts.do(http.MethodPut, federationPath, admin, map[string]any{"enabled": true})
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["enabled"] != true {
t.Errorf("the PUT should report the new state, got %s", rec.Body.String())
}
if !ts.settings.FederationEnabled() {
t.Error("the settings service did not pick the change up")
}
rec = ts.do(http.MethodPut, federationPath, admin, map[string]any{"enabled": false})
expectStatus(t, rec, http.StatusOK)
if ts.settings.FederationEnabled() {
t.Error("switching back off did not take effect")
}
}
func TestRegistrationSwitch(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
rec := ts.do(http.MethodPut, registrationPath, admin, map[string]any{"enabled": true})
expectStatus(t, rec, http.StatusOK)
if !ts.settings.RegistrationEnabled() {
t.Error("registration was not enabled")
}
rec = ts.do(http.MethodGet, registrationPath, admin, nil)
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["enabled"] != true {
t.Errorf("GET = %s", rec.Body.String())
}
}
// TestSwitchPinnedByConfig covers settings.ErrNotToggleable: the request was
// fine, the server's own configuration is what refuses it, so it is a
// conflict rather than a bad request.
func TestSwitchPinnedByConfig(t *testing.T) {
ts := newTestServer(t, settings.Defaults{})
admin := ts.admin("root")
for _, path := range []string{federationPath, registrationPath} {
rec := ts.do(http.MethodPut, path, admin, map[string]any{"enabled": true})
expectError(t, rec, http.StatusConflict, "M_FORBIDDEN")
if msg, _ := jsonBody(t, rec)["error"].(string); msg == "" {
t.Error("the refusal must say which setting is pinned")
}
// And the GET says so in advance, so the panel can grey the
// switch out instead of offering something that will bounce.
rec = ts.do(http.MethodGet, path, admin, nil)
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["toggleable"] != false {
t.Errorf("GET %s reports toggleable, but it is pinned", path)
}
}
}
func TestSwitchRequiresTheEnabledField(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
expectError(t, ts.do(http.MethodPut, federationPath, admin, map[string]any{}),
http.StatusBadRequest, "M_MISSING_PARAM")
}
func TestListUsers(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
ts.user("alice")
ts.user("bob")
rec := ts.do(http.MethodGet, usersPath, admin, nil)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if total, _ := body["total"].(float64); total != 3 {
t.Errorf("total = %v, want 3", body["total"])
}
users, _ := body["users"].([]any)
if len(users) != 3 {
t.Fatalf("got %d users, want 3", len(users))
}
first, _ := users[0].(map[string]any)
if first["user_id"] != "@alice:warp.example" {
t.Errorf("users are not ordered by localpart: %s", rec.Body.String())
}
if first["admin"] != false {
t.Errorf("alice should not be an admin: %v", first)
}
// Paging reports the full total so the panel can show how many pages
// there are.
rec = ts.do(http.MethodGet, usersPath+"?limit=1&offset=1", admin, nil)
expectStatus(t, rec, http.StatusOK)
body = jsonBody(t, rec)
users, _ = body["users"].([]any)
if len(users) != 1 || users[0].(map[string]any)["user_id"] != "@bob:warp.example" {
t.Errorf("paged listing = %s", rec.Body.String())
}
if total, _ := body["total"].(float64); total != 3 {
t.Errorf("paged total = %v, want 3", body["total"])
}
rec = ts.do(http.MethodGet, usersPath+"?search=ali", admin, nil)
expectStatus(t, rec, http.StatusOK)
if total, _ := jsonBody(t, rec)["total"].(float64); total != 1 {
t.Errorf("search found %v users, want 1", jsonBody(t, rec)["total"])
}
expectError(t, ts.do(http.MethodGet, usersPath+"?limit=lots", admin, nil),
http.StatusBadRequest, "M_INVALID_PARAM")
expectError(t, ts.do(http.MethodGet, usersPath+"?offset=-1", admin, nil),
http.StatusBadRequest, "M_INVALID_PARAM")
}
// TestCreateUser covers the way an operator adds an account to a server with
// registration switched off — which is the normal state of a private Warp.
func TestCreateUser(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
if ts.settings.RegistrationEnabled() {
t.Fatal("this test needs registration to be off")
}
rec := ts.do(http.MethodPost, usersPath, admin, map[string]any{
"username": "Alice",
"password": "sUp3rs3kr1t",
"display_name": "Alice",
"admin": true,
})
expectStatus(t, rec, http.StatusCreated)
body := jsonBody(t, rec)
if body["user_id"] != "@alice:warp.example" {
t.Errorf("user_id = %v; the localpart should be normalised", body["user_id"])
}
if body["admin"] != true {
t.Errorf("admin = %v, want true", body["admin"])
}
// The new account can really log in, which is the only thing that
// makes creating it useful.
if err := ts.users.CheckPassword(t.Context(), "alice", "sUp3rs3kr1t"); err != nil {
t.Errorf("the created account cannot log in: %v", err)
}
expectError(t, ts.do(http.MethodPost, usersPath, admin, map[string]any{
"username": "alice", "password": "sUp3rs3kr1t",
}), http.StatusBadRequest, "M_USER_IN_USE")
expectError(t, ts.do(http.MethodPost, usersPath, admin, map[string]any{
"username": "not valid", "password": "sUp3rs3kr1t",
}), http.StatusBadRequest, "M_INVALID_USERNAME")
expectError(t, ts.do(http.MethodPost, usersPath, admin, map[string]any{"username": "bob"}),
http.StatusBadRequest, "M_MISSING_PARAM")
expectError(t, ts.do(http.MethodPost, usersPath, admin, map[string]any{"password": "x"}),
http.StatusBadRequest, "M_MISSING_PARAM")
}
func TestStats(t *testing.T) {
ts := newTestServer(t, toggleable)
admin := ts.admin("root")
ts.user("alice")
rec := ts.do(http.MethodGet, statsPath, admin, nil)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if body["server_name"] != testServerName {
t.Errorf("server_name = %v", body["server_name"])
}
if body["database"] != storage.DriverSQLite {
t.Errorf("database = %v, want sqlite", body["database"])
}
if users, _ := body["users"].(float64); users != 2 {
t.Errorf("users = %v, want 2", body["users"])
}
if admins, _ := body["admins"].(float64); admins != 1 {
t.Errorf("admins = %v, want 1", body["admins"])
}
// Each account above got one session, so the device count follows.
if devices, _ := body["devices"].(float64); devices != 2 {
t.Errorf("devices = %v, want 2", body["devices"])
}
if body["federation_enabled"] != false || body["registration_enabled"] != false {
t.Errorf("switch state = %s", rec.Body.String())
}
}
+149
View File
@@ -0,0 +1,149 @@
package adminapi
import (
"errors"
"net/http"
"strconv"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// adminUser is one row of the user list. It is a flatter shape than the
// Client-Server API's profile object because the panel shows it as a table.
type adminUser struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
Admin bool `json:"admin"`
Deactivated bool `json:"deactivated"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type usersResponse struct {
Users []adminUser `json:"users"`
// Total is the number of users matching the query, not the number
// returned, so the panel can page.
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// defaultUserPageLimit is what a request without ?limit= gets.
const defaultUserPageLimit = 50
// handleGetUsers implements GET /_warp/admin/v1/users.
func (a *AdminAPI) handleGetUsers(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
query := r.URL.Query()
limit, err := intParam(query.Get("limit"), defaultUserPageLimit)
if err != nil {
return httputil.InvalidParam("limit must be a number")
}
offset, err := intParam(query.Get("offset"), 0)
if err != nil {
return httputil.InvalidParam("offset must be a number")
}
if limit < 0 || offset < 0 {
return httputil.InvalidParam("limit and offset must not be negative")
}
accounts, total, err := a.users.ListAccounts(r.Context(), storage.AccountFilter{
Search: query.Get("search"),
Limit: limit,
Offset: offset,
})
if err != nil {
return err
}
users := make([]adminUser, 0, len(accounts))
for _, acc := range accounts {
users = append(users, adminUser{
UserID: a.users.UserID(acc.Localpart),
DisplayName: acc.DisplayName,
AvatarURL: acc.AvatarURL,
Admin: acc.Admin,
Deactivated: acc.Deactivated,
CreatedAtMS: acc.CreatedAtMS,
})
}
httputil.WriteJSON(w, http.StatusOK, usersResponse{
Users: users,
Total: total,
Limit: limit,
Offset: offset,
})
return nil
}
// createUserRequest is the body of POST /_warp/admin/v1/users.
type createUserRequest struct {
// Username is the localpart; it is normalised like any other.
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Admin bool `json:"admin"`
}
// handlePostUsers implements POST /_warp/admin/v1/users.
//
// This is how an operator adds an account on a server with registration
// turned off, so it deliberately ignores the registration switch: it is the
// admin's own action, not a public sign-up. It does require a password —
// unlike `warp create-account`, which may leave one unset — because an
// account created from the panel is meant to be handed to somebody.
func (a *AdminAPI) handlePostUsers(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
var req createUserRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if req.Username == "" {
return httputil.MissingParam("username is required")
}
if req.Password == "" {
return httputil.MissingParam("password is required")
}
if len(req.Password) > userapi.MaxPasswordLength {
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeWeakPassword,
"Password must be at most %d bytes", userapi.MaxPasswordLength)
}
account, err := a.users.CreateAccount(r.Context(), userapi.NewAccountRequest{
Localpart: req.Username,
Password: req.Password,
DisplayName: req.DisplayName,
Admin: req.Admin,
})
switch {
case errors.Is(err, userapi.ErrUserExists):
return httputil.UserInUse()
case errors.Is(err, userapi.ErrInvalidLocalpart):
return httputil.InvalidUsername("%s", err.Error())
case err != nil:
return err
}
a.log.Info("account created through the admin API",
"user_id", a.users.UserID(account.Localpart), "admin", account.Admin, "by", auth.UserID)
httputil.WriteJSON(w, http.StatusCreated, adminUser{
UserID: a.users.UserID(account.Localpart),
DisplayName: account.DisplayName,
Admin: account.Admin,
CreatedAtMS: account.CreatedAtMS,
})
return nil
}
// intParam parses an optional integer query parameter.
func intParam(raw string, fallback int) (int, error) {
if raw == "" {
return fallback, nil
}
return strconv.Atoi(raw)
}
+154
View File
@@ -0,0 +1,154 @@
package clientapi
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// MaxAccountDataSize caps one account data blob. The spec sets no limit, but
// account data is sent down /sync in full on every initial sync, so an
// unbounded blob is an unbounded sync.
const MaxAccountDataSize = 64 * 1024
// typeFullyRead is the account data type the spec reserves: it is set
// through POST /rooms/{roomId}/read_markers, which keeps the read marker and
// the receipt in step, so writing it directly is refused.
const typeFullyRead = "m.fully_read"
// handleGetAccountData implements
// GET /_matrix/client/v3/user/{userId}/account_data/{type}.
func (c *ClientAPI) handleGetAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
return c.getAccountData(w, r, auth, "")
}
// handleGetRoomAccountData implements the per-room variant.
func (c *ClientAPI) handleGetRoomAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID := r.PathValue("roomID")
if err := validateRoomID(roomID); err != nil {
return err
}
return c.getAccountData(w, r, auth, roomID)
}
func (c *ClientAPI) getAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated, roomID string) error {
// Account data is private, so even a read is only ever of one's own —
// an admin reading it would be reading a user's client settings, which
// the admin API has no business doing either.
localpart, err := c.requireOwnAccountData(auth, r.PathValue("userID"))
if err != nil {
return err
}
dataType := r.PathValue("dataType")
if dataType == "" {
return httputil.MissingParam("An account data type is required")
}
content, err := c.users.AccountData(r.Context(), localpart, roomID, dataType)
switch {
case errors.Is(err, userapi.ErrNoSuchAccountData):
return httputil.NotFound("Account data not found")
case err != nil:
return err
}
// The stored bytes are the client's own JSON and go back out verbatim,
// so they are written directly rather than round-tripped through a map.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(content); err != nil {
httputil.LoggerFrom(r.Context()).Debug("writing account data failed", "error", err)
}
return nil
}
// handlePutAccountData implements
// PUT /_matrix/client/v3/user/{userId}/account_data/{type}.
func (c *ClientAPI) handlePutAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
return c.putAccountData(w, r, auth, "")
}
// handlePutRoomAccountData implements the per-room variant.
func (c *ClientAPI) handlePutRoomAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID := r.PathValue("roomID")
if err := validateRoomID(roomID); err != nil {
return err
}
return c.putAccountData(w, r, auth, roomID)
}
func (c *ClientAPI) putAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated, roomID string) error {
localpart, err := c.requireOwnAccountData(auth, r.PathValue("userID"))
if err != nil {
return err
}
dataType := r.PathValue("dataType")
if dataType == "" {
return httputil.MissingParam("An account data type is required")
}
if dataType == typeFullyRead {
return httputil.BadJSON("%s may only be set through /rooms/{roomId}/read_markers", typeFullyRead)
}
// The content is stored as the client sent it, so it is read as raw
// JSON and only checked for being a JSON object at all.
var content json.RawMessage
if err := httputil.DecodeJSON(w, r, &content); err != nil {
return err
}
if len(content) > MaxAccountDataSize {
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeTooLarge,
"Account data must be at most %d bytes", MaxAccountDataSize)
}
if !json.Valid(content) {
return httputil.NotJSON()
}
if trimmed := strings.TrimSpace(string(content)); !strings.HasPrefix(trimmed, "{") {
return httputil.BadJSON("Account data content must be a JSON object")
}
if err := c.users.SetAccountData(r.Context(), localpart, roomID, dataType, content); err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// requireOwnAccountData resolves the path's user ID and insists it is the
// caller's own. Unlike a profile, account data is not something an admin may
// reach into: it is the user's client configuration.
func (c *ClientAPI) requireOwnAccountData(auth userapi.Authenticated, pathUserID string) (string, error) {
localpart, err := c.users.ParseLocalUserID(pathUserID)
switch {
case errors.Is(err, userapi.ErrForeignServer):
return "", httputil.Forbidden("Cannot access the account data of another server")
case err != nil:
return "", httputil.InvalidParam("%s is not a valid user ID", pathUserID)
}
if localpart != auth.Localpart {
return "", httputil.Forbidden("Cannot access the account data of another user")
}
return localpart, nil
}
// validateRoomID checks the shape of a room ID in a path.
//
// Phase 1 has no rooms, so there is nothing to check it against: room
// account data is simply a second key space, and a client may set it for a
// room it has not joined. The full check — that the room exists and the user
// is in it — belongs to the roomserver in Phase 3.
func validateRoomID(roomID string) error {
switch {
case roomID == "":
return httputil.MissingParam("A room ID is required")
case !strings.HasPrefix(roomID, "!"):
return httputil.InvalidParam("%s is not a valid room ID", roomID)
case len(roomID) > 255:
return httputil.InvalidParam("Room IDs must be at most 255 bytes")
}
return nil
}
@@ -0,0 +1,167 @@
package clientapi_test
import (
"net/http"
"strings"
"testing"
)
// accountDataPath builds the global account data path for a user.
func accountDataPath(userID, dataType string) string {
return "/_matrix/client/v3/user/" + userID + "/account_data/" + dataType
}
// roomAccountDataPath builds the per-room variant.
func roomAccountDataPath(userID, roomID, dataType string) string {
return "/_matrix/client/v3/user/" + userID + "/rooms/" + roomID + "/account_data/" + dataType
}
func TestAccountDataRoundTrip(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
path := accountDataPath(acc.UserID, "test.key")
expectError(t, ts.get(path, acc.AccessToken), http.StatusNotFound, "M_NOT_FOUND")
rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken,
body: map[string]any{"value": "first"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(path, acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got := jsonBody(t, rec)["value"]; got != "first" {
t.Errorf("value = %v, want first", got)
}
rec = ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken,
body: map[string]any{"value": "second"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(path, acc.AccessToken)
if got := jsonBody(t, rec)["value"]; got != "second" {
t.Errorf("value after overwriting = %v, want second", got)
}
}
func TestRoomAccountDataRoundTrip(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
const roomID = "!room:warp.example"
path := roomAccountDataPath(acc.UserID, roomID, "test.key")
rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken,
body: map[string]any{"value": "room first"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(path, acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got := jsonBody(t, rec)["value"]; got != "room first" {
t.Errorf("value = %v, want %q", got, "room first")
}
// Room account data is its own key space: the global entry of the same
// type is untouched.
expectError(t, ts.get(accountDataPath(acc.UserID, "test.key"), acc.AccessToken),
http.StatusNotFound, "M_NOT_FOUND")
}
func TestAccountDataIsPrivate(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "superuser")
bob := ts.register("bob", "bobspassword")
rec := ts.do(request{method: http.MethodPut, path: accountDataPath(alice.UserID, "test.key"),
token: alice.AccessToken, body: map[string]any{"value": "secret"}})
expectStatus(t, rec, http.StatusOK)
// Not even an admin: this is the user's own client configuration, not
// something the server operator has business reading.
ts.makeAdmin(bob)
for _, method := range []string{http.MethodGet, http.MethodPut} {
rec := ts.do(request{method: method, path: accountDataPath(alice.UserID, "test.key"),
token: bob.AccessToken, body: map[string]any{"value": "nosy"}})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
}
}
func TestAccountDataRequiresAToken(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
expectError(t, ts.get(accountDataPath(acc.UserID, "test.key"), ""),
http.StatusUnauthorized, "M_MISSING_TOKEN")
}
// TestAccountDataRejectsFullyRead covers the one type the spec reserves: it
// has to travel with a read receipt, so writing it directly is refused.
func TestAccountDataRejectsFullyRead(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut,
path: roomAccountDataPath(acc.UserID, "!room:warp.example", "m.fully_read"),
token: acc.AccessToken, body: map[string]any{"event_id": "$x"}})
expectError(t, rec, http.StatusBadRequest, "M_BAD_JSON")
}
func TestAccountDataRejectsNonObjects(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
path := accountDataPath(acc.UserID, "test.key")
for name, body := range map[string]any{
"an array": []any{1, 2, 3},
"a string": "just a string",
"a number": 42,
"a boolean": true,
} {
t.Run(name, func(t *testing.T) {
rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken, body: body})
expectError(t, rec, http.StatusBadRequest, "M_BAD_JSON")
})
}
}
func TestAccountDataRejectsOversizedContent(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: accountDataPath(acc.UserID, "test.key"),
token: acc.AccessToken, body: map[string]any{"value": strings.Repeat("x", 70*1024)}})
expectError(t, rec, http.StatusBadRequest, "M_TOO_LARGE")
}
func TestRoomAccountDataRejectsMalformedRoomID(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut,
path: roomAccountDataPath(acc.UserID, "not-a-room-id", "test.key"),
token: acc.AccessToken, body: map[string]any{"value": "x"}})
expectError(t, rec, http.StatusBadRequest, "M_INVALID_PARAM")
}
// TestAccountDataIsReturnedVerbatim matters because account data is the
// client's own JSON: a server that re-serialises it can reorder keys or
// change number formatting under a client that is comparing bytes.
func TestAccountDataIsReturnedVerbatim(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
path := accountDataPath(acc.UserID, "test.key")
const content = `{"b":1,"a":{"nested":[1,2,3]},"big":10000000000000001}`
rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken,
body: rawJSON(content)})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(path, acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got := rec.Body.String(); got != content {
t.Errorf("account data came back as %s, want %s", got, content)
}
}
// rawJSON is marshalled as-is, so a test can send bytes of its choosing.
type rawJSON string
func (r rawJSON) MarshalJSON() ([]byte, error) { return []byte(r), nil }
+101
View File
@@ -0,0 +1,101 @@
package clientapi
import (
"errors"
"net/http"
"strings"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// AuthedHandler is a handler that needs an authenticated user. The identity
// is passed in rather than hidden in the request context, so a handler
// cannot forget to check it and the compiler says which endpoints are
// protected.
type AuthedHandler func(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error
// withAuth resolves the access token before calling h.
func (c *ClientAPI) withAuth(h AuthedHandler) httputil.Handler {
return func(w http.ResponseWriter, r *http.Request) error {
auth, err := c.authenticate(r)
if err != nil {
return err
}
c.users.RecordActivity(r.Context(), auth, c.clientIP(r), r.UserAgent())
return h(w, r, auth)
}
}
// authenticate turns a request's access token into an identity.
func (c *ClientAPI) authenticate(r *http.Request) (userapi.Authenticated, error) {
token := accessTokenFrom(r)
if token == "" {
return userapi.Authenticated{}, httputil.MissingToken()
}
auth, err := c.users.Authenticate(r.Context(), token)
switch {
case err == nil:
return auth, nil
case errors.Is(err, userapi.ErrNoSuchToken):
return userapi.Authenticated{}, httputil.UnknownToken(false)
case errors.Is(err, userapi.ErrTokenExpired):
// A soft logout keeps the client's encryption state: the token
// aged out, the device is still real, and logging in again
// restores the session rather than replacing it.
return userapi.Authenticated{}, httputil.UnknownToken(true)
case errors.Is(err, userapi.ErrAccountDeactivated):
return userapi.Authenticated{}, httputil.UserDeactivated()
default:
return userapi.Authenticated{}, err
}
}
// accessTokenFrom extracts the access token from a request.
//
// The Authorization header is the only form the current spec defines, but
// the ?access_token= query parameter is still what media URLs embedded in
// web clients and a long tail of older clients use, so both are accepted.
func accessTokenFrom(r *http.Request) string {
if header := r.Header.Get("Authorization"); header != "" {
if token, ok := bearerToken(header); ok {
return token
}
// A malformed Authorization header is not a reason to fall through
// to the query string: that would let a broken header hide a
// working token and make the failure hard to see.
return ""
}
return r.URL.Query().Get("access_token")
}
// bearerToken parses "Bearer <token>", case-insensitively on the scheme.
func bearerToken(header string) (string, bool) {
scheme, token, found := strings.Cut(header, " ")
if !found || !strings.EqualFold(scheme, "Bearer") {
return "", false
}
token = strings.TrimSpace(token)
return token, token != ""
}
// requireSelf checks that the user ID in a path belongs to the requester.
//
// Several endpoints are addressed by user ID even though they only ever
// operate on the caller's own data. The spec's answer for someone else's is
// M_FORBIDDEN; admins are allowed through, which is what makes the admin
// panel able to fix up a user's profile.
func (c *ClientAPI) requireSelf(auth userapi.Authenticated, pathUserID string) (string, error) {
localpart, err := c.users.ParseLocalUserID(pathUserID)
switch {
case errors.Is(err, userapi.ErrForeignServer):
return "", httputil.Forbidden("Cannot change data belonging to another server")
case err != nil:
return "", httputil.InvalidParam("%s is not a valid user ID", pathUserID)
}
if localpart != auth.Localpart && !auth.Admin {
return "", httputil.Forbidden("Cannot change the data of another user")
}
return localpart, nil
}
+64
View File
@@ -0,0 +1,64 @@
package clientapi
import (
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// DefaultRoomVersion is what this server creates rooms at.
//
// PLAN.md's decision is to implement the latest stable room version first
// and backfill older ones only as federation needs them, so the registry
// here has exactly one entry until the event core lands in Phase 2.
const DefaultRoomVersion = "11"
// roomVersionsCapability is the m.room_versions capability.
type roomVersionsCapability struct {
Default string `json:"default"`
Available map[string]string `json:"available"`
}
// booleanCapability is the shape of the several capabilities that are only
// an on/off switch.
type booleanCapability struct {
Enabled bool `json:"enabled"`
}
type capabilitiesResponse struct {
Capabilities capabilities `json:"capabilities"`
}
type capabilities struct {
RoomVersions roomVersionsCapability `json:"m.room_versions"`
ChangePassword booleanCapability `json:"m.change_password"`
SetDisplayName booleanCapability `json:"m.set_displayname"`
SetAvatarURL booleanCapability `json:"m.set_avatar_url"`
ThreePIDChanges booleanCapability `json:"m.3pid_changes"`
}
// handleCapabilities implements GET /_matrix/client/v3/capabilities.
//
// The endpoint needs a token: what a server will let you do depends on who
// you are, and the spec makes it authenticated for that reason.
//
// Every flag here is the truth about this build, not an aspiration.
// Password changes and third-party identifiers arrive with the rest of
// account management in Phase 9, so they report false and clients hide the
// buttons rather than offering something that 404s.
func (c *ClientAPI) handleCapabilities(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
httputil.WriteJSON(w, http.StatusOK, capabilitiesResponse{
Capabilities: capabilities{
RoomVersions: roomVersionsCapability{
Default: DefaultRoomVersion,
Available: map[string]string{DefaultRoomVersion: "stable"},
},
ChangePassword: booleanCapability{Enabled: false},
SetDisplayName: booleanCapability{Enabled: true},
SetAvatarURL: booleanCapability{Enabled: true},
ThreePIDChanges: booleanCapability{Enabled: false},
},
})
return nil
}
+204
View File
@@ -0,0 +1,204 @@
// Package clientapi implements the Client-Server API: the HTTP surface
// clients talk to.
//
// Phase 1 covers everything about identity — registration, login, sessions,
// devices, profiles, account data and capabilities. Rooms, messages and
// /sync arrive in Phases 3 and 4; the route table here is the place they
// will be added.
//
// Handlers are thin. They decode a request, map it onto a userapi call and
// shape the response; the rules live in userapi, and the storage in
// internal/storage. A handler that needs more than that is a sign something
// belongs further down.
package clientapi
import (
"errors"
"log/slog"
"time"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/ratelimit"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// ClientAPI serves the Client-Server API.
type ClientAPI struct {
serverName string
clientBaseURL string
users *userapi.API
settings *settings.Service
federation *federation.Gate
registration config.Registration
log *slog.Logger
now func() time.Time
uia *uiaStore
clientIP ratelimit.KeyFunc
// One limiter per endpoint class, so a client hammering /login cannot
// spend the budget of /profile.
limitDefault *ratelimit.Limiter
limitLogin *ratelimit.Limiter
limitRegister *ratelimit.Limiter
}
// Options configure the component.
type Options struct {
// ServerName is the Matrix server name.
ServerName string
// ClientBaseURL is the URL advertised to clients, the same value
// /.well-known/matrix/client carries. A successful login repeats it so
// that a client which only knew the server name does not have to fetch
// the discovery document separately.
ClientBaseURL string
// Users is the userapi component.
Users *userapi.API
// Settings is the runtime-settings service; the registration switch is
// read from it on every registration attempt.
Settings *settings.Service
// Federation gates the endpoints that would need a remote server.
Federation *federation.Gate
// Registration is the file configuration of the registration flow.
Registration config.Registration
// RateLimit configures the per-endpoint buckets.
RateLimit config.RateLimit
// TrustXForwardedFor takes the client address from the proxy header.
TrustXForwardedFor bool
Logger *slog.Logger
// Now is the clock, so tests can expire sessions without waiting.
Now func() time.Time
}
// New builds the component.
func New(opts Options) (*ClientAPI, error) {
if opts.ServerName == "" {
return nil, errors.New("clientapi: ServerName must not be empty")
}
if opts.Users == nil {
return nil, errors.New("clientapi: Users must not be nil")
}
if opts.Settings == nil {
return nil, errors.New("clientapi: Settings must not be nil")
}
if opts.Federation == nil {
opts.Federation = federation.NewGate(nil, nil)
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.Now == nil {
opts.Now = time.Now
}
c := &ClientAPI{
serverName: opts.ServerName,
clientBaseURL: opts.ClientBaseURL,
users: opts.Users,
settings: opts.Settings,
federation: opts.Federation,
registration: opts.Registration,
log: opts.Logger,
now: opts.Now,
uia: newUIAStore(uiaSessionTTL, opts.Now),
clientIP: ratelimit.ClientIP(opts.TrustXForwardedFor),
}
if opts.RateLimit.Enabled {
c.limitDefault = ratelimit.New(bucket(opts.RateLimit.Default))
c.limitLogin = ratelimit.New(bucket(opts.RateLimit.Login))
c.limitRegister = ratelimit.New(bucket(opts.RateLimit.Register))
} else {
c.limitDefault, c.limitLogin, c.limitRegister =
ratelimit.Disabled(), ratelimit.Disabled(), ratelimit.Disabled()
}
return c, nil
}
func bucket(b config.RateLimitBucket) ratelimit.Policy {
return ratelimit.Policy{Burst: b.Burst, Interval: b.Interval}
}
// Client-Server API path prefixes. Warp serves the versioned paths only:
// the r0 prefix was deprecated in Matrix 1.4, and every client that still
// sends it also understands a 404 and falls back to version negotiation
// through /_matrix/client/versions.
const (
v3 = "/_matrix/client/v3"
v1 = "/_matrix/client/v1"
)
// RegisterRoutes adds the Client-Server endpoints to the router.
func (c *ClientAPI) RegisterRoutes(r *httputil.Router) {
// Registration. The token-validity endpoint is v1 because that is the
// version the spec introduced it at.
c.public(r, "POST "+v3+"/register", c.limitRegister, c.handleRegister)
c.public(r, "GET "+v3+"/register/available", c.limitRegister, c.handleRegisterAvailable)
c.public(r, "GET "+v1+"/register/m.login.registration_token/validity",
c.limitRegister, c.handleRegistrationTokenValidity)
// Sessions.
c.public(r, "GET "+v3+"/login", c.limitDefault, c.handleLoginFlows)
c.public(r, "POST "+v3+"/login", c.limitLogin, c.handleLogin)
c.public(r, "POST "+v3+"/refresh", c.limitLogin, c.handleRefresh)
c.authed(r, "POST "+v3+"/logout", c.limitDefault, c.handleLogout)
c.authed(r, "POST "+v3+"/logout/all", c.limitDefault, c.handleLogoutAll)
// Account.
c.authed(r, "GET "+v3+"/account/whoami", c.limitDefault, c.handleWhoami)
c.authed(r, "GET "+v3+"/capabilities", c.limitDefault, c.handleCapabilities)
// Devices.
c.authed(r, "GET "+v3+"/devices", c.limitDefault, c.handleGetDevices)
c.authed(r, "GET "+v3+"/devices/{deviceID}", c.limitDefault, c.handleGetDevice)
c.authed(r, "PUT "+v3+"/devices/{deviceID}", c.limitDefault, c.handlePutDevice)
c.authed(r, "DELETE "+v3+"/devices/{deviceID}", c.limitDefault, c.handleDeleteDevice)
c.authed(r, "POST "+v3+"/delete_devices", c.limitDefault, c.handleDeleteDevices)
// Profiles are world-readable: a client showing a room member's name
// has no token for that member's server, and the spec makes the GETs
// unauthenticated for exactly that reason.
c.public(r, "GET "+v3+"/profile/{userID}", c.limitDefault, c.handleGetProfile)
c.public(r, "GET "+v3+"/profile/{userID}/displayname", c.limitDefault, c.handleGetDisplayName)
c.public(r, "GET "+v3+"/profile/{userID}/avatar_url", c.limitDefault, c.handleGetAvatarURL)
c.authed(r, "PUT "+v3+"/profile/{userID}/displayname", c.limitDefault, c.handlePutDisplayName)
c.authed(r, "PUT "+v3+"/profile/{userID}/avatar_url", c.limitDefault, c.handlePutAvatarURL)
// Account data.
c.authed(r, "GET "+v3+"/user/{userID}/account_data/{dataType}",
c.limitDefault, c.handleGetAccountData)
c.authed(r, "PUT "+v3+"/user/{userID}/account_data/{dataType}",
c.limitDefault, c.handlePutAccountData)
c.authed(r, "GET "+v3+"/user/{userID}/rooms/{roomID}/account_data/{dataType}",
c.limitDefault, c.handleGetRoomAccountData)
c.authed(r, "PUT "+v3+"/user/{userID}/rooms/{roomID}/account_data/{dataType}",
c.limitDefault, c.handlePutRoomAccountData)
c.authed(r, "GET "+v3+"/sync", c.limitDefault, c.handleSync)
}
// public registers an endpoint that needs no access token.
func (c *ClientAPI) public(r *httputil.Router, pattern string, limiter *ratelimit.Limiter, h httputil.Handler) {
r.Handle(pattern, limiter.Middleware(c.clientIP)(httputil.Wrap(h)))
}
// authed registers an endpoint that needs one.
//
// The rate limiter still keys on the client address rather than the user:
// it runs before the token is resolved, and a limiter that had to hit the
// database to find its own key would be a denial of service of its own.
func (c *ClientAPI) authed(r *httputil.Router, pattern string, limiter *ratelimit.Limiter, h AuthedHandler) {
r.Handle(pattern, limiter.Middleware(c.clientIP)(httputil.Wrap(c.withAuth(h))))
}
+268
View File
@@ -0,0 +1,268 @@
package clientapi_test
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
const testServerName = "warp.example"
// testServer is a Client-Server API on a throwaway SQLite database.
//
// It is built from the real components rather than fakes: the point of these
// tests is the behaviour a client sees, and a fake userapi would only prove
// that the handlers call the methods the fake expects.
type testServer struct {
t *testing.T
handler http.Handler
users *userapi.API
settings *settings.Service
fedGate *federation.Gate
}
// newTestServer builds a server. tweak adjusts the options before the
// component is created, which is how a test turns on registration tokens or
// rate limiting.
func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db"))
db, err := storage.Open(t.Context(), storage.Config{
Driver: storage.DriverSQLite,
ConnectionString: "file:" + path + "?_pragma=busy_timeout(5000)",
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("open storage: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("migrate: %v", err)
}
users, err := userapi.New(userapi.Options{
ServerName: testServerName,
Store: db.Users(),
Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatalf("userapi.New: %v", err)
}
// Registration is on and the limiter is off by default: almost every
// test needs an account, and almost none is about rate limiting.
settingsService, err := settings.New(t.Context(), db.Settings(), settings.Defaults{
RegistrationEnabled: true,
RegistrationAllowRuntimeToggle: true,
FederationAllowRuntimeToggle: true,
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("settings.New: %v", err)
}
gate := federation.NewGate(settingsService.FederationEnabled, nil)
opts := clientapi.Options{
ServerName: testServerName,
Users: users,
Settings: settingsService,
Federation: gate,
Logger: slog.New(slog.DiscardHandler),
}
if tweak != nil {
tweak(&opts)
}
api, err := clientapi.New(opts)
if err != nil {
t.Fatalf("clientapi.New: %v", err)
}
router := httputil.NewRouter(nil)
api.RegisterRoutes(router)
return &testServer{
t: t,
handler: router,
users: users,
settings: settingsService,
fedGate: gate,
}
}
// request is one HTTP call under test.
type request struct {
method string
path string
body any
token string
remote string
}
// do performs a request. A nil body sends none at all, which is what the
// endpoints whose body is optional have to cope with.
func (ts *testServer) do(req request) *httptest.ResponseRecorder {
ts.t.Helper()
var reader io.Reader
if req.body != nil {
raw, err := json.Marshal(req.body)
if err != nil {
ts.t.Fatalf("marshalling the request body: %v", err)
}
reader = bytes.NewReader(raw)
}
httpReq := httptest.NewRequest(req.method, req.path, reader)
if req.body != nil {
httpReq.Header.Set("Content-Type", "application/json")
}
if req.token != "" {
httpReq.Header.Set("Authorization", "Bearer "+req.token)
}
if req.remote != "" {
httpReq.RemoteAddr = req.remote
}
rec := httptest.NewRecorder()
ts.handler.ServeHTTP(rec, httpReq)
return rec
}
// get is the common read-only case.
func (ts *testServer) get(path, token string) *httptest.ResponseRecorder {
ts.t.Helper()
return ts.do(request{method: http.MethodGet, path: path, token: token})
}
// decode parses a response body as JSON.
func decode[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
t.Helper()
var out T
body := rec.Body.Bytes()
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("response is not JSON (%s): %v", body, err)
}
return out
}
// jsonBody parses a response into a generic map.
func jsonBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
return decode[map[string]any](t, rec)
}
// expectStatus fails unless the response has the given status.
func expectStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) {
t.Helper()
if rec.Code != want {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, want, rec.Body.String())
}
}
// expectError fails unless the response is the given Matrix error.
func expectError(t *testing.T, rec *httptest.ResponseRecorder, status int, errcode string) {
t.Helper()
expectStatus(t, rec, status)
body := jsonBody(t, rec)
if got, _ := body["errcode"].(string); got != errcode {
t.Errorf("errcode = %q, want %q (body: %s)", got, errcode, rec.Body.String())
}
if msg, _ := body["error"].(string); msg == "" {
t.Error("the error envelope must carry a human-readable message")
}
}
// account is a registered user plus its first session, which is what most
// tests need before they can do anything.
type account struct {
UserID string
Localpart string
DeviceID string
AccessToken string
Password string
}
// register walks the full two-step registration flow, as a client does.
func (ts *testServer) register(localpart, password string) account {
ts.t.Helper()
rec := ts.do(request{
method: http.MethodPost,
path: "/_matrix/client/v3/register",
body: map[string]any{"username": localpart, "password": password},
})
expectStatus(ts.t, rec, http.StatusUnauthorized)
session, _ := jsonBody(ts.t, rec)["session"].(string)
if session == "" {
ts.t.Fatalf("the challenge carried no session: %s", rec.Body.String())
}
rec = ts.do(request{
method: http.MethodPost,
path: "/_matrix/client/v3/register",
body: map[string]any{
"username": localpart,
"password": password,
"auth": map[string]any{"type": "m.login.dummy", "session": session},
},
})
expectStatus(ts.t, rec, http.StatusOK)
body := jsonBody(ts.t, rec)
acc := account{Password: password}
acc.UserID, _ = body["user_id"].(string)
acc.AccessToken, _ = body["access_token"].(string)
acc.DeviceID, _ = body["device_id"].(string)
acc.Localpart = userapi.NormaliseLocalpart(localpart)
if acc.AccessToken == "" || acc.DeviceID == "" {
ts.t.Fatalf("registration returned no session: %s", rec.Body.String())
}
return acc
}
// login creates a second session for an existing account.
func (ts *testServer) login(acc account, deviceID string) account {
ts.t.Helper()
body := map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": acc.Password,
}
if deviceID != "" {
body["device_id"] = deviceID
}
rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/login", body: body})
expectStatus(ts.t, rec, http.StatusOK)
parsed := jsonBody(ts.t, rec)
out := acc
out.AccessToken, _ = parsed["access_token"].(string)
out.DeviceID, _ = parsed["device_id"].(string)
return out
}
// makeAdmin grants the admin flag to an existing account.
func (ts *testServer) makeAdmin(acc account) {
ts.t.Helper()
if err := ts.users.SetAdmin(ts.t.Context(), acc.Localpart, true); err != nil {
ts.t.Fatalf("SetAdmin: %v", err)
}
}
// withRateLimit is a tweak that turns on the limiter with a given policy.
func withRateLimit(limit config.RateLimit) func(*clientapi.Options) {
return func(o *clientapi.Options) { o.RateLimit = limit }
}
+189
View File
@@ -0,0 +1,189 @@
package clientapi
import (
"errors"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// deviceResponse is one entry of the device list.
type deviceResponse struct {
DeviceID string `json:"device_id"`
DisplayName string `json:"display_name,omitempty"`
// LastSeenIP and LastSeenTS are omitted until the device has been seen,
// which for a freshly created one is the same request that created it.
LastSeenIP string `json:"last_seen_ip,omitempty"`
LastSeenTS int64 `json:"last_seen_ts,omitempty"`
}
type devicesResponse struct {
Devices []deviceResponse `json:"devices"`
}
func newDeviceResponse(d storage.Device) deviceResponse {
return deviceResponse{
DeviceID: d.ID,
DisplayName: d.DisplayName,
LastSeenIP: d.LastSeenIP,
LastSeenTS: d.LastSeenMS,
}
}
// handleGetDevices implements GET /_matrix/client/v3/devices.
func (c *ClientAPI) handleGetDevices(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
devices, err := c.users.Devices(r.Context(), auth.Localpart)
if err != nil {
return err
}
out := make([]deviceResponse, 0, len(devices))
for _, d := range devices {
out = append(out, newDeviceResponse(d))
}
httputil.WriteJSON(w, http.StatusOK, devicesResponse{Devices: out})
return nil
}
// handleGetDevice implements GET /_matrix/client/v3/devices/{deviceId}.
func (c *ClientAPI) handleGetDevice(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
device, err := c.users.Device(r.Context(), auth.Localpart, r.PathValue("deviceID"))
switch {
case errors.Is(err, userapi.ErrNoSuchDevice):
return httputil.NotFound("Unknown device")
case err != nil:
return err
}
httputil.WriteJSON(w, http.StatusOK, newDeviceResponse(device))
return nil
}
// putDeviceRequest is the body of PUT /devices/{deviceId}.
type putDeviceRequest struct {
DisplayName string `json:"display_name"`
}
// handlePutDevice implements PUT /_matrix/client/v3/devices/{deviceId}.
func (c *ClientAPI) handlePutDevice(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
var req putDeviceRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
err := c.users.SetDeviceDisplayName(r.Context(), auth.Localpart, r.PathValue("deviceID"), req.DisplayName)
switch {
case errors.Is(err, userapi.ErrNoSuchDevice):
return httputil.NotFound("Unknown device")
case err != nil:
return err
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// deleteDeviceRequest is the body of DELETE /devices/{deviceId}: nothing but
// the authentication.
type deleteDeviceRequest struct {
Auth *uiaRequest `json:"auth"`
}
// deleteDevicesRequest is the body of POST /delete_devices.
type deleteDevicesRequest struct {
Devices []string `json:"devices"`
Auth *uiaRequest `json:"auth"`
}
// handleDeleteDevice implements DELETE /_matrix/client/v3/devices/{deviceId}.
func (c *ClientAPI) handleDeleteDevice(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
var req deleteDeviceRequest
// The body may be absent: a client's first attempt carries no
// authentication, because the 401 is how it learns the endpoint needs
// any.
if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil {
return err
}
return c.deleteDevicesWithUIA(w, r, auth, req.Auth, []string{r.PathValue("deviceID")})
}
// handleDeleteDevices implements POST /_matrix/client/v3/delete_devices.
func (c *ClientAPI) handleDeleteDevices(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
var req deleteDevicesRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
return c.deleteDevicesWithUIA(w, r, auth, req.Auth, req.Devices)
}
// deleteDevicesWithUIA is the shared half of the two device-deletion
// endpoints: both ask the user to re-enter their password first.
//
// Revoking a device logs out whoever holds its token, so a stolen access
// token must not be enough to do it — that is exactly the position a victim
// is in when they go to remove the attacker's session.
func (c *ClientAPI) deleteDevicesWithUIA(
w http.ResponseWriter,
r *http.Request,
auth userapi.Authenticated,
authReq *uiaRequest,
deviceIDs []string,
) error {
flows := []uiaFlow{{Stages: []string{stagePassword}}}
sessionID, done, err := c.beginOrAdvanceUIA(w, flows, authReq,
func(_ string, stage *uiaRequest) (*httputil.MatrixError, error) {
return c.passwordStage(r, auth, stage)
})
if err != nil || !done {
return err
}
if err := c.users.DeleteDevices(r.Context(), auth.Localpart, deviceIDs); err != nil {
return err
}
c.uia.remove(sessionID)
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// passwordStage checks an m.login.password stage of an interactive
// authentication.
//
// The identity in the stage must be the user making the request. Without
// that check, knowing *any* account's password would be enough to pass the
// re-authentication on somebody else's session — which is the flaw the
// stage exists to prevent, and which Complement tests for directly.
func (c *ClientAPI) passwordStage(r *http.Request, auth userapi.Authenticated, stage *uiaRequest) (*httputil.MatrixError, error) {
identifier := stage.Identifier.User
switch {
case identifier != "" && stage.Identifier.Type != identifierUser:
return httputil.InvalidParam("Login identifier type %q is not supported", stage.Identifier.Type), nil
case identifier == "":
identifier = stage.User
}
if identifier == "" {
return httputil.MissingParam("A user identifier is required"), nil
}
localpart, err := c.users.ParseLocalUserID(identifier)
if err != nil {
return httputil.Forbidden("Invalid username or password"), nil
}
if localpart != auth.Localpart {
// Not a failed stage but a refusal: no password will make this
// request acceptable, so it ends here with a 403 instead of
// inviting another attempt.
return nil, httputil.Forbidden(
"The user in the authentication must be the one making the request")
}
switch err := c.users.CheckPassword(r.Context(), localpart, stage.Password); {
case err == nil:
return nil, nil
case errors.Is(err, userapi.ErrWrongPassword), errors.Is(err, userapi.ErrNoPassword):
return httputil.Forbidden("Invalid username or password"), nil
default:
return nil, err
}
}
+250
View File
@@ -0,0 +1,250 @@
package clientapi_test
import (
"net/http"
"testing"
)
const devicesPath = "/_matrix/client/v3/devices"
func TestGetDevices(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
ts.login(acc, "login_device")
ts.login(acc, "login_device_2")
rec := ts.get(devicesPath, acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
devices, _ := jsonBody(t, rec)["devices"].([]any)
seen := map[string]bool{}
for _, device := range devices {
id, _ := device.(map[string]any)["device_id"].(string)
seen[id] = true
}
for _, want := range []string{acc.DeviceID, "login_device", "login_device_2"} {
if !seen[want] {
t.Errorf("/devices did not return %s (got %v)", want, seen)
}
}
}
// TestGetDevicesIsPerUser is the check that matters most here: one user must
// never see another's sessions.
func TestGetDevicesIsPerUser(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "superuser")
bob := ts.register("bob", "bobspassword")
rec := ts.get(devicesPath, bob.AccessToken)
expectStatus(t, rec, http.StatusOK)
devices, _ := jsonBody(t, rec)["devices"].([]any)
if len(devices) != 1 {
t.Fatalf("bob sees %d devices, want 1", len(devices))
}
if devices[0].(map[string]any)["device_id"] == alice.DeviceID {
t.Fatal("bob can see alice's device")
}
}
func TestGetDevice(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": "superuser",
"device_id": "login_device",
"initial_device_display_name": "device display",
}})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(devicesPath+"/login_device", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if body["device_id"] != "login_device" {
t.Errorf("device_id = %v", body["device_id"])
}
if body["display_name"] != "device display" {
t.Errorf("display_name = %v, want %q", body["display_name"], "device display")
}
}
func TestGetUnknownDeviceIs404(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
expectError(t, ts.get(devicesPath+"/unknown_device", acc.AccessToken),
http.StatusNotFound, "M_NOT_FOUND")
}
func TestPutDevice(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: devicesPath + "/" + acc.DeviceID,
token: acc.AccessToken, body: map[string]any{"display_name": "new device display"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(devicesPath+"/"+acc.DeviceID, acc.AccessToken)
if got := jsonBody(t, rec)["display_name"]; got != "new device display" {
t.Errorf("display_name = %v, want the updated one", got)
}
}
func TestPutUnknownDeviceIs404(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: devicesPath + "/unknown_device",
token: acc.AccessToken, body: map[string]any{"display_name": "x"}})
expectError(t, rec, http.StatusNotFound, "M_NOT_FOUND")
}
// TestDeleteDeviceRequiresInteractiveAuth is the reason this endpoint is
// interactive: a stolen access token must not be enough to log out the real
// owner, which is exactly what they are trying to do when they reach for it.
func TestDeleteDeviceRequiresInteractiveAuth(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
victim := ts.login(acc, "")
path := devicesPath + "/" + victim.DeviceID
// No body at all: the client does not know yet that it has to
// authenticate, so this is the normal first request.
rec := ts.do(request{method: http.MethodDelete, path: path, token: acc.AccessToken})
expectStatus(t, rec, http.StatusUnauthorized)
body := jsonBody(t, rec)
for _, key := range []string{"session", "flows", "params"} {
if _, present := body[key]; !present {
t.Errorf("the challenge is missing %s: %s", key, rec.Body.String())
}
}
flows, _ := body["flows"].([]any)
stages, _ := flows[0].(map[string]any)["stages"].([]any)
if len(stages) != 1 || stages[0] != "m.login.password" {
t.Fatalf("stages = %v, want [m.login.password]", stages)
}
session := body["session"]
// The wrong password fails the stage and says so, keeping the session.
rec = ts.do(request{method: http.MethodDelete, path: path, token: acc.AccessToken,
body: map[string]any{"auth": map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": "super-wrong-password",
"session": session,
}}})
expectStatus(t, rec, http.StatusUnauthorized)
body = jsonBody(t, rec)
if body["errcode"] != "M_FORBIDDEN" {
t.Errorf("errcode = %v, want M_FORBIDDEN: %s", body["errcode"], rec.Body.String())
}
for _, key := range []string{"session", "flows", "params", "error"} {
if _, present := body[key]; !present {
t.Errorf("a failed stage is missing %s: %s", key, rec.Body.String())
}
}
// The device survived the failed attempt.
expectStatus(t, ts.get(path, acc.AccessToken), http.StatusOK)
// The right password completes it.
rec = ts.do(request{method: http.MethodDelete, path: path, token: acc.AccessToken,
body: map[string]any{"auth": map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": "superuser",
"session": session,
}}})
expectStatus(t, rec, http.StatusOK)
expectError(t, ts.get(path, acc.AccessToken), http.StatusNotFound, "M_NOT_FOUND")
expectError(t, ts.get("/_matrix/client/v3/sync", victim.AccessToken),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
}
// TestDeleteDeviceRequiresTheOwnersPassword is the flaw the stage exists to
// prevent: knowing *somebody's* password must not pass the
// re-authentication on another account's session.
func TestDeleteDeviceRequiresTheOwnersPassword(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "superuser")
bob := ts.register("bob", "bobspassword")
victim := ts.login(alice, "")
path := devicesPath + "/" + victim.DeviceID
rec := ts.do(request{method: http.MethodDelete, path: path, token: alice.AccessToken})
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodDelete, path: path, token: alice.AccessToken,
body: map[string]any{"auth": map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": bob.UserID},
"password": "bobspassword",
"session": session,
}}})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
expectStatus(t, ts.get(path, alice.AccessToken), http.StatusOK)
// Alice's own password still works.
rec = ts.do(request{method: http.MethodDelete, path: path, token: alice.AccessToken,
body: map[string]any{"auth": map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": alice.UserID},
"password": "superuser",
"session": session,
}}})
expectStatus(t, rec, http.StatusOK)
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, "M_NOT_FOUND")
}
func TestDeleteDevices(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
first := ts.login(acc, "DEVICE_ONE")
second := ts.login(acc, "DEVICE_TWO")
const path = "/_matrix/client/v3/delete_devices"
rec := ts.do(request{method: http.MethodPost, path: path, token: acc.AccessToken,
body: map[string]any{"devices": []string{"DEVICE_ONE", "DEVICE_TWO"}}})
expectStatus(t, rec, http.StatusUnauthorized)
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodPost, path: path, token: acc.AccessToken,
body: map[string]any{
"devices": []string{"DEVICE_ONE", "DEVICE_TWO"},
"auth": map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": "superuser",
"session": session,
},
}})
expectStatus(t, rec, http.StatusOK)
for _, token := range []string{first.AccessToken, second.AccessToken} {
expectError(t, ts.get("/_matrix/client/v3/sync", token),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
}
// The session doing the deleting is untouched.
expectStatus(t, ts.get("/_matrix/client/v3/sync", acc.AccessToken), http.StatusOK)
}
// TestDeviceLastSeenIsRecorded covers the bookkeeping the device list shows.
func TestDeviceLastSeenIsRecorded(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.get(devicesPath+"/"+acc.DeviceID, acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if ts, _ := body["last_seen_ts"].(float64); ts <= 0 {
t.Errorf("last_seen_ts = %v, want the time of the request that created it", body["last_seen_ts"])
}
}
+249
View File
@@ -0,0 +1,249 @@
package clientapi
import (
"errors"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// Login identifier types. Third-party identifiers (email, phone) need an
// identity server, which is a non-goal for now.
const (
identifierUser = "m.id.user"
)
// loginIdentifier is the `identifier` object of a login or a password stage.
type loginIdentifier struct {
Type string `json:"type"`
User string `json:"user"`
}
// loginRequest is the body of POST /login.
type loginRequest struct {
Type string `json:"type"`
Identifier loginIdentifier `json:"identifier"`
// User is the deprecated identifier form, still sent by older clients
// and by some test harnesses.
User string `json:"user"`
Password string `json:"password"`
DeviceID string `json:"device_id"`
InitialDeviceDisplayName string `json:"initial_device_display_name"`
RefreshToken bool `json:"refresh_token"`
}
// loginFlow is one entry of GET /login.
//
// The spec defines a flow as having `stages`, but for login every flow has
// exactly one, and clients have always read a flat `type`. Both are sent:
// `type` for the clients, `stages` for a strict reading of the spec.
type loginFlow struct {
Type string `json:"type"`
Stages []string `json:"stages,omitempty"`
}
type loginFlowsResponse struct {
Flows []loginFlow `json:"flows"`
}
// loginResponse is the body of a successful login.
type loginResponse struct {
UserID string `json:"user_id"`
AccessToken string `json:"access_token"`
DeviceID string `json:"device_id"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresInMS int64 `json:"expires_in_ms,omitempty"`
// HomeServer is deprecated but still read by older clients.
HomeServer string `json:"home_server,omitempty"`
WellKnown *wellKnownDiscovery `json:"well_known,omitempty"`
}
// wellKnownDiscovery mirrors /.well-known/matrix/client, which the spec
// allows a login response to carry so a client does not have to fetch it.
type wellKnownDiscovery struct {
Homeserver wellKnownBaseURL `json:"m.homeserver"`
}
type wellKnownBaseURL struct {
BaseURL string `json:"base_url"`
}
// handleLoginFlows implements GET /_matrix/client/v3/login.
func (c *ClientAPI) handleLoginFlows(w http.ResponseWriter, _ *http.Request) error {
httputil.WriteJSON(w, http.StatusOK, loginFlowsResponse{
Flows: []loginFlow{{Type: stagePassword, Stages: []string{stagePassword}}},
})
return nil
}
// handleLogin implements POST /_matrix/client/v3/login.
//
// Every rejection is the same M_FORBIDDEN, whether the account does not
// exist, has no password or the password is wrong, and userapi spends the
// same work on each. A login endpoint that distinguishes them is a user
// directory for anyone who asks.
func (c *ClientAPI) handleLogin(w http.ResponseWriter, r *http.Request) error {
var req loginRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if req.Type != stagePassword {
return httputil.Unknown(http.StatusBadRequest, "Login type %q is not supported", req.Type)
}
identifier := req.Identifier.User
switch {
case identifier != "" && req.Identifier.Type != identifierUser:
return httputil.InvalidParam("Login identifier type %q is not supported", req.Identifier.Type)
case identifier == "":
identifier = req.User
}
if identifier == "" {
return httputil.MissingParam("A user identifier is required")
}
localpart, err := c.users.ParseLocalUserID(identifier)
switch {
case errors.Is(err, userapi.ErrForeignServer):
// Not a federation matter: an account on another server has its
// password there, and no amount of federation changes that.
return httputil.Forbidden("Cannot log in as a user of another server")
case err != nil:
return httputil.Forbidden("Invalid username or password")
}
switch err := c.users.CheckPassword(r.Context(), localpart, req.Password); {
case err == nil:
case errors.Is(err, userapi.ErrWrongPassword), errors.Is(err, userapi.ErrNoPassword):
return httputil.Forbidden("Invalid username or password")
default:
return err
}
account, err := c.users.Account(r.Context(), localpart)
if err != nil {
return err
}
if account.Deactivated {
return httputil.UserDeactivated()
}
if req.DeviceID != "" {
if err := userapi.ValidateDeviceID(req.DeviceID); err != nil {
return httputil.InvalidParam("%s", err.Error())
}
}
session, err := c.users.CreateSession(r.Context(), userapi.SessionRequest{
Localpart: localpart,
DeviceID: req.DeviceID,
DisplayName: req.InitialDeviceDisplayName,
WithRefreshToken: req.RefreshToken,
IP: c.clientIP(r),
UserAgent: r.UserAgent(),
})
if err != nil {
return err
}
resp := loginResponse{
UserID: session.UserID,
AccessToken: session.AccessToken,
DeviceID: session.DeviceID,
RefreshToken: session.RefreshToken,
ExpiresInMS: session.ExpiresInMS,
HomeServer: c.serverName,
}
if c.clientBaseURL != "" {
resp.WellKnown = &wellKnownDiscovery{
Homeserver: wellKnownBaseURL{BaseURL: c.clientBaseURL},
}
}
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}
// refreshRequest is the body of POST /refresh.
type refreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
// refreshResponse is its answer.
type refreshResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresInMS int64 `json:"expires_in_ms,omitempty"`
}
// handleRefresh implements POST /_matrix/client/v3/refresh.
//
// It is unauthenticated on purpose: the refresh token *is* the credential,
// and the access token it replaces has usually already expired.
func (c *ClientAPI) handleRefresh(w http.ResponseWriter, r *http.Request) error {
var req refreshRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if req.RefreshToken == "" {
return httputil.MissingParam("refresh_token is required")
}
session, err := c.users.RefreshSession(r.Context(), req.RefreshToken)
switch {
case err == nil:
case errors.Is(err, userapi.ErrNoSuchToken):
// A soft logout: the device is probably still legitimate, it just
// cannot prove it any more.
return httputil.UnknownToken(true)
case errors.Is(err, userapi.ErrAccountDeactivated):
return httputil.UserDeactivated()
default:
return err
}
httputil.WriteJSON(w, http.StatusOK, refreshResponse{
AccessToken: session.AccessToken,
RefreshToken: session.RefreshToken,
ExpiresInMS: session.ExpiresInMS,
})
return nil
}
// handleLogout implements POST /_matrix/client/v3/logout. Logging out
// deletes the device, not only the token: that is what the spec says, and it
// is what keeps a user's device list from filling up with dead entries.
func (c *ClientAPI) handleLogout(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
if err := c.users.DeleteDevices(r.Context(), auth.Localpart, []string{auth.DeviceID}); err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// handleLogoutAll implements POST /_matrix/client/v3/logout/all.
func (c *ClientAPI) handleLogoutAll(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
if err := c.users.DeleteAllDevices(r.Context(), auth.Localpart); err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// handleWhoami implements GET /_matrix/client/v3/account/whoami.
func (c *ClientAPI) handleWhoami(w http.ResponseWriter, _ *http.Request, auth userapi.Authenticated) error {
httputil.WriteJSON(w, http.StatusOK, map[string]any{
"user_id": auth.UserID,
"device_id": auth.DeviceID,
// Guest accounts are not implemented, so this is always false
// rather than omitted: clients branch on it.
"is_guest": false,
})
return nil
}
+525
View File
@@ -0,0 +1,525 @@
package clientapi_test
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/config"
)
const loginPath = "/_matrix/client/v3/login"
func TestLoginFlows(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.get(loginPath, "")
expectStatus(t, rec, http.StatusOK)
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
flows, _ := jsonBody(t, rec)["flows"].([]any)
if len(flows) == 0 {
t.Fatal("flows must not be empty")
}
// Clients read the flat `type`; a strict reading of the spec wants
// `stages`. Both are sent, so both are checked.
for _, flow := range flows {
entry, _ := flow.(map[string]any)
if entry["type"] != "m.login.password" {
t.Errorf("flow type = %v, want m.login.password", entry["type"])
}
if stages, ok := entry["stages"].([]any); !ok || len(stages) == 0 {
t.Errorf("flow %v has no stages", entry)
}
}
}
func TestLoginWithPassword(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": "superuser",
}})
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
for _, field := range []string{"user_id", "access_token", "device_id"} {
if value, _ := body[field].(string); value == "" {
t.Errorf("login response is missing %s: %s", field, rec.Body.String())
}
}
if body["user_id"] != acc.UserID {
t.Errorf("user_id = %v, want %s", body["user_id"], acc.UserID)
}
// Logging in must not reuse the registration's device, or a second
// client would silently take over the first one's session.
if body["device_id"] == acc.DeviceID {
t.Error("a login without a device_id must create a new device")
}
}
// TestLoginAcceptsEveryIdentifierForm covers the three shapes clients send:
// the full user ID, the bare localpart, and the deprecated top-level `user`.
func TestLoginAcceptsEveryIdentifierForm(t *testing.T) {
ts := newTestServer(t, nil)
ts.register("alice", "superuser")
for name, body := range map[string]map[string]any{
"full user ID": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "@alice:warp.example"},
"password": "superuser",
},
"localpart only": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "superuser",
},
"deprecated user field": {
"type": "m.login.password",
"user": "alice",
"password": "superuser",
},
"uppercase user ID": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "@ALICE:warp.example"},
"password": "superuser",
},
"uppercase server name": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "@alice:WARP.example"},
"password": "superuser",
},
} {
t.Run(name, func(t *testing.T) {
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: body})
expectStatus(t, rec, http.StatusOK)
if got := jsonBody(t, rec)["user_id"]; got != "@alice:warp.example" {
t.Errorf("user_id = %v, want @alice:warp.example", got)
}
})
}
}
func TestLoginHonoursRequestedDeviceID(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": "superuser",
"device_id": "test_device_id",
"initial_device_display_name": "device display",
}})
expectStatus(t, rec, http.StatusOK)
if got := jsonBody(t, rec)["device_id"]; got != "test_device_id" {
t.Errorf("device_id = %v, want test_device_id", got)
}
}
// TestLoginOnExistingDeviceRevokesItsToken is the spec's rule for a login
// naming a device that already exists: the device stays, its old token does
// not.
func TestLoginOnExistingDeviceRevokesItsToken(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
first := ts.login(acc, "SHARED")
second := ts.login(acc, "SHARED")
if first.AccessToken == second.AccessToken {
t.Fatal("the second login returned the same token")
}
expectError(t, ts.get("/_matrix/client/v3/account/whoami", first.AccessToken),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
expectStatus(t, ts.get("/_matrix/client/v3/account/whoami", second.AccessToken), http.StatusOK)
}
// TestLoginRejectionsAreIndistinguishable keeps the login endpoint from
// becoming a user directory: every failure is the same answer.
func TestLoginRejectionsAreIndistinguishable(t *testing.T) {
ts := newTestServer(t, nil)
ts.register("alice", "superuser")
for name, body := range map[string]map[string]any{
"wrong password": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "wrong_password",
},
"unknown user": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "i-dont-exist"},
"password": "superuser",
},
"malformed user ID": {
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "@not-a-user-id"},
"password": "superuser",
},
} {
t.Run(name, func(t *testing.T) {
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: body})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
})
}
}
func TestLoginRejectsForeignUser(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "@bob:other.example"},
"password": "superuser",
}})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
}
func TestLoginRejectsUnsupportedType(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.token",
"token": "whatever",
}})
expectError(t, rec, http.StatusBadRequest, "M_UNKNOWN")
}
func TestLoginRequiresAnIdentifier(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"password": "superuser",
}})
expectError(t, rec, http.StatusBadRequest, "M_MISSING_PARAM")
}
func TestLogout(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
second := ts.login(acc, "")
rec := ts.get("/_matrix/client/v3/devices", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
if devices, _ := jsonBody(t, rec)["devices"].([]any); len(devices) != 2 {
t.Fatalf("expected 2 devices before logging out, got %d", len(devices))
}
rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout", token: second.AccessToken})
expectStatus(t, rec, http.StatusOK)
// The token is dead and the device is gone, not merely signed out.
expectError(t, ts.get("/_matrix/client/v3/sync", second.AccessToken),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
rec = ts.get("/_matrix/client/v3/devices", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
devices, _ := jsonBody(t, rec)["devices"].([]any)
if len(devices) != 1 {
t.Fatalf("expected 1 device after logging out, got %d", len(devices))
}
if devices[0].(map[string]any)["device_id"] != acc.DeviceID {
t.Error("the wrong device was removed")
}
}
func TestLogoutAll(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
second := ts.login(acc, "")
rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout/all", token: second.AccessToken})
expectStatus(t, rec, http.StatusOK)
for name, token := range map[string]string{"the caller": second.AccessToken, "the other session": acc.AccessToken} {
t.Run(name, func(t *testing.T) {
expectError(t, ts.get("/_matrix/client/v3/sync", token),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
})
}
}
func TestLogoutRequiresAToken(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout"})
expectError(t, rec, http.StatusUnauthorized, "M_MISSING_TOKEN")
rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout", token: "invalidAccessToken"})
expectError(t, rec, http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
}
// TestRefreshToken covers the rotation: a refresh swaps both halves of the
// pair, and the spent ones stop working.
func TestRefreshToken(t *testing.T) {
ts := newTestServer(t, nil)
ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "superuser",
"refresh_token": true,
}})
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
oldAccess, _ := body["access_token"].(string)
refresh, _ := body["refresh_token"].(string)
if refresh == "" {
t.Fatalf("refresh_token: true produced no refresh token: %s", rec.Body.String())
}
rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/refresh",
body: map[string]any{"refresh_token": refresh}})
expectStatus(t, rec, http.StatusOK)
refreshed := jsonBody(t, rec)
newAccess, _ := refreshed["access_token"].(string)
newRefresh, _ := refreshed["refresh_token"].(string)
if newAccess == "" || newRefresh == "" || newRefresh == refresh {
t.Fatalf("refresh did not rotate the pair: %s", rec.Body.String())
}
expectStatus(t, ts.get("/_matrix/client/v3/account/whoami", newAccess), http.StatusOK)
expectError(t, ts.get("/_matrix/client/v3/account/whoami", oldAccess),
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
// Replaying a spent refresh token is a soft logout, not a second
// rotation.
rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/refresh",
body: map[string]any{"refresh_token": refresh}})
expectError(t, rec, http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
if jsonBody(t, rec)["soft_logout"] != true {
t.Error("a dead refresh token should be reported as a soft logout")
}
}
func TestRefreshRequiresAToken(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/refresh",
body: map[string]any{}})
expectError(t, rec, http.StatusBadRequest, "M_MISSING_PARAM")
}
// TestLoginWithoutRefreshTokenOmitsIt keeps the response honest for the
// clients that do not support rotation.
func TestLoginWithoutRefreshTokenOmitsIt(t *testing.T) {
ts := newTestServer(t, nil)
ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "superuser",
}})
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if _, present := body["refresh_token"]; present {
t.Error("a login that did not ask for a refresh token must not get one")
}
if _, present := body["expires_in_ms"]; present {
t.Error("a token that does not expire must not report a lifetime")
}
}
func TestLoginIsRateLimited(t *testing.T) {
ts := newTestServer(t, withRateLimit(config.RateLimit{
Enabled: true,
Default: config.RateLimitBucket{Burst: 100, Interval: time.Millisecond},
Login: config.RateLimitBucket{Burst: 2, Interval: time.Minute},
Register: config.RateLimitBucket{Burst: 100, Interval: time.Millisecond},
}))
attempt := func() int {
return ts.do(request{
method: http.MethodPost,
path: loginPath,
remote: "192.0.2.10:4321",
body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "wrong",
},
}).Code
}
for i := range 2 {
if status := attempt(); status != http.StatusForbidden {
t.Fatalf("attempt %d status = %d, want 403", i+1, status)
}
}
rec := ts.do(request{
method: http.MethodPost,
path: loginPath,
remote: "192.0.2.10:4321",
body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "wrong",
},
})
expectError(t, rec, http.StatusTooManyRequests, "M_LIMIT_EXCEEDED")
retryAfter, _ := jsonBody(t, rec)["retry_after_ms"].(float64)
if retryAfter <= 0 {
t.Errorf("retry_after_ms = %v, want a positive number", retryAfter)
}
// Another address has its own budget: one noisy client must not lock
// everybody out.
ts2 := ts.do(request{
method: http.MethodPost,
path: loginPath,
remote: "198.51.100.7:4321",
body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "wrong",
},
})
if ts2.Code == http.StatusTooManyRequests {
t.Error("the limiter is keyed globally rather than per client")
}
// And the budget is per endpoint class, so /login running dry does not
// take the rest of the API with it.
if rec := ts.get("/_matrix/client/v3/login", ""); rec.Code != http.StatusOK {
t.Errorf("GET /login status = %d; limiters must be per endpoint", rec.Code)
}
}
// TestAccessTokenInQueryString covers the form older clients and media URLs
// still use.
func TestAccessTokenInQueryString(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.get("/_matrix/client/v3/account/whoami?access_token="+acc.AccessToken, "")
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["user_id"] != acc.UserID {
t.Errorf("whoami = %s", rec.Body.String())
}
}
func TestMalformedAuthorizationHeaderIsRejected(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
req := request{method: http.MethodGet, path: "/_matrix/client/v3/account/whoami"}
for name, header := range map[string]string{
"no scheme": acc.AccessToken,
"wrong scheme": "Basic " + acc.AccessToken,
"empty bearer": "Bearer ",
} {
t.Run(name, func(t *testing.T) {
httpReq := requestWithRawAuth(t, req, header)
rec := serve(ts, httpReq)
expectError(t, rec, http.StatusUnauthorized, "M_MISSING_TOKEN")
})
}
// The scheme itself is case-insensitive, per RFC 9110.
rec := serve(ts, requestWithRawAuth(t, req, "bearer "+acc.AccessToken))
expectStatus(t, rec, http.StatusOK)
}
func TestWhoami(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.get("/_matrix/client/v3/account/whoami", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if body["user_id"] != acc.UserID || body["device_id"] != acc.DeviceID {
t.Errorf("whoami = %s", rec.Body.String())
}
if body["is_guest"] != false {
t.Errorf("is_guest = %v, want false", body["is_guest"])
}
}
func TestCapabilities(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
// The endpoint is authenticated: what a server lets you do depends on
// who you are.
expectError(t, ts.get("/_matrix/client/v3/capabilities", ""),
http.StatusUnauthorized, "M_MISSING_TOKEN")
rec := ts.get("/_matrix/client/v3/capabilities", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
caps, _ := jsonBody(t, rec)["capabilities"].(map[string]any)
for _, key := range []string{"m.room_versions", "m.change_password", "m.set_displayname"} {
if _, present := caps[key]; !present {
t.Errorf("capabilities is missing %s: %s", key, rec.Body.String())
}
}
versions, _ := caps["m.room_versions"].(map[string]any)
if versions["default"] != clientapi.DefaultRoomVersion {
t.Errorf("default room version = %v, want %s", versions["default"], clientapi.DefaultRoomVersion)
}
if available, _ := versions["available"].(map[string]any); len(available) == 0 {
t.Error("m.room_versions.available must list at least the default")
}
}
// TestSyncPlaceholder documents what the Phase 1 /sync does: it answers a
// valid token with an empty, well-formed response, and refuses a revoked
// one. Phase 4 replaces it with the real syncapi.
func TestSyncPlaceholder(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.get("/_matrix/client/v3/sync", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if next, _ := body["next_batch"].(string); next == "" {
t.Error("/sync must return a next_batch token")
}
rooms, _ := body["rooms"].(map[string]any)
for _, key := range []string{"join", "invite", "leave"} {
if _, present := rooms[key]; !present {
t.Errorf("rooms is missing %s: %s", key, rec.Body.String())
}
}
}
func TestUnknownClientEndpoint(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.get("/_matrix/client/v3/rooms/!a:b/messages", "")
expectError(t, rec, http.StatusNotFound, "M_UNRECOGNIZED")
}
// serve runs a prepared request through the handler.
func serve(ts *testServer, req *http.Request) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
ts.handler.ServeHTTP(rec, req)
return rec
}
// requestWithRawAuth builds a request carrying an Authorization header
// verbatim, so the parsing of the header itself can be tested.
func requestWithRawAuth(t *testing.T, req request, header string) *http.Request {
t.Helper()
httpReq := httptest.NewRequest(req.method, req.path, nil)
httpReq.Header.Set("Authorization", header)
return httpReq
}
+157
View File
@@ -0,0 +1,157 @@
package clientapi
import (
"errors"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// profileResponse is the body of GET /profile/{userId}. Both fields are
// omitted when unset: the spec distinguishes "no display name" from an empty
// one, and clients fall back to the localpart for the former.
type profileResponse struct {
DisplayName string `json:"displayname,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// MaxDisplayNameLength and MaxAvatarURLLength bound the profile fields.
// The spec sets no limit; these are what keeps a member list from carrying a
// megabyte of display name per user once rooms exist.
const (
MaxDisplayNameLength = 256
MaxAvatarURLLength = 1024
)
// handleGetProfile implements GET /_matrix/client/v3/profile/{userId}.
func (c *ClientAPI) handleGetProfile(w http.ResponseWriter, r *http.Request) error {
account, err := c.lookupProfile(r)
if err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, profileResponse{
DisplayName: account.DisplayName,
AvatarURL: account.AvatarURL,
})
return nil
}
// handleGetDisplayName implements GET /profile/{userId}/displayname.
func (c *ClientAPI) handleGetDisplayName(w http.ResponseWriter, r *http.Request) error {
account, err := c.lookupProfile(r)
if err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, profileResponse{DisplayName: account.DisplayName})
return nil
}
// handleGetAvatarURL implements GET /profile/{userId}/avatar_url.
func (c *ClientAPI) handleGetAvatarURL(w http.ResponseWriter, r *http.Request) error {
account, err := c.lookupProfile(r)
if err != nil {
return err
}
httputil.WriteJSON(w, http.StatusOK, profileResponse{AvatarURL: account.AvatarURL})
return nil
}
// lookupProfile resolves the user ID in the path to a local account.
//
// A user ID on another server is where the federation switch shows up in the
// Client-Server API: with federation off there is no way to answer and the
// spec's M_FORBIDDEN is the honest reply, as PLAN.md section 3 requires.
// With federation on, the lookup itself is Phase 5 work.
func (c *ClientAPI) lookupProfile(r *http.Request) (storage.Account, error) {
userID := r.PathValue("userID")
localpart, err := c.users.ParseLocalUserID(userID)
switch {
case errors.Is(err, userapi.ErrForeignServer):
if !c.federation.Enabled() {
return storage.Account{}, federation.RejectClientRequest(
"looking up the profile of a user on another server")
}
return storage.Account{}, httputil.NotFound(
"Profiles of users on other servers cannot be fetched yet")
case err != nil:
return storage.Account{}, httputil.InvalidParam("%s is not a valid user ID", userID)
}
account, err := c.users.Account(r.Context(), localpart)
switch {
case errors.Is(err, userapi.ErrNoSuchUser):
return storage.Account{}, httputil.NotFound("Profile not found")
case err != nil:
return storage.Account{}, err
}
return account, nil
}
// putDisplayNameRequest is the body of PUT /profile/{userId}/displayname.
type putDisplayNameRequest struct {
DisplayName string `json:"displayname"`
}
// putAvatarURLRequest is the body of PUT /profile/{userId}/avatar_url.
type putAvatarURLRequest struct {
AvatarURL string `json:"avatar_url"`
}
// handlePutDisplayName implements PUT /profile/{userId}/displayname.
func (c *ClientAPI) handlePutDisplayName(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
localpart, err := c.requireSelf(auth, r.PathValue("userID"))
if err != nil {
return err
}
var req putDisplayNameRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if len(req.DisplayName) > MaxDisplayNameLength {
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeTooLarge,
"Display names must be at most %d bytes", MaxDisplayNameLength)
}
if err := c.users.SetDisplayName(r.Context(), localpart, req.DisplayName); err != nil {
return profileWriteError(err)
}
// Once rooms exist this also has to rewrite the m.room.member event in
// every room the user is in; that belongs to the roomserver in Phase 3.
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// handlePutAvatarURL implements PUT /profile/{userId}/avatar_url.
func (c *ClientAPI) handlePutAvatarURL(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
localpart, err := c.requireSelf(auth, r.PathValue("userID"))
if err != nil {
return err
}
var req putAvatarURLRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if len(req.AvatarURL) > MaxAvatarURLLength {
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeTooLarge,
"Avatar URLs must be at most %d bytes", MaxAvatarURLLength)
}
if err := c.users.SetAvatarURL(r.Context(), localpart, req.AvatarURL); err != nil {
return profileWriteError(err)
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// profileWriteError maps a failed profile update. An admin may write to
// another user's profile, so "no such user" is reachable here.
func profileWriteError(err error) error {
if errors.Is(err, userapi.ErrNoSuchUser) {
return httputil.NotFound("Profile not found")
}
return err
}
+147
View File
@@ -0,0 +1,147 @@
package clientapi_test
import (
"net/http"
"strings"
"testing"
)
const profilePath = "/_matrix/client/v3/profile"
func TestProfileRoundTrip(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
// A fresh account has neither field, and the spec distinguishes that
// from an empty one: clients fall back to the localpart.
rec := ts.get(profilePath+"/"+acc.UserID, "")
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if _, present := body["displayname"]; present {
t.Errorf("an unset display name must be omitted, got %s", rec.Body.String())
}
rec = ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/displayname",
token: acc.AccessToken, body: map[string]any{"displayname": "Alice"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/avatar_url",
token: acc.AccessToken, body: map[string]any{"avatar_url": "mxc://warp.example/abc"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(profilePath+"/"+acc.UserID, "")
expectStatus(t, rec, http.StatusOK)
body = jsonBody(t, rec)
if body["displayname"] != "Alice" || body["avatar_url"] != "mxc://warp.example/abc" {
t.Errorf("profile = %s", rec.Body.String())
}
// The per-field endpoints return only their own field.
rec = ts.get(profilePath+"/"+acc.UserID+"/displayname", "")
expectStatus(t, rec, http.StatusOK)
body = jsonBody(t, rec)
if body["displayname"] != "Alice" {
t.Errorf("displayname = %v", body["displayname"])
}
if _, present := body["avatar_url"]; present {
t.Error("/displayname must not include the avatar")
}
rec = ts.get(profilePath+"/"+acc.UserID+"/avatar_url", "")
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["avatar_url"] != "mxc://warp.example/abc" {
t.Errorf("avatar_url = %s", rec.Body.String())
}
}
// TestProfileReadsArePublic covers the rule a member list depends on: a
// client showing somebody's name has no token for them.
func TestProfileReadsArePublic(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/displayname",
token: acc.AccessToken, body: map[string]any{"displayname": "my_display_name"}})
expectStatus(t, rec, http.StatusOK)
for _, path := range []string{
profilePath + "/" + acc.UserID,
profilePath + "/" + acc.UserID + "/displayname",
} {
rec := ts.get(path, "")
expectStatus(t, rec, http.StatusOK)
if name := jsonBody(t, rec)["displayname"]; name != "my_display_name" {
t.Errorf("GET %s without a token returned %v", path, name)
}
}
}
func TestProfileWritesAreOwnOnly(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "superuser")
bob := ts.register("bob", "bobspassword")
rec := ts.do(request{method: http.MethodPut, path: profilePath + "/" + alice.UserID + "/displayname",
token: bob.AccessToken, body: map[string]any{"displayname": "not bob"}})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
// An admin may, which is what lets the panel clean up a profile.
ts.makeAdmin(bob)
rec = ts.do(request{method: http.MethodPut, path: profilePath + "/" + alice.UserID + "/displayname",
token: bob.AccessToken, body: map[string]any{"displayname": "set by an admin"}})
expectStatus(t, rec, http.StatusOK)
}
func TestProfileWritesRequireAToken(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/displayname",
body: map[string]any{"displayname": "Alice"}})
expectError(t, rec, http.StatusUnauthorized, "M_MISSING_TOKEN")
}
func TestProfileOfUnknownUserIs404(t *testing.T) {
ts := newTestServer(t, nil)
expectError(t, ts.get(profilePath+"/@nobody:warp.example", ""),
http.StatusNotFound, "M_NOT_FOUND")
}
func TestProfileRejectsMalformedUserID(t *testing.T) {
ts := newTestServer(t, nil)
expectError(t, ts.get(profilePath+"/@no-server-name", ""),
http.StatusBadRequest, "M_INVALID_PARAM")
}
// TestProfileOfRemoteUserHonoursTheFederationSwitch is where PLAN.md
// section 3 shows up in the Client-Server API: with federation off there is
// no way to answer, and the spec's M_FORBIDDEN with a clear message is the
// honest reply.
func TestProfileOfRemoteUserHonoursTheFederationSwitch(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.get(profilePath+"/@bob:other.example", "")
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
if msg, _ := jsonBody(t, rec)["error"].(string); !strings.Contains(msg, "Federation is disabled") {
t.Errorf("the message should say why, got %q", msg)
}
// With the switch on it is no longer a refusal, only something that is
// not implemented until Phase 5.
if err := ts.settings.SetFederationEnabled(t.Context(), true); err != nil {
t.Fatalf("SetFederationEnabled: %v", err)
}
expectError(t, ts.get(profilePath+"/@bob:other.example", ""),
http.StatusNotFound, "M_NOT_FOUND")
}
func TestProfileRejectsOverlongDisplayName(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/displayname",
token: acc.AccessToken, body: map[string]any{"displayname": strings.Repeat("x", 1000)}})
expectError(t, rec, http.StatusBadRequest, "M_TOO_LARGE")
}
+254
View File
@@ -0,0 +1,254 @@
package clientapi
import (
"crypto/rand"
"errors"
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// registerRequest is the body of POST /register.
type registerRequest struct {
Username string `json:"username"`
Password string `json:"password"`
DeviceID string `json:"device_id"`
InitialDeviceDisplayName string `json:"initial_device_display_name"`
// InhibitLogin asks for the account without a session, for a client
// registering on someone else's behalf.
InhibitLogin bool `json:"inhibit_login"`
RefreshToken bool `json:"refresh_token"`
Auth *uiaRequest `json:"auth"`
}
// registerResponse is the body of a successful POST /register.
type registerResponse struct {
UserID string `json:"user_id"`
// The session fields are absent when inhibit_login was set.
AccessToken string `json:"access_token,omitempty"`
DeviceID string `json:"device_id,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresInMS int64 `json:"expires_in_ms,omitempty"`
// HomeServer is deprecated and still read by older clients, which
// treat its absence as a malformed response.
HomeServer string `json:"home_server,omitempty"`
}
// handleRegister implements POST /_matrix/client/v3/register.
//
// The order of the checks matters and is not the obvious one. The spec has
// the registration flow start with a 401 listing the flows, so an empty
// body must get the challenge rather than a complaint about the missing
// password — but a username that is unusable or already taken has to be
// rejected *before* the challenge, because a client that has to complete a
// flow only to be told the name was taken cannot retry without starting
// over. Complement checks both halves of that.
func (c *ClientAPI) handleRegister(w http.ResponseWriter, r *http.Request) error {
var req registerRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
// Guest registration needs the room-level restrictions that come with
// the roomserver, so it is refused rather than half-implemented.
if kind := r.URL.Query().Get("kind"); kind != "" && kind != "user" {
if kind == "guest" {
return httputil.Forbidden("Guest access is not supported by this server")
}
return httputil.InvalidParam("Unknown registration kind %q", kind)
}
if !c.settings.RegistrationEnabled() {
return httputil.Forbidden("Registration is disabled on this server")
}
localpart := userapi.NormaliseLocalpart(req.Username)
if localpart != "" {
if err := userapi.ValidateLocalpart(localpart, c.serverName); err != nil {
return httputil.InvalidUsername("%s", err.Error())
}
taken, err := c.users.AccountExists(r.Context(), localpart)
if err != nil {
return err
}
if taken {
return httputil.UserInUse()
}
}
flows := c.registrationFlows()
sessionID, done, err := c.beginOrAdvanceUIA(w, flows, req.Auth, c.registrationStage)
if err != nil || !done {
return err
}
// Only now that the client has authenticated is the rest of the body
// worth validating.
if req.Password == "" {
return httputil.MissingParam("A password is required to register an account")
}
if len(req.Password) > userapi.MaxPasswordLength {
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeWeakPassword,
"Password must be at most %d bytes", userapi.MaxPasswordLength)
}
if req.DeviceID != "" {
if err := userapi.ValidateDeviceID(req.DeviceID); err != nil {
return httputil.InvalidParam("%s", err.Error())
}
}
if localpart == "" {
// The spec lets a client leave the name to the server.
if localpart, err = generateLocalpart(); err != nil {
return err
}
}
if _, err := c.users.CreateAccount(r.Context(), userapi.NewAccountRequest{
Localpart: localpart,
Password: req.Password,
}); err != nil {
switch {
case errors.Is(err, userapi.ErrUserExists):
// Lost the race against another registration of the same name
// between the check above and the insert.
return httputil.UserInUse()
case errors.Is(err, userapi.ErrInvalidLocalpart):
return httputil.InvalidUsername("%s", err.Error())
default:
return err
}
}
// The flow has been acted on; a replay of the same request must not
// create a second account.
c.uia.remove(sessionID)
resp := registerResponse{
UserID: c.users.UserID(localpart),
HomeServer: c.serverName,
}
if req.InhibitLogin {
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}
session, err := c.users.CreateSession(r.Context(), userapi.SessionRequest{
Localpart: localpart,
DeviceID: req.DeviceID,
DisplayName: req.InitialDeviceDisplayName,
WithRefreshToken: req.RefreshToken,
IP: c.clientIP(r),
UserAgent: r.UserAgent(),
})
if err != nil {
return err
}
resp.AccessToken = session.AccessToken
resp.DeviceID = session.DeviceID
resp.RefreshToken = session.RefreshToken
resp.ExpiresInMS = session.ExpiresInMS
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}
// registrationFlows is what a client has to complete to register.
//
// m.login.dummy is a stage that is satisfied by being attempted. It exists
// so that the flow is never empty: the spec's registration is always
// interactive, and a server with no real requirements still has to offer
// something for the client to complete.
func (c *ClientAPI) registrationFlows() []uiaFlow {
if c.registration.RequiresToken() {
return []uiaFlow{{Stages: []string{stageRegistrationToken, stageDummy}}}
}
return []uiaFlow{{Stages: []string{stageDummy}}}
}
// registrationStage checks one stage of the registration flow.
func (c *ClientAPI) registrationStage(stage string, auth *uiaRequest) (*httputil.MatrixError, error) {
switch stage {
case stageDummy:
return nil, nil
case stageRegistrationToken:
if auth.Token == "" {
return httputil.MissingParam("A registration token is required"), nil
}
if !c.registration.TokenValid(auth.Token) {
return httputil.Forbidden("Invalid registration token"), nil
}
return nil, nil
default:
// beginOrAdvanceUIA only calls this for stages it offered, so
// reaching here means the flow list and this switch disagree.
return nil, httputil.InternalError()
}
}
// handleRegisterAvailable implements GET /register/available, the live check
// a client runs while the user is typing a name.
func (c *ClientAPI) handleRegisterAvailable(w http.ResponseWriter, r *http.Request) error {
username := r.URL.Query().Get("username")
if username == "" {
return httputil.MissingParam("The username query parameter is required")
}
localpart := userapi.NormaliseLocalpart(username)
if err := userapi.ValidateLocalpart(localpart, c.serverName); err != nil {
return httputil.InvalidUsername("%s", err.Error())
}
taken, err := c.users.AccountExists(r.Context(), localpart)
if err != nil {
return err
}
if taken {
return httputil.UserInUse()
}
httputil.WriteJSON(w, http.StatusOK, map[string]bool{"available": true})
return nil
}
// handleRegistrationTokenValidity implements
// GET /_matrix/client/v1/register/m.login.registration_token/validity, which
// lets a client tell the user their token is wrong before they fill in the
// rest of the form.
func (c *ClientAPI) handleRegistrationTokenValidity(w http.ResponseWriter, r *http.Request) error {
token := r.URL.Query().Get("token")
if token == "" {
return httputil.MissingParam("The token query parameter is required")
}
httputil.WriteJSON(w, http.StatusOK, map[string]bool{"valid": c.registration.TokenValid(token)})
return nil
}
// generatedLocalpartAlphabet is the character set for a server-chosen user
// name: lowercase letters and digits, which are valid everywhere in a
// localpart and unambiguous when read out.
const generatedLocalpartAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
// generatedLocalpartLength gives roughly 62 bits of entropy, enough that a
// server-generated name will not collide with an existing one in practice.
const generatedLocalpartLength = 12
// generateLocalpart invents a user name for a registration that did not ask
// for one.
func generateLocalpart() (string, error) {
raw := make([]byte, generatedLocalpartLength)
if _, err := rand.Read(raw); err != nil {
return "", err
}
out := make([]byte, generatedLocalpartLength)
for i, b := range raw {
out[i] = generatedLocalpartAlphabet[int(b)%len(generatedLocalpartAlphabet)]
}
return string(out), nil
}
+391
View File
@@ -0,0 +1,391 @@
package clientapi_test
import (
"net/http"
"strings"
"testing"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/config"
)
const registerPath = "/_matrix/client/v3/register"
// TestRegisterReturnsFlows covers the first half of the registration
// handshake: an empty body gets the flows and a session, not a complaint
// about the missing password.
func TestRegisterReturnsFlows(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{}})
expectStatus(t, rec, http.StatusUnauthorized)
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
body := jsonBody(t, rec)
if _, ok := body["session"].(string); !ok {
t.Error("the challenge must carry a session")
}
if _, ok := body["params"].(map[string]any); !ok {
t.Error("the challenge must carry a params object, even an empty one")
}
flows, ok := body["flows"].([]any)
if !ok || len(flows) == 0 {
t.Fatalf("flows = %v, want a non-empty array", body["flows"])
}
for _, flow := range flows {
stages, ok := flow.(map[string]any)["stages"].([]any)
if !ok || len(stages) == 0 {
t.Fatalf("every flow needs a non-empty stages array, got %v", flow)
}
if stages[0] != "m.login.dummy" {
t.Errorf("first stage = %v, want m.login.dummy", stages[0])
}
}
}
func TestRegisterCreatesAccount(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "sUp3rs3kr1t")
if acc.UserID != "@alice:warp.example" {
t.Errorf("user_id = %q, want @alice:warp.example", acc.UserID)
}
// The token works straight away: a registration that needs a separate
// login is a registration that half worked.
rec := ts.get("/_matrix/client/v3/account/whoami", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got := jsonBody(t, rec)["user_id"]; got != acc.UserID {
t.Errorf("whoami = %v, want %s", got, acc.UserID)
}
}
// TestRegisterDowncasesUsername pins the behaviour clients rely on: a
// localpart is lowercase, so a capital in the request is normalised rather
// than rejected.
func TestRegisterDowncasesUsername(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("user-UPPER", "sUp3rs3kr1t")
if acc.UserID != "@user-upper:warp.example" {
t.Errorf("user_id = %q, want @user-upper:warp.example", acc.UserID)
}
}
func TestRegisterHonoursRequestedDeviceID(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"device_id": "my_device_id",
"initial_device_display_name": "a phone",
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}})
expectStatus(t, rec, http.StatusOK)
if got := jsonBody(t, rec)["device_id"]; got != "my_device_id" {
t.Errorf("device_id = %v, want my_device_id", got)
}
}
// TestRegisterRejectsBadUsernamesBeforeUIA is the half of the ordering that
// is easy to get wrong: a username that can never work is rejected with a
// 400 straight away, rather than after the client has completed a flow.
func TestRegisterRejectsBadUsernamesBeforeUIA(t *testing.T) {
ts := newTestServer(t, nil)
for _, ch := range []string{"!", `"`, ":", "?", `\`, "@", "[", "]", "{", "|", "}", "£", "é", "\n", "'", ","} {
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "user-" + ch + "-reject", "password": "sUp3rs3kr1t"}})
expectError(t, rec, http.StatusBadRequest, "M_INVALID_USERNAME")
}
// The characters the spec does allow must still get through.
for _, ch := range []string{"q", "3", ".", "_", "=", "-", "/", "+"} {
ts.register("chrtest"+ch, "sUp3rs3kr1t")
}
}
func TestRegisterRejectsOverlongUsername(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": strings.Repeat("a", 256),
"password": "sUp3rs3kr1t",
}})
expectError(t, rec, http.StatusBadRequest, "M_INVALID_USERNAME")
}
// TestRegisterRejectsTakenUsernameBeforeUIA is the other half: the name is
// checked before the challenge, so a client is not asked to authenticate
// only to be told the name was gone all along.
func TestRegisterRejectsTakenUsernameBeforeUIA(t *testing.T) {
ts := newTestServer(t, nil)
ts.register("alice", "sUp3rs3kr1t")
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
expectError(t, rec, http.StatusBadRequest, "M_USER_IN_USE")
// Capitalisation does not get around it, because the name is
// normalised first.
rec = ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "ALICE", "password": "sUp3rs3kr1t"}})
expectError(t, rec, http.StatusBadRequest, "M_USER_IN_USE")
}
// TestRegisterRequiresTheSession covers the rule that makes the flow a flow:
// an `auth` without the session the server handed out starts again rather
// than completing.
func TestRegisterRequiresTheSession(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.dummy"},
}})
expectStatus(t, rec, http.StatusUnauthorized)
// An unknown session is treated the same way, with a fresh one offered.
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.dummy", "session": "not-a-session"},
}})
expectStatus(t, rec, http.StatusUnauthorized)
if _, ok := jsonBody(t, rec)["session"].(string); !ok {
t.Error("a rejected session must come with a new one to try")
}
}
func TestRegisterRejectsUnknownStage(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.recaptcha", "session": session},
}})
expectError(t, rec, http.StatusBadRequest, "M_UNRECOGNIZED")
}
func TestRegisterRequiresPasswordAfterUIA(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice"}})
expectStatus(t, rec, http.StatusUnauthorized)
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}})
expectError(t, rec, http.StatusBadRequest, "M_MISSING_PARAM")
}
func TestRegisterInhibitLogin(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"inhibit_login": true,
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}})
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if body["user_id"] != "@alice:warp.example" {
t.Errorf("user_id = %v", body["user_id"])
}
for _, field := range []string{"access_token", "device_id"} {
if _, present := body[field]; present {
t.Errorf("inhibit_login must suppress %s", field)
}
}
}
// TestRegisterGeneratesUsername covers the case where the client leaves the
// choice of name to the server.
func TestRegisterGeneratesUsername(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"password": "sUp3rs3kr1t"}})
session := jsonBody(t, rec)["session"]
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}})
expectStatus(t, rec, http.StatusOK)
userID, _ := jsonBody(t, rec)["user_id"].(string)
if !strings.HasPrefix(userID, "@") || !strings.HasSuffix(userID, ":warp.example") {
t.Errorf("generated user_id = %q, want @<something>:warp.example", userID)
}
if len(userID) <= len("@:warp.example") {
t.Errorf("generated user_id %q has an empty localpart", userID)
}
}
func TestRegisterSessionIsSpentOnce(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
session := jsonBody(t, rec)["session"]
complete := map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}
expectStatus(t, ts.do(request{method: http.MethodPost, path: registerPath, body: complete}), http.StatusOK)
// Replaying the identical request must not create a second account; the
// name is taken now, and the session is gone either way.
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: complete})
expectError(t, rec, http.StatusBadRequest, "M_USER_IN_USE")
}
func TestRegisterRespectsTheRegistrationSwitch(t *testing.T) {
ts := newTestServer(t, nil)
if err := ts.settings.SetRegistrationEnabled(t.Context(), false); err != nil {
t.Fatalf("SetRegistrationEnabled: %v", err)
}
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
// And comes back without a restart, which is the point of the switch.
if err := ts.settings.SetRegistrationEnabled(t.Context(), true); err != nil {
t.Fatalf("SetRegistrationEnabled: %v", err)
}
ts.register("alice", "sUp3rs3kr1t")
}
func TestRegisterRejectsGuests(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.do(request{method: http.MethodPost, path: registerPath + "?kind=guest",
body: map[string]any{}})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
}
// TestRegisterWithRegistrationToken covers the second stage a server with
// invite-only sign-up adds to the flow.
func TestRegisterWithRegistrationToken(t *testing.T) {
ts := newTestServer(t, func(o *clientapi.Options) {
o.Registration = config.Registration{Enabled: true, Tokens: []string{"let-me-in"}}
})
rec := ts.do(request{method: http.MethodPost, path: registerPath,
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
expectStatus(t, rec, http.StatusUnauthorized)
body := jsonBody(t, rec)
flows, _ := body["flows"].([]any)
stages, _ := flows[0].(map[string]any)["stages"].([]any)
if len(stages) != 2 || stages[0] != "m.login.registration_token" {
t.Fatalf("stages = %v, want the token stage first", stages)
}
session := body["session"]
// A wrong token fails the stage: the client gets the error and the
// flows together, so it can prompt again without starting over.
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{
"type": "m.login.registration_token", "session": session, "token": "nope",
},
}})
expectStatus(t, rec, http.StatusUnauthorized)
body = jsonBody(t, rec)
if body["errcode"] != "M_FORBIDDEN" {
t.Errorf("a failed stage must say why, got %s", rec.Body.String())
}
if body["session"] != session {
t.Error("a failed stage must keep the session alive")
}
// The right one advances the flow but does not finish it.
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{
"type": "m.login.registration_token", "session": session, "token": "let-me-in",
},
}})
expectStatus(t, rec, http.StatusUnauthorized)
completed, _ := jsonBody(t, rec)["completed"].([]any)
if len(completed) != 1 || completed[0] != "m.login.registration_token" {
t.Fatalf("completed = %v, want the token stage", completed)
}
// And the dummy stage completes it.
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}})
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["user_id"] != "@alice:warp.example" {
t.Errorf("user_id = %v", jsonBody(t, rec)["user_id"])
}
}
func TestRegisterAvailable(t *testing.T) {
ts := newTestServer(t, nil)
const path = "/_matrix/client/v3/register/available"
rec := ts.get(path+"?username=free_name", "")
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["available"] != true {
t.Errorf("available = %v, want true", jsonBody(t, rec)["available"])
}
ts.register("taken", "sUp3rs3kr1t")
expectError(t, ts.get(path+"?username=taken", ""), http.StatusBadRequest, "M_USER_IN_USE")
expectError(t, ts.get(path+"?username=not,valid", ""), http.StatusBadRequest, "M_INVALID_USERNAME")
expectError(t, ts.get(path, ""), http.StatusBadRequest, "M_MISSING_PARAM")
}
func TestRegistrationTokenValidity(t *testing.T) {
ts := newTestServer(t, func(o *clientapi.Options) {
o.Registration = config.Registration{Enabled: true, Tokens: []string{"let-me-in"}}
})
const path = "/_matrix/client/v1/register/m.login.registration_token/validity"
rec := ts.get(path+"?token=let-me-in", "")
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["valid"] != true {
t.Errorf("valid = %v, want true", jsonBody(t, rec)["valid"])
}
rec = ts.get(path+"?token=nope", "")
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["valid"] != false {
t.Errorf("valid = %v, want false", jsonBody(t, rec)["valid"])
}
expectError(t, ts.get(path, ""), http.StatusBadRequest, "M_MISSING_PARAM")
}
+73
View File
@@ -0,0 +1,73 @@
package clientapi
import (
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// syncResponse is the subset of the /sync body that is meaningful before
// there are any rooms.
type syncResponse struct {
NextBatch string `json:"next_batch"`
Rooms syncRooms `json:"rooms"`
Presence syncEventList `json:"presence"`
AccountData syncEventList `json:"account_data"`
ToDevice syncEventList `json:"to_device"`
DeviceLists syncDeviceLists `json:"device_lists"`
}
type syncRooms struct {
Join map[string]any `json:"join"`
Invite map[string]any `json:"invite"`
Leave map[string]any `json:"leave"`
Knock map[string]any `json:"knock"`
}
type syncEventList struct {
Events []any `json:"events"`
}
type syncDeviceLists struct {
Changed []string `json:"changed"`
Left []string `json:"left"`
}
// syncPlaceholderToken is the only stream position Phase 1 can be at.
//
// It is a real token shape rather than an empty string so that a client
// storing it and sending it back as ?since= gets the same answer, which is
// the correct one: nothing has happened.
const syncPlaceholderToken = "s0_0"
// handleSync implements GET /_matrix/client/v3/sync as far as Phase 1 can.
//
// This is a placeholder, and deliberately a working one. There are no rooms
// until Phase 3 and no event streams until Phase 4, so an empty sync is not
// a lie — it is the complete state of this server. What it buys in the
// meantime is the thing every client and the test suite check first: that a
// valid token syncs and a revoked one does not. Without it, "logging out
// invalidates the session" cannot be observed at all.
//
// Phase 4 replaces this wholesale with the syncapi: stream tokens, long
// polling, filters and lazy-loaded members.
func (c *ClientAPI) handleSync(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
// The `timeout` parameter is ignored rather than honoured: blocking for
// 30 seconds to return a response that cannot change would hold a
// connection open for nothing.
httputil.WriteJSON(w, http.StatusOK, syncResponse{
NextBatch: syncPlaceholderToken,
Rooms: syncRooms{
Join: map[string]any{},
Invite: map[string]any{},
Leave: map[string]any{},
Knock: map[string]any{},
},
Presence: syncEventList{Events: []any{}},
AccountData: syncEventList{Events: []any{}},
ToDevice: syncEventList{Events: []any{}},
DeviceLists: syncDeviceLists{Changed: []string{}, Left: []string{}},
})
return nil
}
+295
View File
@@ -0,0 +1,295 @@
package clientapi
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"slices"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
// User-Interactive Authentication stage types. The spec's full list is long;
// these are the ones Warp implements. CAPTCHA, email and terms stages belong
// to deployments with public sign-up and arrive with the rest of that story.
const (
stageDummy = "m.login.dummy"
stagePassword = "m.login.password"
stageRegistrationToken = "m.login.registration_token"
)
// uiaFlow is one route through the stages. A client may satisfy any flow.
type uiaFlow struct {
Stages []string `json:"stages"`
}
// uiaChallenge is the 401 body of an incomplete User-Interactive
// Authentication: the flows on offer, the session to quote them against, and
// what has been done so far.
//
// When a stage has just been failed it also carries an error envelope, which
// is why errcode and error are part of this type rather than sent as a
// separate Matrix error: the spec puts both in one 401 body so the client
// can show the failure and retry the stage in one step.
type uiaChallenge struct {
Flows []uiaFlow `json:"flows"`
Params map[string]any `json:"params"`
Session string `json:"session"`
Completed []string `json:"completed,omitempty"`
ErrCode string `json:"errcode,omitempty"`
Error string `json:"error,omitempty"`
}
// uiaSessionTTL is how long an unfinished authentication may sit idle.
// Clients complete a flow in seconds; the window only has to be long enough
// for a person to read a prompt and type a password.
const uiaSessionTTL = 15 * time.Minute
// uiaSession tracks one client's progress through a flow set.
type uiaSession struct {
id string
completed []string
expiresAt time.Time
}
// uiaStore holds the in-flight authentication sessions.
//
// Sessions live in memory. They are worthless after a few minutes, they are
// only ever read by the process that wrote them (Warp is one process by
// design), and a restart costs a client exactly one extra round trip —
// whereas a table would need its own schema, its own sweeper and a write on
// every stage.
type uiaStore struct {
ttl time.Duration
now func() time.Time
mu sync.Mutex
sessions map[string]*uiaSession
sweptAt time.Time
}
func newUIAStore(ttl time.Duration, now func() time.Time) *uiaStore {
if now == nil {
now = time.Now
}
return &uiaStore{ttl: ttl, now: now, sessions: map[string]*uiaSession{}}
}
// create starts a session.
func (s *uiaStore) create() (*uiaSession, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return nil, fmt.Errorf("generate auth session: %w", err)
}
session := &uiaSession{
id: base64.RawURLEncoding.EncodeToString(raw),
expiresAt: s.now().Add(s.ttl),
}
s.mu.Lock()
defer s.mu.Unlock()
s.sweep()
s.sessions[session.id] = session
return session, nil
}
// get returns a live session, or nil if it is unknown or expired.
func (s *uiaStore) get(id string) *uiaSession {
if id == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok {
return nil
}
if s.now().After(session.expiresAt) {
delete(s.sessions, id)
return nil
}
// Each copy handed out is independent, so a caller cannot mutate the
// stored progress by accident.
return &uiaSession{id: session.id, completed: slices.Clone(session.completed), expiresAt: session.expiresAt}
}
// complete records a finished stage and returns the session's new progress.
func (s *uiaStore) complete(id, stage string) []string {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok {
return nil
}
if !slices.Contains(session.completed, stage) {
session.completed = append(session.completed, stage)
}
// Finishing a stage is progress, so the window starts again: a flow
// with several stages must not time out halfway through.
session.expiresAt = s.now().Add(s.ttl)
return slices.Clone(session.completed)
}
// remove forgets a session, which happens once its flow has been acted on.
// Leaving it behind would let a replayed request create a second account or
// delete a second device.
func (s *uiaStore) remove(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, id)
}
// sweep drops expired sessions. It runs under the caller's lock, at most
// once per TTL.
func (s *uiaStore) sweep() {
now := s.now()
if now.Sub(s.sweptAt) < s.ttl {
return
}
s.sweptAt = now
for id, session := range s.sessions {
if now.After(session.expiresAt) {
delete(s.sessions, id)
}
}
}
// uiaRequest is the `auth` dictionary a client sends. The stage-specific
// fields are flattened into it rather than nested, which is how the spec
// defines each stage's payload.
type uiaRequest struct {
Type string `json:"type"`
Session string `json:"session"`
// Token is the m.login.registration_token payload.
Token string `json:"token"`
// Identifier and Password are the m.login.password payload.
Identifier loginIdentifier `json:"identifier"`
Password string `json:"password"`
// User is the deprecated top-level form of Identifier, still sent by
// older clients.
User string `json:"user"`
}
// flowSatisfied reports whether completed covers every stage of any flow.
func flowSatisfied(flows []uiaFlow, completed []string) bool {
for _, flow := range flows {
done := true
for _, stage := range flow.Stages {
if !slices.Contains(completed, stage) {
done = false
break
}
}
if done {
return true
}
}
return false
}
// challenge writes a 401 asking the client to complete a stage.
//
// cause is optional: when a stage was attempted and failed, the client gets
// the failure and the flows in one body, which is what lets it re-prompt
// without starting over.
func (c *ClientAPI) challenge(w http.ResponseWriter, flows []uiaFlow, session *uiaSession, cause *httputil.MatrixError) {
body := uiaChallenge{
Flows: flows,
// Always an object, never null: clients and Complement both expect
// `params` to be present even when no stage needs parameters.
Params: map[string]any{},
Session: session.id,
Completed: session.completed,
}
if cause != nil {
body.ErrCode, body.Error = cause.Code, cause.Message
}
httputil.WriteJSON(w, http.StatusUnauthorized, body)
}
// stageHandler checks one stage of a flow.
//
// The two return values are different kinds of "no": a *MatrixError is a
// stage the client may retry, and is sent back inside the 401 alongside the
// flows; a plain error ends the request. Wrong password is the first kind,
// authenticating as somebody else is the second.
type stageHandler func(stage string, auth *uiaRequest) (*httputil.MatrixError, error)
// beginOrAdvanceUIA drives one request through a flow set.
//
// It returns done=true when the client has satisfied a flow and the caller
// may go ahead; otherwise it has already written the 401 challenge. The
// returned session ID is the one to forget once the operation succeeds.
func (c *ClientAPI) beginOrAdvanceUIA(
w http.ResponseWriter,
flows []uiaFlow,
auth *uiaRequest,
handleStage stageHandler,
) (sessionID string, done bool, err error) {
// No auth at all, or an auth without the session the server handed out,
// starts a new attempt. The spec requires the session to be quoted back,
// so a request that drops it is not treated as a continuation.
if auth == nil || auth.Type == "" || auth.Session == "" {
session, createErr := c.uia.create()
if createErr != nil {
return "", false, createErr
}
c.challenge(w, flows, session, nil)
return "", false, nil
}
session := c.uia.get(auth.Session)
if session == nil {
// An unknown or expired session: start a fresh one rather than
// failing, so a client that was slow can simply carry on.
session, createErr := c.uia.create()
if createErr != nil {
return "", false, createErr
}
c.challenge(w, flows, session,
httputil.NewError(http.StatusUnauthorized, httputil.ErrCodeUnrecognized,
"The authentication session has expired; start again"))
return "", false, nil
}
if !stageOffered(flows, auth.Type) {
return "", false, httputil.NewError(http.StatusBadRequest, httputil.ErrCodeUnrecognized,
"Unknown authentication type %q", auth.Type)
}
retryable, fatal := handleStage(auth.Type, auth)
if fatal != nil {
return "", false, fatal
}
if retryable != nil {
c.challenge(w, flows, session, retryable)
return "", false, nil
}
session.completed = c.uia.complete(session.id, auth.Type)
if !flowSatisfied(flows, session.completed) {
c.challenge(w, flows, session, nil)
return "", false, nil
}
return session.id, true, nil
}
// stageOffered reports whether stage appears in any of the flows, so that a
// client cannot complete a stage this operation never asked for.
func stageOffered(flows []uiaFlow, stage string) bool {
for _, flow := range flows {
if slices.Contains(flow.Stages, stage) {
return true
}
}
return false
}
@@ -0,0 +1,52 @@
package clientapi_test
import (
"net/http"
"testing"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
)
// TestLoginCarriesDiscovery covers the shortcut the spec allows: a client
// that only knew the server name learns its API URL from the login response
// instead of fetching /.well-known separately.
func TestLoginCarriesDiscovery(t *testing.T) {
ts := newTestServer(t, func(o *clientapi.Options) {
o.ClientBaseURL = "https://matrix.warp.example"
})
ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "superuser",
}})
expectStatus(t, rec, http.StatusOK)
wellKnown, ok := jsonBody(t, rec)["well_known"].(map[string]any)
if !ok {
t.Fatalf("login response carries no well_known: %s", rec.Body.String())
}
homeserver, _ := wellKnown["m.homeserver"].(map[string]any)
if homeserver["base_url"] != "https://matrix.warp.example" {
t.Errorf("base_url = %v", homeserver["base_url"])
}
}
// TestLoginOmitsDiscoveryWhenUnconfigured keeps the response from carrying
// an empty object a client would have to guess at.
func TestLoginOmitsDiscoveryWhenUnconfigured(t *testing.T) {
ts := newTestServer(t, nil)
ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "superuser",
}})
expectStatus(t, rec, http.StatusOK)
if _, present := jsonBody(t, rec)["well_known"]; present {
t.Error("well_known must be omitted when no client base URL is configured")
}
}
+107
View File
@@ -3,6 +3,7 @@
package config
import (
"crypto/subtle"
"errors"
"fmt"
"net/url"
@@ -96,6 +97,51 @@ type ClientAPI struct {
// browser. The Matrix spec expects "*"; narrow it if the only client is
// your own web app on a known origin.
CORSAllowedOrigins []string `yaml:"cors_allowed_origins" env:"WARP_CORS_ALLOWED_ORIGINS"`
// TrustXForwardedFor takes the client address from the X-Forwarded-For
// header instead of the socket. Turn it on only behind a reverse proxy
// that overwrites the header: anyone can send it, and the rate limiter
// keys its buckets on the result.
TrustXForwardedFor bool `yaml:"trust_x_forwarded_for" env:"WARP_TRUST_X_FORWARDED_FOR"`
// AccessTokenLifetime expires access tokens after this long. Zero — the
// default — issues tokens that never expire, which is what clients
// without refresh-token support need.
AccessTokenLifetime time.Duration `yaml:"access_token_lifetime" env:"WARP_ACCESS_TOKEN_LIFETIME"`
// RefreshTokenLifetime bounds how long a refresh token may be exchanged
// for a new access token. Zero means no expiry.
RefreshTokenLifetime time.Duration `yaml:"refresh_token_lifetime" env:"WARP_REFRESH_TOKEN_LIFETIME"`
RateLimit RateLimit `yaml:"rate_limit"`
}
// RateLimit configures the per-endpoint token buckets in front of the C-S
// API. Login and registration get their own, tighter, buckets because they
// are the two endpoints worth brute-forcing.
//
// Only `enabled` has an environment override: the three buckets share one Go
// type, so per-bucket `env` tags would all collide on the same variable.
// Turning the whole limiter off (WARP_RATE_LIMIT_ENABLED=false) is what test
// harnesses such as Complement need anyway.
type RateLimit struct {
Enabled bool `yaml:"enabled" env:"WARP_RATE_LIMIT_ENABLED"`
// Default applies to every rate-limited endpoint without its own bucket.
Default RateLimitBucket `yaml:"default"`
// Login covers /login and /refresh.
Login RateLimitBucket `yaml:"login"`
// Register covers /register and /register/available.
Register RateLimitBucket `yaml:"register"`
}
// RateLimitBucket is one token bucket: Burst requests may arrive back to
// back, and one slot returns every Interval.
type RateLimitBucket struct {
Burst int `yaml:"burst"`
Interval time.Duration `yaml:"interval"`
}
// Federation controls the server-to-server surface. See PLAN.md section 3: it
@@ -118,6 +164,30 @@ type Federation struct {
type Registration struct {
Enabled bool `yaml:"enabled" env:"WARP_REGISTRATION_ENABLED"`
AllowRuntimeToggle bool `yaml:"allow_runtime_toggle" env:"WARP_REGISTRATION_ALLOW_RUNTIME_TOGGLE"`
// Tokens, when non-empty, adds m.login.registration_token as a required
// stage to the registration flow and lists the tokens that satisfy it.
//
// Phase 1 keeps the tokens in the config file: per-token use counts and
// expiry belong to the admin token management of Phase 9, and a table
// nothing can write to yet would be dead weight.
Tokens []string `yaml:"tokens" env:"WARP_REGISTRATION_TOKENS"`
}
// RequiresToken reports whether the registration flow includes the
// m.login.registration_token stage.
func (r Registration) RequiresToken() bool { return len(r.Tokens) > 0 }
// TokenValid reports whether token is one of the configured registration
// tokens. Every candidate is compared in constant time, and the loop does
// not stop early, so neither the value nor the position of a token leaks
// through the time this takes.
func (r Registration) TokenValid(token string) bool {
found := 0
for _, want := range r.Tokens {
found |= subtle.ConstantTimeCompare([]byte(token), []byte(want))
}
return found == 1
}
// Logging configures slog.
@@ -163,6 +233,14 @@ func Default() *Config {
},
ClientAPI: ClientAPI{
CORSAllowedOrigins: []string{"*"},
RateLimit: RateLimit{
Enabled: true,
// Generous enough that a chatty client never notices,
// tight enough that a password-guessing script does.
Default: RateLimitBucket{Burst: 30, Interval: 100 * time.Millisecond},
Login: RateLimitBucket{Burst: 10, Interval: time.Second},
Register: RateLimitBucket{Burst: 5, Interval: 2 * time.Second},
},
},
Federation: Federation{
Enabled: false,
@@ -252,6 +330,35 @@ func (c *Config) Validate() error {
bad("metrics.path: must start with /")
}
if c.ClientAPI.AccessTokenLifetime < 0 {
bad("client_api.access_token_lifetime: must not be negative")
}
if c.ClientAPI.RefreshTokenLifetime < 0 {
bad("client_api.refresh_token_lifetime: must not be negative")
}
if c.ClientAPI.RateLimit.Enabled {
for _, b := range []struct {
name string
bucket RateLimitBucket
}{
{"default", c.ClientAPI.RateLimit.Default},
{"login", c.ClientAPI.RateLimit.Login},
{"register", c.ClientAPI.RateLimit.Register},
} {
if b.bucket.Burst < 1 {
bad("client_api.rate_limit.%s.burst: must be at least 1", b.name)
}
if b.bucket.Interval <= 0 {
bad("client_api.rate_limit.%s.interval: must be positive", b.name)
}
}
}
for i, token := range c.Registration.Tokens {
if strings.TrimSpace(token) == "" {
bad("registration.tokens[%d]: must not be empty", i)
}
}
for i, s := range c.Federation.Allowlist {
if err := validateServerName(s); err != nil {
bad("federation.allowlist[%d]: %w", i, err)
+139
View File
@@ -0,0 +1,139 @@
package config
import (
"strings"
"testing"
"time"
)
func TestRateLimitDefaults(t *testing.T) {
cfg := Default()
if !cfg.ClientAPI.RateLimit.Enabled {
t.Error("rate limiting must be on by default: login is worth brute-forcing")
}
for name, bucket := range map[string]RateLimitBucket{
"default": cfg.ClientAPI.RateLimit.Default,
"login": cfg.ClientAPI.RateLimit.Login,
"register": cfg.ClientAPI.RateLimit.Register,
} {
if bucket.Burst < 1 || bucket.Interval <= 0 {
t.Errorf("the %s bucket is unusable by default: %+v", name, bucket)
}
}
// Login and registration are deliberately tighter than the rest.
if cfg.ClientAPI.RateLimit.Login.Interval <= cfg.ClientAPI.RateLimit.Default.Interval {
t.Error("the login bucket should refill more slowly than the default one")
}
if cfg.ClientAPI.RateLimit.Register.Interval <= cfg.ClientAPI.RateLimit.Login.Interval {
t.Error("the registration bucket should refill more slowly than the login one")
}
if cfg.ClientAPI.AccessTokenLifetime != 0 {
t.Error("access tokens must not expire by default: clients without refresh support need that")
}
if cfg.ClientAPI.TrustXForwardedFor {
t.Error("X-Forwarded-For must not be trusted by default: it is spoofable")
}
}
func TestRateLimitCanBeDisabledFromTheEnvironment(t *testing.T) {
cfg := Default()
if err := ApplyEnv(cfg, func(key string) string {
if key == "WARP_RATE_LIMIT_ENABLED" {
return "false"
}
return ""
}); err != nil {
t.Fatalf("ApplyEnv: %v", err)
}
if cfg.ClientAPI.RateLimit.Enabled {
t.Error("WARP_RATE_LIMIT_ENABLED=false did not take effect")
}
}
func TestValidateRejectsUnusableRateLimits(t *testing.T) {
cfg := Default()
cfg.Global.ServerName = "warp.example"
cfg.ClientAPI.RateLimit.Login.Burst = 0
cfg.ClientAPI.RateLimit.Register.Interval = 0
cfg.ClientAPI.AccessTokenLifetime = -time.Second
err := cfg.Validate()
if err == nil {
t.Fatal("expected validation to fail")
}
for _, want := range []string{
"client_api.rate_limit.login.burst",
"client_api.rate_limit.register.interval",
"client_api.access_token_lifetime",
} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error should mention %s, got: %v", want, err)
}
}
}
// TestDisabledRateLimitsSkipValidation: a deployment that turned the limiter
// off should not have to keep its buckets valid.
func TestDisabledRateLimitsSkipValidation(t *testing.T) {
cfg := Default()
cfg.Global.ServerName = "warp.example"
cfg.ClientAPI.RateLimit = RateLimit{Enabled: false}
if err := cfg.Validate(); err != nil {
t.Errorf("Validate with rate limiting off = %v, want nil", err)
}
}
func TestRegistrationTokens(t *testing.T) {
cfg := Default()
if cfg.Registration.RequiresToken() {
t.Error("no tokens configured means no token stage")
}
if cfg.Registration.TokenValid("") || cfg.Registration.TokenValid("anything") {
t.Error("with no tokens configured, nothing may validate")
}
cfg.Registration.Tokens = []string{"first-token", "second-token"}
if !cfg.Registration.RequiresToken() {
t.Error("configured tokens must add the token stage")
}
for _, token := range []string{"first-token", "second-token"} {
if !cfg.Registration.TokenValid(token) {
t.Errorf("TokenValid(%q) = false, want true", token)
}
}
for _, token := range []string{"", "FIRST-TOKEN", "first-toke", "first-tokenn", "nope"} {
if cfg.Registration.TokenValid(token) {
t.Errorf("TokenValid(%q) = true, want false", token)
}
}
}
func TestValidateRejectsEmptyRegistrationToken(t *testing.T) {
cfg := Default()
cfg.Global.ServerName = "warp.example"
cfg.Registration.Tokens = []string{"fine", " "}
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "registration.tokens[1]") {
t.Fatalf("expected an error naming the empty token, got: %v", err)
}
}
func TestRegistrationTokensFromTheEnvironment(t *testing.T) {
cfg := Default()
if err := ApplyEnv(cfg, func(key string) string {
if key == "WARP_REGISTRATION_TOKENS" {
return "one, two"
}
return ""
}); err != nil {
t.Fatalf("ApplyEnv: %v", err)
}
if len(cfg.Registration.Tokens) != 2 || !cfg.Registration.TokenValid("two") {
t.Errorf("tokens = %v", cfg.Registration.Tokens)
}
}
+30
View File
@@ -25,6 +25,13 @@ const (
ErrCodeInvalidParam = "M_INVALID_PARAM"
ErrCodeTooLarge = "M_TOO_LARGE"
ErrCodeUnsupportedVer = "M_UNSUPPORTED_ROOM_VERSION"
// Account and session codes, added with the userapi in Phase 1.
ErrCodeUserInUse = "M_USER_IN_USE"
ErrCodeInvalidUsername = "M_INVALID_USERNAME"
ErrCodeUserDeactivated = "M_USER_DEACTIVATED"
ErrCodeExclusive = "M_EXCLUSIVE"
ErrCodeWeakPassword = "M_WEAK_PASSWORD"
)
// MatrixError is the standard Matrix error envelope. Every non-2xx response
@@ -130,3 +137,26 @@ func LimitExceeded(retryAfterMS int64) *MatrixError {
func InternalError() *MatrixError {
return NewError(http.StatusInternalServerError, ErrCodeUnknown, "Internal server error")
}
// UserInUse is M_USER_IN_USE (400): the requested user name is taken.
func UserInUse() *MatrixError {
return NewError(http.StatusBadRequest, ErrCodeUserInUse, "Desired user ID is already taken")
}
// InvalidUsername is M_INVALID_USERNAME (400): the user name is not usable,
// either because of its characters or its length.
func InvalidUsername(format string, args ...any) *MatrixError {
return NewError(http.StatusBadRequest, ErrCodeInvalidUsername, format, args...)
}
// UserDeactivated is M_USER_DEACTIVATED (403).
func UserDeactivated() *MatrixError {
return NewError(http.StatusForbidden, ErrCodeUserDeactivated, "This account has been deactivated")
}
// Unknown is M_UNKNOWN with an explicit status, for the handful of cases the
// spec answers with that code rather than a specific one — an unsupported
// login type, for instance.
func Unknown(status int, format string, args ...any) *MatrixError {
return NewError(status, ErrCodeUnknown, format, args...)
}
+26 -7
View File
@@ -1,6 +1,7 @@
package httputil
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -67,26 +68,44 @@ func WriteError(w http.ResponseWriter, r *http.Request, err error) {
// DecodeJSON reads a JSON request body into v, enforcing MaxRequestBody and
// mapping a missing, oversized or malformed body onto the right Matrix error.
func DecodeJSON(w http.ResponseWriter, r *http.Request, v any) error {
present, err := DecodeOptionalJSON(w, r, v)
if err != nil {
return err
}
if !present {
return NotJSON()
}
return nil
}
// DecodeOptionalJSON is DecodeJSON for an endpoint whose body may legitimately
// be absent, reporting whether there was one. A malformed body is still an
// error: only a genuinely empty one is "not sent".
//
// DELETE /devices/{deviceId} is the case this exists for. A client sends it
// with no body at all, because it does not know until the 401 comes back that
// the endpoint is interactive.
func DecodeOptionalJSON(w http.ResponseWriter, r *http.Request, v any) (bool, error) {
body := http.MaxBytesReader(w, r.Body, MaxRequestBody)
data, err := io.ReadAll(body)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return NewError(http.StatusRequestEntityTooLarge, ErrCodeTooLarge, "Request body too large")
return false, NewError(http.StatusRequestEntityTooLarge, ErrCodeTooLarge, "Request body too large")
}
return NotJSON()
return false, NotJSON()
}
if len(data) == 0 {
return NotJSON()
if len(bytes.TrimSpace(data)) == 0 {
return false, nil
}
if err := json.Unmarshal(data, v); err != nil {
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
return BadJSON("Invalid value for field %q", typeErr.Field)
return false, BadJSON("Invalid value for field %q", typeErr.Field)
}
return NotJSON()
return false, NotJSON()
}
return nil
return true, nil
}
// Handler is an http.Handler whose errors are turned into Matrix envelopes by
+164
View File
@@ -0,0 +1,164 @@
// Package ratelimit is the per-endpoint rate limiting the Client-Server API
// needs: a token bucket per caller, answered with M_LIMIT_EXCEEDED and a
// retry_after_ms the client is expected to honour.
//
// Each rate-limited route gets its own Limiter, so a client hammering
// /login cannot use up the budget of /profile. Buckets are keyed per caller
// within a limiter; the key is supplied by the caller (the client address
// for unauthenticated endpoints, the user ID once a request is
// authenticated).
package ratelimit
import (
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
// Policy is one bucket's shape: Burst requests may arrive back to back, and
// one slot returns every Interval.
type Policy struct {
Burst int
Interval time.Duration
}
// Limiter is a set of token buckets, one per key.
//
// The implementation stores a single timestamp per key — the moment the
// bucket would next be completely full — rather than a count and a refill
// time. That is the whole state a leaky bucket needs: how far that moment is
// in the future says how many slots are in use, and the arithmetic cannot
// drift the way a periodically refilled counter can.
type Limiter struct {
policy Policy
// span is Burst × Interval: the longest the "full again" moment can be
// pushed into the future, which is what makes the bucket finite.
span time.Duration
now func() time.Time
mu sync.Mutex
fullAt map[string]time.Time
sweptAt time.Time
disabled bool
}
// sweepInterval is how often idle buckets are collected. Without it the map
// would keep one entry per address that ever connected.
const sweepInterval = time.Minute
// New builds a limiter. A policy with a non-positive burst or interval
// disables it, so that a limiter is always safe to call even when the
// configuration turned rate limiting off.
func New(policy Policy) *Limiter {
l := &Limiter{policy: policy, now: time.Now, fullAt: map[string]time.Time{}}
if policy.Burst <= 0 || policy.Interval <= 0 {
l.disabled = true
return l
}
l.span = time.Duration(policy.Burst) * policy.Interval
return l
}
// Disabled returns a limiter that allows everything, for a deployment with
// rate limiting switched off.
func Disabled() *Limiter { return &Limiter{disabled: true} }
// Allow records one request against key. It returns how long the caller
// should wait; a zero duration means the request may proceed.
func (l *Limiter) Allow(key string) time.Duration {
if l.disabled {
return 0
}
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
l.sweep(now)
// An unseen or long-idle bucket starts full, which is the same as
// "would be completely full as of now".
fullAt := l.fullAt[key]
if fullAt.Before(now) {
fullAt = now
}
// Taking a slot pushes the "full again" moment one interval further
// out. It is allowed as long as that keeps it within the bucket's span;
// beyond that, every slot is in use and the overshoot is exactly how
// long the caller has to wait for the next one.
next := fullAt.Add(l.policy.Interval)
if overshoot := next.Sub(now) - l.span; overshoot > 0 {
return overshoot
}
l.fullAt[key] = next
return 0
}
// sweep drops buckets that have been full — and therefore idle — for a
// while. It runs at most once per sweepInterval and under the caller's lock,
// so it costs nothing on a busy server beyond one comparison per request.
func (l *Limiter) sweep(now time.Time) {
if now.Sub(l.sweptAt) < sweepInterval {
return
}
l.sweptAt = now
for key, fullAt := range l.fullAt {
if fullAt.Before(now) {
delete(l.fullAt, key)
}
}
}
// KeyFunc derives the bucket key for a request.
type KeyFunc func(*http.Request) string
// Middleware rejects requests over the limit with M_LIMIT_EXCEEDED.
func (l *Limiter) Middleware(key KeyFunc) httputil.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if retryAfter := l.Allow(key(r)); retryAfter > 0 {
// Rounded up: a client that waits exactly retry_after_ms
// and is told to wait again learns nothing useful.
ms := retryAfter.Milliseconds()
if retryAfter%time.Millisecond != 0 {
ms++
}
httputil.WriteError(w, r, httputil.LimitExceeded(ms))
return
}
next.ServeHTTP(w, r)
})
}
}
// ClientIP returns the address a request came from, as a rate limiting key.
//
// trustForwardedFor takes the left-most X-Forwarded-For entry instead of the
// socket address. It must only be turned on behind a proxy that overwrites
// the header: anyone can send it, and a spoofable key is no key at all.
func ClientIP(trustForwardedFor bool) KeyFunc {
return func(r *http.Request) string {
if trustForwardedFor {
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
first, _, _ := strings.Cut(forwarded, ",")
if first = strings.TrimSpace(first); first != "" {
return first
}
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
// RemoteAddr is not host:port for a unix socket or a synthetic
// request; use it whole rather than losing the distinction.
return r.RemoteAddr
}
return host
}
}
+165
View File
@@ -0,0 +1,165 @@
package ratelimit
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// clock is a hand-wound clock, so the tests do not sleep.
type clock struct{ at time.Time }
func (c *clock) now() time.Time { return c.at }
func (c *clock) add(d time.Duration) { c.at = c.at.Add(d) }
func newTestLimiter(policy Policy) (*Limiter, *clock) {
c := &clock{at: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
l := New(policy)
l.now = c.now
return l, c
}
func TestLimiterAllowsBurstThenRefills(t *testing.T) {
l, c := newTestLimiter(Policy{Burst: 3, Interval: time.Second})
for i := range 3 {
if wait := l.Allow("alice"); wait != 0 {
t.Fatalf("request %d of the burst was limited (wait %v)", i+1, wait)
}
}
wait := l.Allow("alice")
if wait <= 0 {
t.Fatal("the fourth request should have been limited")
}
if wait != time.Second {
t.Errorf("retry after = %v, want 1s (one interval)", wait)
}
// Half an interval is not enough for a slot to come back.
c.add(500 * time.Millisecond)
if wait := l.Allow("alice"); wait != 500*time.Millisecond {
t.Errorf("retry after half an interval = %v, want 500ms", wait)
}
c.add(500 * time.Millisecond)
if wait := l.Allow("alice"); wait != 0 {
t.Errorf("a slot should have come back after one interval, got wait %v", wait)
}
}
func TestLimiterKeysAreIndependent(t *testing.T) {
l, _ := newTestLimiter(Policy{Burst: 1, Interval: time.Second})
if wait := l.Allow("alice"); wait != 0 {
t.Fatalf("alice was limited straight away: %v", wait)
}
if wait := l.Allow("alice"); wait == 0 {
t.Fatal("alice should be out of budget")
}
if wait := l.Allow("bob"); wait != 0 {
t.Errorf("bob was limited by alice's traffic: %v", wait)
}
}
func TestLimiterRecoversFullyWhenIdle(t *testing.T) {
l, c := newTestLimiter(Policy{Burst: 2, Interval: time.Second})
l.Allow("alice")
l.Allow("alice")
c.add(time.Hour)
for i := range 2 {
if wait := l.Allow("alice"); wait != 0 {
t.Errorf("after an idle hour, request %d was limited: %v", i+1, wait)
}
}
}
func TestLimiterSweepsIdleBuckets(t *testing.T) {
l, c := newTestLimiter(Policy{Burst: 1, Interval: time.Second})
l.Allow("alice")
c.add(sweepInterval + time.Second)
// This call triggers the sweep, which must drop alice's long-full bucket
// rather than keeping one entry per address that ever connected.
l.Allow("bob")
l.mu.Lock()
defer l.mu.Unlock()
if _, present := l.fullAt["alice"]; present {
t.Error("an idle bucket survived the sweep")
}
if _, present := l.fullAt["bob"]; !present {
t.Error("the active bucket was swept away")
}
}
func TestDisabledLimiterAllowsEverything(t *testing.T) {
for name, l := range map[string]*Limiter{
"explicitly disabled": Disabled(),
"zero burst": New(Policy{Burst: 0, Interval: time.Second}),
"zero interval": New(Policy{Burst: 10, Interval: 0}),
} {
t.Run(name, func(t *testing.T) {
for range 100 {
if wait := l.Allow("alice"); wait != 0 {
t.Fatalf("a disabled limiter limited a request: %v", wait)
}
}
})
}
}
func TestMiddlewareReturnsLimitExceeded(t *testing.T) {
l, _ := newTestLimiter(Policy{Burst: 1, Interval: 1500 * time.Millisecond})
handler := l.Middleware(ClientIP(false))(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
call := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/_matrix/client/v3/login", nil)
req.RemoteAddr = "192.0.2.10:1234"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
if rec := call(); rec.Code != http.StatusOK {
t.Fatalf("first request status = %d, want 200", rec.Code)
}
rec := call()
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("second request status = %d, want 429", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
body := rec.Body.String()
for _, want := range []string{`"errcode":"M_LIMIT_EXCEEDED"`, `"retry_after_ms":1500`} {
if !strings.Contains(body, want) {
t.Errorf("body %s is missing %s", body, want)
}
}
}
func TestClientIPUsesForwardedForOnlyWhenTrusted(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.10:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.7, 192.0.2.1")
if got := ClientIP(false)(req); got != "192.0.2.10" {
t.Errorf("untrusted X-Forwarded-For was used: key = %q", got)
}
if got := ClientIP(true)(req); got != "203.0.113.7" {
t.Errorf("trusted X-Forwarded-For key = %q, want the left-most entry", got)
}
// A request without the header falls back to the socket either way.
req.Header.Del("X-Forwarded-For")
if got := ClientIP(true)(req); got != "192.0.2.10" {
t.Errorf("key without the header = %q, want the socket address", got)
}
}
+169
View File
@@ -0,0 +1,169 @@
package setup_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/setup"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// post sends a JSON body through the fully wrapped handler.
func post(t *testing.T, srv *setup.Server, path string, body any, token string) *httptest.ResponseRecorder {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
// getWithToken is `get` plus an access token.
func getWithToken(t *testing.T, srv *setup.Server, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
// TestClientAPIIsWiredUp walks a client's first minutes through the whole
// stack — middleware, router, metrics, components — rather than through a
// hand-built router, which is what the per-package tests use. It is here to
// catch a route that was written but never registered.
func TestClientAPIIsWiredUp(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) {
c.Registration.Enabled = true
// The limiter keys on the client address, and every httptest
// request comes from the same one.
c.ClientAPI.RateLimit.Enabled = false
})
rec := post(t, srv, "/_matrix/client/v3/register",
map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("register status = %d, want 401 (body: %s)", rec.Code, rec.Body.String())
}
session := decode[map[string]any](t, rec)["session"]
rec = post(t, srv, "/_matrix/client/v3/register", map[string]any{
"username": "alice",
"password": "sUp3rs3kr1t",
"auth": map[string]any{"type": "m.login.dummy", "session": session},
}, "")
if rec.Code != http.StatusOK {
t.Fatalf("register status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
registered := decode[map[string]any](t, rec)
token, _ := registered["access_token"].(string)
if token == "" {
t.Fatalf("registration returned no access token: %s", rec.Body.String())
}
for _, path := range []string{
"/_matrix/client/v3/account/whoami",
"/_matrix/client/v3/capabilities",
"/_matrix/client/v3/devices",
"/_matrix/client/v3/sync",
} {
if rec := getWithToken(t, srv, path, token); rec.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200 (body: %s)", path, rec.Code, rec.Body.String())
}
}
// Profiles are reachable without a token.
if rec := get(t, srv, http.MethodGet, "/_matrix/client/v3/profile/@alice:warp.example"); rec.Code != http.StatusOK {
t.Errorf("GET /profile = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
rec = post(t, srv, "/_matrix/client/v3/login", map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
"password": "sUp3rs3kr1t",
}, "")
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
}
// TestAdminAPIIsWiredUp does the same for /_warp/admin/v1, and checks the
// one thing only the full stack can show: that the admin routes are labelled
// for the metrics like any other.
func TestAdminAPIIsWiredUp(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) { c.Metrics.Enabled = true })
account, err := srv.Users().CreateAccount(t.Context(), userapi.NewAccountRequest{
Localpart: "root",
Password: "sUp3rs3kr1t",
Admin: true,
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
session, err := srv.Users().CreateSession(t.Context(), userapi.SessionRequest{
Localpart: account.Localpart,
})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
rec := getWithToken(t, srv, "/_warp/admin/v1/stats", session.AccessToken)
if rec.Code != http.StatusOK {
t.Fatalf("GET /stats = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if got := decode[map[string]any](t, rec)["server_name"]; got != "warp.example" {
t.Errorf("server_name = %v", got)
}
// Flipping federation through the admin API must take effect on the
// gate, with no restart: PLAN.md section 3.
if rec := get(t, srv, http.MethodGet, "/.well-known/matrix/server"); rec.Code != http.StatusNotFound {
t.Fatalf("with federation off, /.well-known/matrix/server = %d, want 404", rec.Code)
}
req := httptest.NewRequest(http.MethodPut, "/_warp/admin/v1/federation",
bytes.NewReader([]byte(`{"enabled":true}`)))
req.Header.Set("Authorization", "Bearer "+session.AccessToken)
rec = httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT /federation = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if !srv.Settings().FederationEnabled() {
t.Error("the federation switch did not take effect")
}
metrics := get(t, srv, http.MethodGet, "/metrics").Body.String()
if !strings.Contains(metrics, `route="GET /_warp/admin/v1/stats"`) {
t.Error("admin routes are not labelled in the metrics")
}
}
// TestRegistrationIsOffByDefault is the posture a new install starts in:
// private, with no way in until the operator opens one.
func TestRegistrationIsOffByDefault(t *testing.T) {
srv := newTestServer(t, nil)
rec := post(t, srv, "/_matrix/client/v3/register",
map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}, "")
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 with registration off (body: %s)", rec.Code, rec.Body.String())
}
if code := decode[map[string]string](t, rec)["errcode"]; code != "M_FORBIDDEN" {
t.Errorf("errcode = %q, want M_FORBIDDEN", code)
}
}
+8 -4
View File
@@ -11,9 +11,10 @@ import (
// SpecVersions is what GET /_matrix/client/versions reports.
//
// Keep it honest: a version belongs here once the endpoints a client
// actually uses from it work. Phase 0 serves only /versions and
// /.well-known, so the list stays minimal and grows as the C-S API lands in
// Phases 1 to 4.
// actually uses from it work. Phase 1 adds accounts, sessions, devices,
// profiles and account data; rooms and /sync are still missing, so the list
// is a promise about the identity half of the API only and grows as
// Phases 3 and 4 land.
var SpecVersions = []string{"v1.13", "v1.14"}
// UnstableFeatures advertises MSC support. Simplified Sliding Sync
@@ -46,7 +47,7 @@ type healthResponse struct {
Error string `json:"error,omitempty"`
}
// routes registers every endpoint Phase 0 serves.
// routes registers every endpoint this server serves.
func (s *Server) routes() *httputil.Router {
r := httputil.NewRouter(s.metrics.Instrument)
@@ -57,6 +58,9 @@ func (s *Server) routes() *httputil.Router {
// surface, so it goes through the gate and disappears with it.
r.Handle("GET /.well-known/matrix/server", s.federation.ProtectFunc(s.handleWellKnownServer))
s.clientAPI.RegisterRoutes(r)
s.adminAPI.RegisterRoutes(r)
r.HandleFunc("GET /healthz", s.handleHealth)
if s.cfg.Metrics.Enabled {
r.Handle("GET "+s.cfg.Metrics.Path, s.metrics.Handler())
+52
View File
@@ -13,7 +13,9 @@ import (
"sync/atomic"
"time"
"github.com/dariuszjaniak/warp/server/internal/adminapi"
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
@@ -22,6 +24,7 @@ import (
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers" // link in the storage drivers
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// Server is a configured, not yet listening homeserver.
@@ -35,6 +38,10 @@ type Server struct {
federation *federation.Gate
signingKey *signing.KeyPair
users *userapi.API
clientAPI *clientapi.ClientAPI
adminAPI *adminapi.AdminAPI
handler http.Handler
// addr is the address Run is listening on; empty until then.
@@ -123,6 +130,47 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
log.Info("federation switch applied", "enabled", snap.FederationEnabled)
})
s.users, err = userapi.New(userapi.Options{
ServerName: cfg.Global.ServerName,
Store: s.db.Users(),
AccessTokenLifetime: cfg.ClientAPI.AccessTokenLifetime,
RefreshTokenLifetime: cfg.ClientAPI.RefreshTokenLifetime,
Logger: log,
})
if err != nil {
_ = s.closeDB()
return nil, err
}
s.clientAPI, err = clientapi.New(clientapi.Options{
ServerName: cfg.Global.ServerName,
ClientBaseURL: cfg.ClientBaseURL(),
Users: s.users,
Settings: settingsService,
Federation: s.federation,
Registration: cfg.Registration,
RateLimit: cfg.ClientAPI.RateLimit,
TrustXForwardedFor: cfg.ClientAPI.TrustXForwardedFor,
Logger: log,
})
if err != nil {
_ = s.closeDB()
return nil, err
}
s.adminAPI, err = adminapi.New(adminapi.Options{
ServerName: cfg.Global.ServerName,
Users: s.users,
Settings: settingsService,
Federation: s.federation,
Database: s.db,
Logger: log,
})
if err != nil {
_ = s.closeDB()
return nil, err
}
s.handler = httputil.Chain(s.routes(),
httputil.Recoverer,
httputil.RequestLogger(log),
@@ -170,6 +218,10 @@ func (s *Server) Settings() *settings.Service { return s.settings }
// Database exposes the storage handle.
func (s *Server) Database() storage.Database { return s.db }
// Users exposes the userapi component, which `warp create-account` and the
// tests use to create accounts without going through HTTP.
func (s *Server) Users() *userapi.API { return s.users }
// SigningKey returns the server signing key, or nil when it was skipped.
func (s *Server) SigningKey() *signing.KeyPair { return s.signingKey }
@@ -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)
}
})
}
}
+137
View File
@@ -0,0 +1,137 @@
package userapi
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters. These are OWASP's recommended settings for argon2id
// (19 MiB, two passes, one lane): strong enough to make a stolen hash
// expensive to attack, cheap enough that a login does not become a denial of
// service against the server itself.
//
// The values are recorded in every hash, so raising them later is a matter
// of changing these constants; old hashes keep verifying with the parameters
// they were made with, and are rewritten the next time the password changes.
const (
argonMemoryKiB = 19 * 1024
argonTime = 2
argonLanes = 1
argonSaltLen = 16
argonKeyLen = 32
)
// MaxPasswordLength caps a password at 512 bytes. Argon2id does not care how
// long its input is, but accepting unbounded input means accepting unbounded
// work in the hash function.
const MaxPasswordLength = 512
// ErrPasswordTooLong is returned when a password exceeds MaxPasswordLength.
var ErrPasswordTooLong = fmt.Errorf("password must be at most %d bytes", MaxPasswordLength)
// ErrInvalidPasswordHash is returned when a stored hash cannot be parsed. It
// means the row is corrupt, not that the password was wrong.
var ErrInvalidPasswordHash = errors.New("stored password hash is malformed")
// HashPassword returns an encoded argon2id hash in the PHC string format:
//
// $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
//
// The format is the de facto standard for password hashes, so the parameters
// travel with the hash and an operator can tell at a glance what a row was
// written with.
func HashPassword(password string) (string, error) {
if len(password) > MaxPasswordLength {
return "", ErrPasswordTooLong
}
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate password salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemoryKiB, argonLanes, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemoryKiB, argonTime, argonLanes,
b64.EncodeToString(salt), b64.EncodeToString(key)), nil
}
// VerifyPassword reports whether password matches encoded. An unparseable
// hash is an error rather than a mismatch, so a corrupt row shows up in the
// logs instead of looking like a wrong password forever.
func VerifyPassword(encoded, password string) (bool, error) {
if len(password) > MaxPasswordLength {
return false, ErrPasswordTooLong
}
params, salt, want, err := decodeHash(encoded)
if err != nil {
return false, err
}
got := argon2.IDKey([]byte(password), salt, params.time, params.memory, params.lanes, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
// b64 is the unpadded base64 the PHC format uses.
var b64 = base64.RawStdEncoding
type argonParams struct {
memory uint32
time uint32
lanes uint8
}
// decodeHash parses a PHC argon2id string.
func decodeHash(encoded string) (argonParams, []byte, []byte, error) {
// "", "argon2id", "v=19", "m=...,t=...,p=...", salt, hash
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[0] != "" {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
if parts[1] != "argon2id" {
return argonParams{}, nil, nil, fmt.Errorf("%w: unsupported algorithm %q", ErrInvalidPasswordHash, parts[1])
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
if version != argon2.Version {
return argonParams{}, nil, nil, fmt.Errorf("%w: unsupported argon2 version %d", ErrInvalidPasswordHash, version)
}
var p argonParams
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.memory, &p.time, &p.lanes); err != nil {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
if p.memory == 0 || p.time == 0 || p.lanes == 0 {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
salt, err := b64.DecodeString(parts[4])
if err != nil || len(salt) == 0 {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
key, err := b64.DecodeString(parts[5])
if err != nil || len(key) == 0 {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
return p, salt, key, nil
}
// dummyPasswordHash is verified against when the named account does not
// exist or has no password, so that a login attempt costs the same either
// way. Without it, the time a rejection takes would tell an attacker which
// user names are real.
var dummyPasswordHash = func() string {
hash, err := HashPassword("warp: this password hashes nothing")
if err != nil {
// HashPassword only fails on a short read from crypto/rand, which
// at init time means the process cannot work at all.
panic("userapi: cannot build the dummy password hash: " + err.Error())
}
return hash
}()
+160
View File
@@ -0,0 +1,160 @@
package userapi
import (
"errors"
"strings"
"testing"
"golang.org/x/crypto/argon2"
)
func TestHashAndVerifyPassword(t *testing.T) {
const password = "correct horse battery staple"
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if !strings.HasPrefix(hash, "$argon2id$v=") {
t.Fatalf("hash %q is not in the PHC format", hash)
}
ok, err := VerifyPassword(hash, password)
if err != nil || !ok {
t.Fatalf("VerifyPassword of the right password = %v, %v", ok, err)
}
ok, err = VerifyPassword(hash, password+"!")
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if ok {
t.Error("the wrong password verified")
}
}
// TestHashIsSalted is what makes a stolen table worth attacking one row at a
// time rather than all at once.
func TestHashIsSalted(t *testing.T) {
first, err := HashPassword("same password")
if err != nil {
t.Fatal(err)
}
second, err := HashPassword("same password")
if err != nil {
t.Fatal(err)
}
if first == second {
t.Error("two hashes of the same password are identical, so the salt is not random")
}
}
// TestHashRecordsItsParameters is what lets the cost be raised later without
// invalidating every existing password.
func TestHashRecordsItsParameters(t *testing.T) {
hash, err := HashPassword("x")
if err != nil {
t.Fatal(err)
}
params, salt, key, err := decodeHash(hash)
if err != nil {
t.Fatalf("decodeHash of our own output: %v", err)
}
if params.memory != argonMemoryKiB || params.time != argonTime || params.lanes != argonLanes {
t.Errorf("recorded parameters = %+v, want the current constants", params)
}
if len(salt) != argonSaltLen || len(key) != argonKeyLen {
t.Errorf("salt is %d bytes and key %d bytes", len(salt), len(key))
}
}
// TestVerifyAgainstAnOlderCost proves the point of recording them: a hash
// made with weaker parameters still verifies.
func TestVerifyAgainstAnOlderCost(t *testing.T) {
const password = "from an older release"
// A hash as a previous, cheaper, configuration would have written it.
salt := []byte("0123456789abcdef")
key := argon2.IDKey([]byte(password), salt, 1, 8*1024, 1, 32)
hash := "$argon2id$v=19$m=8192,t=1,p=1$" + b64.EncodeToString(salt) + "$" + b64.EncodeToString(key)
ok, err := VerifyPassword(hash, password)
if err != nil || !ok {
t.Fatalf("VerifyPassword against older parameters = %v, %v", ok, err)
}
}
// TestVerifyRejectsMalformedHashes keeps a corrupt row from looking like a
// wrong password for ever.
func TestVerifyRejectsMalformedHashes(t *testing.T) {
good, err := HashPassword("x")
if err != nil {
t.Fatal(err)
}
parts := strings.Split(good, "$")
for name, hash := range map[string]string{
"empty": "",
"not a hash": "hunter2",
"too few fields": "$argon2id$v=19$m=19456,t=2,p=1$" + parts[4],
"wrong algorithm": "$argon2i$v=19$" + strings.Join(parts[3:], "$"),
"unknown version": "$argon2id$v=16$" + strings.Join(parts[3:], "$"),
"unparseable params": "$argon2id$v=19$m=lots,t=2,p=1$" + parts[4] + "$" + parts[5],
"zero memory": "$argon2id$v=19$m=0,t=2,p=1$" + parts[4] + "$" + parts[5],
"bad base64 salt": "$argon2id$v=19$m=19456,t=2,p=1$not!base64$" + parts[5],
"empty key": "$argon2id$v=19$m=19456,t=2,p=1$" + parts[4] + "$",
} {
t.Run(name, func(t *testing.T) {
ok, err := VerifyPassword(hash, "x")
if ok {
t.Fatal("a malformed hash verified")
}
if !errors.Is(err, ErrInvalidPasswordHash) {
t.Errorf("error = %v, want ErrInvalidPasswordHash", err)
}
})
}
}
func TestPasswordLengthIsBounded(t *testing.T) {
long := strings.Repeat("x", MaxPasswordLength+1)
if _, err := HashPassword(long); !errors.Is(err, ErrPasswordTooLong) {
t.Errorf("HashPassword of an overlong password = %v, want ErrPasswordTooLong", err)
}
hash, err := HashPassword(strings.Repeat("x", MaxPasswordLength))
if err != nil {
t.Fatalf("a password at the limit must be accepted: %v", err)
}
if _, err := VerifyPassword(hash, long); !errors.Is(err, ErrPasswordTooLong) {
t.Errorf("VerifyPassword of an overlong password = %v, want ErrPasswordTooLong", err)
}
}
// TestNonASCIIPasswords: the hash takes bytes, so there is nothing to
// normalise, but a client sending one must not be refused.
func TestNonASCIIPasswords(t *testing.T) {
const password = "übers3kr1t 🔐"
hash, err := HashPassword(password)
if err != nil {
t.Fatal(err)
}
if ok, err := VerifyPassword(hash, password); err != nil || !ok {
t.Errorf("VerifyPassword = %v, %v", ok, err)
}
}
// TestDummyPasswordHashIsUsable: the dummy hash exists so that a login for
// an account that does not exist costs the same as one that does. If it ever
// stopped being a valid hash, that work would vanish and the timing would
// leak which user names are real.
func TestDummyPasswordHashIsUsable(t *testing.T) {
ok, err := VerifyPassword(dummyPasswordHash, "anything")
if err != nil {
t.Fatalf("the dummy hash is not verifiable: %v", err)
}
if ok {
t.Error("the dummy hash matched a password")
}
}
+100
View File
@@ -0,0 +1,100 @@
package userapi
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
)
// Token prefixes. Matrix tokens are opaque to clients, so the prefix is for
// our side: it makes a token recognisable in a log or a bug report, it lets
// secret scanners match on something, and it means a client that sends its
// refresh token where the access token belongs gets a clear rejection
// instead of a puzzling one.
const (
accessTokenPrefix = "warp_at_"
refreshTokenPrefix = "warp_rt_"
)
// tokenEntropyBytes is the random part of a token: 256 bits, the same order
// as the session cookies of anything else worth attacking.
const tokenEntropyBytes = 32
// deviceIDLength is how many characters a generated device ID has. Ten
// uppercase letters is the shape clients and users are used to seeing, and
// 26^10 is far more than a user will ever have devices.
const deviceIDLength = 10
// deviceIDAlphabet is uppercase ASCII only: device IDs are read aloud during
// device verification, so digits and letters that look alike are a nuisance.
const deviceIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// GenerateAccessToken returns a new access token.
func GenerateAccessToken() (string, error) { return generateToken(accessTokenPrefix) }
// GenerateRefreshToken returns a new refresh token.
func GenerateRefreshToken() (string, error) { return generateToken(refreshTokenPrefix) }
func generateToken(prefix string) (string, error) {
raw := make([]byte, tokenEntropyBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate token: %w", err)
}
// URL-safe and unpadded: tokens travel in query strings as well as in
// the Authorization header.
return prefix + base64.RawURLEncoding.EncodeToString(raw), nil
}
// HashToken returns the stored form of a token: lowercase hex SHA-256.
//
// The database holds hashes rather than tokens so that a dump cannot be
// replayed as a set of live sessions. SHA-256 with no salt or stretching is
// the right choice here, unlike for passwords: the input is 256 bits of
// uniform randomness, so there is no dictionary to try, and the hash is on
// the path of every authenticated request.
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// GenerateDeviceID returns a random device ID for a login that did not name
// one.
func GenerateDeviceID() (string, error) {
raw := make([]byte, deviceIDLength)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate device ID: %w", err)
}
out := make([]byte, deviceIDLength)
for i, b := range raw {
// The alphabet has 26 letters and a byte has 256 values, so the
// modulo is very slightly biased towards the first four letters.
// For a name whose only job is to be unique among a user's handful
// of devices, that is not worth a rejection loop.
out[i] = deviceIDAlphabet[int(b)%len(deviceIDAlphabet)]
}
return string(out), nil
}
// MaxDeviceIDLength caps a client-supplied device ID. The spec sets no
// limit, but the column is a primary key and an unbounded one invites abuse.
const MaxDeviceIDLength = 255
// ValidateDeviceID checks a client-supplied device ID. Device IDs are opaque
// strings, so the only rules are that it is non-empty, short enough, and
// free of control characters that would mangle a log line.
func ValidateDeviceID(deviceID string) error {
if deviceID == "" {
return fmt.Errorf("%w: device_id must not be empty", ErrInvalidDeviceID)
}
if len(deviceID) > MaxDeviceIDLength {
return fmt.Errorf("%w: device_id must be at most %d bytes", ErrInvalidDeviceID, MaxDeviceIDLength)
}
for _, r := range deviceID {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("%w: device_id must not contain control characters", ErrInvalidDeviceID)
}
}
return nil
}
+527
View File
@@ -0,0 +1,527 @@
// Package userapi owns accounts, passwords, devices, tokens and account
// data: everything that identifies a local user and their sessions.
//
// It is the component the Client-Server API handlers call for anything
// to do with who is making a request. The HTTP shapes live in clientapi;
// what is here is the part that would be the same behind any transport —
// which is also what makes `warp create-account` able to create an account
// without starting a server.
//
// Nothing in this package knows about rooms. Devices get their encryption
// keys in Phase 7 (keyserver) and their push rules in Phase 8.
package userapi
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Errors callers have to tell apart. The storage sentinels are re-exported
// rather than translated: there is one error value with two names, so
// errors.Is works whichever package a caller reaches for.
var (
ErrUserExists = storage.ErrUserExists
ErrNoSuchUser = storage.ErrNoSuchUser
ErrNoSuchDevice = storage.ErrNoSuchDevice
ErrNoSuchToken = storage.ErrNoSuchToken
ErrNoSuchAccountData = storage.ErrNoSuchAccountData
// ErrTokenExpired is an access token that was valid and has timed out.
// It is distinct from ErrNoSuchToken because the spec answers it with a
// soft logout: the client may keep its encryption state and log in
// again.
ErrTokenExpired = errors.New("access token has expired")
// ErrAccountDeactivated is a token belonging to an account that has
// been deactivated.
ErrAccountDeactivated = errors.New("account has been deactivated")
// ErrWrongPassword is a failed password check. It is deliberately the
// same error whether the account exists or not, so that callers cannot
// turn it into a user-enumeration oracle by accident.
ErrWrongPassword = errors.New("invalid password")
// ErrNoPassword is an account that has no password set and so cannot
// use m.login.password.
ErrNoPassword = errors.New("account has no password")
// ErrInvalidDeviceID is a client-supplied device ID that is empty, too
// long or contains control characters.
ErrInvalidDeviceID = errors.New("invalid device ID")
)
// API is the userapi component.
type API struct {
serverName string
store storage.UserStore
log *slog.Logger
now func() time.Time
accessTokenLifetime time.Duration
refreshTokenLifetime time.Duration
// lastSeen throttles the last-seen bookkeeping; see RecordActivity.
lastSeen sync.Map
}
// Options configure the component.
type Options struct {
// ServerName is the Matrix server name; it becomes the domain part of
// every user ID this component hands out.
ServerName string
// Store is the persistence layer.
Store storage.UserStore
// AccessTokenLifetime expires issued access tokens. Zero issues tokens
// that never expire.
AccessTokenLifetime time.Duration
// RefreshTokenLifetime bounds how long a refresh token stays usable.
// Zero means no expiry.
RefreshTokenLifetime time.Duration
Logger *slog.Logger
// Now is the clock, so tests can control token expiry. Nil means
// time.Now.
Now func() time.Time
}
// New builds the component.
func New(opts Options) (*API, error) {
if opts.ServerName == "" {
return nil, errors.New("userapi: ServerName must not be empty")
}
if opts.Store == nil {
return nil, errors.New("userapi: Store must not be nil")
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.Now == nil {
opts.Now = time.Now
}
return &API{
serverName: opts.ServerName,
store: opts.Store,
log: opts.Logger,
now: opts.Now,
accessTokenLifetime: opts.AccessTokenLifetime,
refreshTokenLifetime: opts.RefreshTokenLifetime,
}, nil
}
// ServerName returns the server name user IDs are built with.
func (a *API) ServerName() string { return a.serverName }
// nowMS is the current time in the unit the store uses.
func (a *API) nowMS() int64 { return a.now().UnixMilli() }
// NewAccountRequest describes an account to create.
type NewAccountRequest struct {
// Localpart is normalised and validated by CreateAccount.
Localpart string
// Password may be empty, which creates an account that cannot log in
// with a password. `warp create-account` uses that for a placeholder
// account; the admin API does not offer it.
Password string
DisplayName string
Admin bool
}
// CreateAccount validates the request, hashes the password and stores the
// account. It returns ErrUserExists if the name is taken and
// ErrInvalidLocalpart if the name is not usable.
func (a *API) CreateAccount(ctx context.Context, req NewAccountRequest) (storage.Account, error) {
localpart := NormaliseLocalpart(req.Localpart)
if err := ValidateLocalpart(localpart, a.serverName); err != nil {
return storage.Account{}, err
}
var hash string
if req.Password != "" {
var err error
if hash, err = HashPassword(req.Password); err != nil {
return storage.Account{}, err
}
}
acc, err := a.store.CreateAccount(ctx, storage.NewAccount{
Localpart: localpart,
PasswordHash: hash,
DisplayName: req.DisplayName,
Admin: req.Admin,
CreatedAtMS: a.nowMS(),
})
if err != nil {
return storage.Account{}, err
}
a.log.Info("created account", "user_id", a.UserID(localpart), "admin", req.Admin)
return acc, nil
}
// Account reads an account by normalised localpart.
func (a *API) Account(ctx context.Context, localpart string) (storage.Account, error) {
return a.store.Account(ctx, localpart)
}
// AccountExists reports whether a localpart is taken. It is the answer
// behind /register/available, and deliberately does not distinguish a
// deactivated account: the name stays reserved.
func (a *API) AccountExists(ctx context.Context, localpart string) (bool, error) {
switch _, err := a.store.Account(ctx, localpart); {
case err == nil:
return true, nil
case errors.Is(err, ErrNoSuchUser):
return false, nil
default:
return false, err
}
}
// SetAdmin grants or revokes the admin flag.
func (a *API) SetAdmin(ctx context.Context, localpart string, admin bool) error {
return a.store.SetAdmin(ctx, localpart, admin)
}
// SetPassword replaces an account's password.
func (a *API) SetPassword(ctx context.Context, localpart, password string) error {
hash, err := HashPassword(password)
if err != nil {
return err
}
return a.store.SetPasswordHash(ctx, localpart, hash)
}
// CheckPassword verifies a password against an account.
//
// Every failure path costs one argon2id hash, including an account that does
// not exist or has no password, so the time a rejection takes says nothing
// about which user names are real.
func (a *API) CheckPassword(ctx context.Context, localpart, password string) error {
hash, err := a.store.PasswordHash(ctx, localpart)
switch {
case errors.Is(err, ErrNoSuchUser):
_, _ = VerifyPassword(dummyPasswordHash, password)
return ErrWrongPassword
case err != nil:
return err
}
if hash == "" {
_, _ = VerifyPassword(dummyPasswordHash, password)
return ErrNoPassword
}
ok, err := VerifyPassword(hash, password)
switch {
case errors.Is(err, ErrPasswordTooLong):
return ErrWrongPassword
case err != nil:
// A malformed hash is an operational problem, not a wrong password.
a.log.Error("stored password hash is unusable", "localpart", localpart, "error", err)
return err
case !ok:
return ErrWrongPassword
}
return nil
}
// SessionRequest asks for a new device and its tokens.
type SessionRequest struct {
// Localpart must already be normalised and must exist.
Localpart string
// DeviceID is the client's choice. Empty means generate one. Naming a
// device that already exists re-uses it and revokes its old tokens.
DeviceID string
// DisplayName is initial_device_display_name: used only when the device
// is created.
DisplayName string
// WithRefreshToken issues a refresh token alongside the access token.
WithRefreshToken bool
IP string
UserAgent string
}
// Session is the result of a login, a registration or a token refresh: what
// the client needs to start making requests.
type Session struct {
UserID string
Localpart string
DeviceID string
AccessToken string
// RefreshToken is empty unless the client asked for one.
RefreshToken string
// ExpiresInMS is how long the access token is good for, or 0 when it
// does not expire.
ExpiresInMS int64
}
// CreateSession issues a device and tokens for an existing account.
func (a *API) CreateSession(ctx context.Context, req SessionRequest) (Session, error) {
deviceID := req.DeviceID
if deviceID == "" {
var err error
if deviceID, err = GenerateDeviceID(); err != nil {
return Session{}, err
}
} else if err := ValidateDeviceID(deviceID); err != nil {
return Session{}, err
}
accessToken, err := GenerateAccessToken()
if err != nil {
return Session{}, err
}
var refreshToken string
if req.WithRefreshToken {
if refreshToken, err = GenerateRefreshToken(); err != nil {
return Session{}, err
}
}
now := a.nowMS()
session := storage.NewSession{
Localpart: req.Localpart,
DeviceID: deviceID,
DisplayName: req.DisplayName,
CreatedAtMS: now,
AccessTokenHash: HashToken(accessToken),
AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime),
RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime),
LastSeenIP: req.IP,
UserAgent: req.UserAgent,
}
if refreshToken != "" {
session.RefreshTokenHash = HashToken(refreshToken)
}
device, err := a.store.CreateSession(ctx, session)
if err != nil {
return Session{}, err
}
return Session{
UserID: a.UserID(req.Localpart),
Localpart: req.Localpart,
DeviceID: device.ID,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresInMS: lifetimeMS(a.accessTokenLifetime),
}, nil
}
// RefreshSession spends a refresh token and issues a new pair. It returns
// ErrNoSuchToken if the token is unknown or already spent.
func (a *API) RefreshSession(ctx context.Context, refreshToken string) (Session, error) {
accessToken, err := GenerateAccessToken()
if err != nil {
return Session{}, err
}
newRefreshToken, err := GenerateRefreshToken()
if err != nil {
return Session{}, err
}
now := a.nowMS()
info, err := a.store.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: HashToken(refreshToken),
NewAccessTokenHash: HashToken(accessToken),
AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime),
NewRefreshTokenHash: HashToken(newRefreshToken),
RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime),
RefreshedAtMS: now,
})
if err != nil {
return Session{}, err
}
if info.Deactivated {
return Session{}, ErrAccountDeactivated
}
return Session{
UserID: a.UserID(info.Localpart),
Localpart: info.Localpart,
DeviceID: info.DeviceID,
AccessToken: accessToken,
RefreshToken: newRefreshToken,
ExpiresInMS: lifetimeMS(a.accessTokenLifetime),
}, nil
}
// Authenticated is the identity behind a request: what the auth middleware
// resolves an access token into and what every authenticated handler is
// given.
type Authenticated struct {
UserID string
Localpart string
DeviceID string
Admin bool
}
// Authenticate resolves an access token.
//
// It returns ErrNoSuchToken for an unknown token, ErrTokenExpired for one
// that has timed out, and ErrAccountDeactivated for a token whose account is
// gone. The caller turns those into M_UNKNOWN_TOKEN, a soft logout, and
// M_USER_DEACTIVATED respectively.
func (a *API) Authenticate(ctx context.Context, accessToken string) (Authenticated, error) {
if accessToken == "" {
return Authenticated{}, ErrNoSuchToken
}
info, err := a.store.TokenInfo(ctx, HashToken(accessToken))
if err != nil {
return Authenticated{}, err
}
if info.ExpiresAtMS != 0 && info.ExpiresAtMS <= a.nowMS() {
return Authenticated{}, ErrTokenExpired
}
if info.Deactivated {
return Authenticated{}, ErrAccountDeactivated
}
return Authenticated{
UserID: a.UserID(info.Localpart),
Localpart: info.Localpart,
DeviceID: info.DeviceID,
Admin: info.Admin,
}, nil
}
// lastSeenInterval is how stale the last-seen record of a device may get.
// Recording it on every request would double the write load of the whole C-S
// API to keep a field nothing reads more often than a user opens their
// device list.
const lastSeenInterval = time.Minute
// RecordActivity updates a device's last-seen timestamp, address and user
// agent, at most once per lastSeenInterval per device. A failure is logged
// and swallowed: the request it belongs to has already been authorised, and
// bookkeeping must not turn into a 500.
func (a *API) RecordActivity(ctx context.Context, auth Authenticated, ip, userAgent string) {
key := auth.Localpart + "\x00" + auth.DeviceID
now := a.now()
if previous, ok := a.lastSeen.Load(key); ok {
if at, ok := previous.(time.Time); ok && now.Sub(at) < lastSeenInterval {
return
}
}
a.lastSeen.Store(key, now)
if err := a.store.TouchDevice(ctx, auth.Localpart, auth.DeviceID, ip, userAgent, now.UnixMilli()); err != nil {
a.log.Warn("recording device activity failed",
"user_id", auth.UserID, "device_id", auth.DeviceID, "error", err)
}
}
// Device reads one of a user's devices.
func (a *API) Device(ctx context.Context, localpart, deviceID string) (storage.Device, error) {
return a.store.Device(ctx, localpart, deviceID)
}
// Devices lists a user's devices.
func (a *API) Devices(ctx context.Context, localpart string) ([]storage.Device, error) {
return a.store.Devices(ctx, localpart)
}
// SetDeviceDisplayName renames a device.
func (a *API) SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error {
return a.store.SetDeviceDisplayName(ctx, localpart, deviceID, displayName)
}
// DeleteDevices revokes devices and their tokens. It is what /logout,
// /delete_devices and DELETE /devices/{id} all end up calling.
func (a *API) DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) error {
if _, err := a.store.DeleteDevices(ctx, localpart, deviceIDs); err != nil {
return err
}
a.forgetLastSeen(localpart, deviceIDs...)
return nil
}
// DeleteAllDevices revokes every device of a user: /logout/all.
func (a *API) DeleteAllDevices(ctx context.Context, localpart string) error {
if _, err := a.store.DeleteAllDevices(ctx, localpart); err != nil {
return err
}
a.forgetLastSeen(localpart)
return nil
}
// forgetLastSeen drops the throttle entries for devices that no longer
// exist, so the map does not grow with every logout. With no device IDs it
// clears every entry of the user.
func (a *API) forgetLastSeen(localpart string, deviceIDs ...string) {
if len(deviceIDs) > 0 {
for _, id := range deviceIDs {
a.lastSeen.Delete(localpart + "\x00" + id)
}
return
}
prefix := localpart + "\x00"
a.lastSeen.Range(func(key, _ any) bool {
if s, ok := key.(string); ok && len(s) > len(prefix) && s[:len(prefix)] == prefix {
a.lastSeen.Delete(key)
}
return true
})
}
// SetDisplayName updates a user's profile display name.
func (a *API) SetDisplayName(ctx context.Context, localpart, displayName string) error {
return a.store.SetDisplayName(ctx, localpart, displayName)
}
// SetAvatarURL updates a user's profile avatar.
func (a *API) SetAvatarURL(ctx context.Context, localpart, avatarURL string) error {
return a.store.SetAvatarURL(ctx, localpart, avatarURL)
}
// AccountData reads one account data blob; roomID is empty for global data.
func (a *API) AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error) {
return a.store.AccountData(ctx, localpart, roomID, dataType)
}
// SetAccountData writes one account data blob.
func (a *API) SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error {
return a.store.SetAccountData(ctx, localpart, roomID, dataType, content)
}
// ListAccounts returns a page of accounts for the admin API.
func (a *API) ListAccounts(ctx context.Context, filter storage.AccountFilter) ([]storage.Account, int, error) {
return a.store.ListAccounts(ctx, filter)
}
// Stats returns the aggregate user counts for the admin API.
func (a *API) Stats(ctx context.Context) (storage.Stats, error) {
return a.store.Stats(ctx)
}
// expiryMS turns a lifetime into an absolute deadline in the store's unit,
// or 0 for "never".
func expiryMS(nowMS int64, lifetime time.Duration) int64 {
if lifetime <= 0 {
return 0
}
return nowMS + lifetime.Milliseconds()
}
// lifetimeMS is the expires_in_ms a client is told, or 0 for "never".
func lifetimeMS(lifetime time.Duration) int64 {
if lifetime <= 0 {
return 0
}
return lifetime.Milliseconds()
}
+323
View File
@@ -0,0 +1,323 @@
package userapi_test
import (
"errors"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
const testServerName = "warp.example"
// fixedClock is a clock a test can wind forward, so token expiry does not
// have to be waited for.
type fixedClock struct{ at time.Time }
func (c *fixedClock) now() time.Time { return c.at }
func (c *fixedClock) add(d time.Duration) { c.at = c.at.Add(d) }
// newTestAPI builds a userapi on a throwaway SQLite database.
func newTestAPI(t *testing.T, tweak func(*userapi.Options)) (*userapi.API, *fixedClock) {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db"))
db, err := storage.Open(t.Context(), storage.Config{
Driver: storage.DriverSQLite,
ConnectionString: "file:" + path,
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("open storage: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("migrate: %v", err)
}
clock := &fixedClock{at: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
opts := userapi.Options{
ServerName: testServerName,
Store: db.Users(),
Logger: slog.New(slog.DiscardHandler),
Now: clock.now,
}
if tweak != nil {
tweak(&opts)
}
api, err := userapi.New(opts)
if err != nil {
t.Fatalf("userapi.New: %v", err)
}
return api, clock
}
func TestNewRejectsIncompleteOptions(t *testing.T) {
if _, err := userapi.New(userapi.Options{Store: nil, ServerName: "x"}); err == nil {
t.Error("a userapi without a store must not be built")
}
if _, err := userapi.New(userapi.Options{ServerName: ""}); err == nil {
t.Error("a userapi without a server name must not be built")
}
}
func TestCreateAccountNormalisesAndValidates(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
acc, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "Alice",
Password: "sUp3rs3kr1t",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if acc.Localpart != "alice" {
t.Errorf("localpart = %q, want alice", acc.Localpart)
}
if api.UserID(acc.Localpart) != "@alice:warp.example" {
t.Errorf("UserID = %q", api.UserID(acc.Localpart))
}
// Normalising on the way in is what makes the name really taken.
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "ALICE", Password: "x",
}); !errors.Is(err, userapi.ErrUserExists) {
t.Errorf("creating @alice twice = %v, want ErrUserExists", err)
}
for name, localpart := range map[string]string{
"empty": "",
"illegal rune": "not valid",
"non-ASCII": "ünïcode",
"colon": "a:b",
"too long": strings.Repeat("a", 250),
} {
t.Run(name, func(t *testing.T) {
_, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: localpart, Password: "x"})
if !errors.Is(err, userapi.ErrInvalidLocalpart) {
t.Errorf("CreateAccount(%q) = %v, want ErrInvalidLocalpart", localpart, err)
}
})
}
}
// TestCheckPasswordRejectsEveryWayTheSame keeps the login path from becoming
// a user directory.
func TestCheckPasswordRejectsEveryWayTheSame(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "alice", Password: "sUp3rs3kr1t",
}); err != nil {
t.Fatal(err)
}
// An account with no password: `warp create-account` can make one.
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "nopassword"}); err != nil {
t.Fatal(err)
}
if err := api.CheckPassword(ctx, "alice", "sUp3rs3kr1t"); err != nil {
t.Errorf("the right password was rejected: %v", err)
}
if err := api.CheckPassword(ctx, "alice", "wrong"); !errors.Is(err, userapi.ErrWrongPassword) {
t.Errorf("wrong password = %v, want ErrWrongPassword", err)
}
// An unknown account is ErrWrongPassword, not ErrNoSuchUser: the
// caller must not be able to tell the difference.
if err := api.CheckPassword(ctx, "nobody", "sUp3rs3kr1t"); !errors.Is(err, userapi.ErrWrongPassword) {
t.Errorf("unknown account = %v, want ErrWrongPassword", err)
}
if err := api.CheckPassword(ctx, "nopassword", "anything"); !errors.Is(err, userapi.ErrNoPassword) {
t.Errorf("passwordless account = %v, want ErrNoPassword", err)
}
}
func TestSetPassword(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "alice", Password: "first",
}); err != nil {
t.Fatal(err)
}
if err := api.SetPassword(ctx, "alice", "second"); err != nil {
t.Fatalf("SetPassword: %v", err)
}
if err := api.CheckPassword(ctx, "alice", "second"); err != nil {
t.Errorf("the new password does not work: %v", err)
}
if err := api.CheckPassword(ctx, "alice", "first"); !errors.Is(err, userapi.ErrWrongPassword) {
t.Error("the old password still works")
}
}
func TestAuthenticate(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "alice", Password: "x", Admin: true,
}); err != nil {
t.Fatal(err)
}
session, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice"})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if session.UserID != "@alice:warp.example" || session.DeviceID == "" || session.AccessToken == "" {
t.Fatalf("CreateSession returned %+v", session)
}
auth, err := api.Authenticate(ctx, session.AccessToken)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if auth.UserID != session.UserID || auth.DeviceID != session.DeviceID || !auth.Admin {
t.Errorf("Authenticate = %+v", auth)
}
for name, token := range map[string]string{"empty": "", "unknown": "warp_at_nope"} {
t.Run(name, func(t *testing.T) {
if _, err := api.Authenticate(ctx, token); !errors.Is(err, userapi.ErrNoSuchToken) {
t.Errorf("Authenticate(%q) = %v, want ErrNoSuchToken", token, err)
}
})
}
}
// TestAccessTokenExpiry covers the lifetime configuration and the reason
// expiry is reported separately: it becomes a soft logout, not a hard one.
func TestAccessTokenExpiry(t *testing.T) {
api, clock := newTestAPI(t, func(o *userapi.Options) {
o.AccessTokenLifetime = time.Hour
})
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
session, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice"})
if err != nil {
t.Fatal(err)
}
if session.ExpiresInMS != time.Hour.Milliseconds() {
t.Errorf("ExpiresInMS = %d, want one hour", session.ExpiresInMS)
}
if _, err := api.Authenticate(ctx, session.AccessToken); err != nil {
t.Fatalf("a fresh token must work: %v", err)
}
clock.add(time.Hour + time.Second)
if _, err := api.Authenticate(ctx, session.AccessToken); !errors.Is(err, userapi.ErrTokenExpired) {
t.Errorf("Authenticate of an expired token = %v, want ErrTokenExpired", err)
}
}
func TestRefreshSessionRequiresARefreshToken(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
// A session that did not ask for one cannot be refreshed.
plain, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice"})
if err != nil {
t.Fatal(err)
}
if plain.RefreshToken != "" {
t.Error("a session that did not ask for a refresh token got one")
}
if _, err := api.RefreshSession(ctx, "warp_rt_nope"); !errors.Is(err, userapi.ErrNoSuchToken) {
t.Errorf("RefreshSession with an unknown token = %v, want ErrNoSuchToken", err)
}
withRefresh, err := api.CreateSession(ctx, userapi.SessionRequest{
Localpart: "alice", WithRefreshToken: true,
})
if err != nil {
t.Fatal(err)
}
refreshed, err := api.RefreshSession(ctx, withRefresh.RefreshToken)
if err != nil {
t.Fatalf("RefreshSession: %v", err)
}
if refreshed.DeviceID != withRefresh.DeviceID {
t.Errorf("refreshing changed the device: %s to %s", withRefresh.DeviceID, refreshed.DeviceID)
}
if refreshed.AccessToken == withRefresh.AccessToken {
t.Error("refreshing returned the same access token")
}
}
// TestRecordActivityIsThrottled covers the decision not to write to the
// database on every authenticated request.
func TestRecordActivityIsThrottled(t *testing.T) {
api, clock := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
session, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice", IP: "10.0.0.1"})
if err != nil {
t.Fatal(err)
}
auth, err := api.Authenticate(ctx, session.AccessToken)
if err != nil {
t.Fatal(err)
}
api.RecordActivity(ctx, auth, "10.0.0.2", "first")
device, err := api.Device(ctx, "alice", session.DeviceID)
if err != nil {
t.Fatal(err)
}
firstSeen := device.LastSeenMS
if device.LastSeenIP != "10.0.0.2" {
t.Errorf("last_seen_ip = %q, want 10.0.0.2", device.LastSeenIP)
}
// A second visit a moment later is not written.
clock.add(time.Second)
api.RecordActivity(ctx, auth, "10.0.0.3", "second")
if device, err = api.Device(ctx, "alice", session.DeviceID); err != nil {
t.Fatal(err)
}
if device.LastSeenIP != "10.0.0.2" || device.LastSeenMS != firstSeen {
t.Errorf("a visit one second later was written: %+v", device)
}
// A later one is.
clock.add(2 * time.Minute)
api.RecordActivity(ctx, auth, "10.0.0.4", "third")
if device, err = api.Device(ctx, "alice", session.DeviceID); err != nil {
t.Fatal(err)
}
if device.LastSeenIP != "10.0.0.4" || device.LastSeenMS <= firstSeen {
t.Errorf("a visit two minutes later was not written: %+v", device)
}
}
func TestAccountExists(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
exists, err := api.AccountExists(ctx, "alice")
if err != nil || exists {
t.Fatalf("AccountExists on an empty database = %v, %v", exists, err)
}
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
if exists, err = api.AccountExists(ctx, "alice"); err != nil || !exists {
t.Fatalf("AccountExists = %v, %v", exists, err)
}
}
+119
View File
@@ -0,0 +1,119 @@
package userapi
import (
"errors"
"fmt"
"strings"
)
// MaxUserIDLength is the spec's cap on a user ID, sigil and server name
// included.
const MaxUserIDLength = 255
// Errors from parsing and validating identifiers. Callers map these onto
// M_INVALID_USERNAME or M_UNKNOWN depending on the endpoint.
var (
// ErrInvalidLocalpart means the localpart is empty, too long, or
// contains a character the spec does not allow.
ErrInvalidLocalpart = errors.New("invalid user name")
// ErrInvalidUserID means the string is not a well-formed user ID.
ErrInvalidUserID = errors.New("invalid user ID")
// ErrForeignServer means a well-formed user ID belongs to another
// homeserver. Answering for it needs federation.
ErrForeignServer = errors.New("user ID belongs to another server")
)
// NormaliseLocalpart lower-cases a localpart.
//
// The spec's grammar for the localpart of a user ID allows only lowercase
// letters, so a client sending "User-UPPER" is asking for "user-upper"
// rather than making a mistake. Both registration and login normalise, which
// is also what Synapse does and what Complement checks.
func NormaliseLocalpart(localpart string) string {
return strings.ToLower(localpart)
}
// ValidateLocalpart checks a normalised localpart against the user ID
// grammar of the Client-Server API: lowercase letters, digits, and the
// characters . _ = - / +.
//
// serverName is needed because the 255-byte limit applies to the whole user
// ID, so how long a localpart may be depends on where it lives.
func ValidateLocalpart(localpart, serverName string) error {
if localpart == "" {
return fmt.Errorf("%w: must not be empty", ErrInvalidLocalpart)
}
if n := len("@" + localpart + ":" + serverName); n > MaxUserIDLength {
return fmt.Errorf("%w: the resulting user ID would be %d bytes, the limit is %d",
ErrInvalidLocalpart, n, MaxUserIDLength)
}
for _, r := range localpart {
if !isLocalpartRune(r) {
return fmt.Errorf("%w: %q is not allowed; use lowercase letters, digits or . _ = - / +",
ErrInvalidLocalpart, r)
}
}
return nil
}
// isLocalpartRune reports whether r may appear in a user ID localpart. The
// range is deliberately byte-oriented: the grammar is ASCII only, so any
// multi-byte rune falls through to false.
func isLocalpartRune(r rune) bool {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return true
case r == '.', r == '_', r == '=', r == '-', r == '/', r == '+':
return true
default:
return false
}
}
// UserID assembles "@localpart:serverName".
func (a *API) UserID(localpart string) string {
return "@" + localpart + ":" + a.serverName
}
// ParseLocalUserID turns a user identifier into a normalised localpart on
// this server.
//
// It accepts both forms clients send: a full "@user:server" ID and a bare
// localpart, which the login endpoint has always allowed. A full ID naming
// another server is ErrForeignServer — a distinct case from a malformed one,
// because that is the answer federation would change.
func (a *API) ParseLocalUserID(identifier string) (string, error) {
if identifier == "" {
return "", fmt.Errorf("%w: must not be empty", ErrInvalidUserID)
}
if !strings.HasPrefix(identifier, "@") {
localpart := NormaliseLocalpart(identifier)
if err := ValidateLocalpart(localpart, a.serverName); err != nil {
return "", err
}
return localpart, nil
}
if len(identifier) > MaxUserIDLength {
return "", fmt.Errorf("%w: longer than %d bytes", ErrInvalidUserID, MaxUserIDLength)
}
localpart, serverName, ok := strings.Cut(identifier[1:], ":")
if !ok || serverName == "" {
return "", fmt.Errorf("%w: expected @localpart:servername", ErrInvalidUserID)
}
// Server names are compared case-insensitively for the same reason the
// federation allowlist is: every real deployment uses a DNS name, and
// treating "Warp.example" as somebody else would turn a client's
// capitalisation into a login failure.
if !strings.EqualFold(serverName, a.serverName) {
return "", fmt.Errorf("%w: %s", ErrForeignServer, serverName)
}
localpart = NormaliseLocalpart(localpart)
if err := ValidateLocalpart(localpart, a.serverName); err != nil {
return "", err
}
return localpart, nil
}
+203
View File
@@ -0,0 +1,203 @@
package userapi_test
import (
"errors"
"strings"
"testing"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
func TestNormaliseLocalpart(t *testing.T) {
for input, want := range map[string]string{
"alice": "alice",
"Alice": "alice",
"USER-UPPER": "user-upper",
"": "",
} {
if got := userapi.NormaliseLocalpart(input); got != want {
t.Errorf("NormaliseLocalpart(%q) = %q, want %q", input, got, want)
}
}
}
func TestValidateLocalpart(t *testing.T) {
// The grammar the Client-Server API gives for a user ID localpart.
for _, localpart := range []string{
"alice", "user-upper", "a", "0", "chrtestuserq", "chrtestuser3",
"chrtestuser.", "chrtestuser_", "chrtestuser=", "chrtestuser-",
"chrtestuser/", "chrtestuser+",
} {
if err := userapi.ValidateLocalpart(localpart, "warp.example"); err != nil {
t.Errorf("ValidateLocalpart(%q) = %v, want nil", localpart, err)
}
}
for name, localpart := range map[string]string{
"empty": "",
"uppercase": "Alice",
"space": "a b",
"exclamation": "a!b",
"quote": `a"b`,
"colon": "a:b",
"question": "a?b",
"backslash": `a\b`,
"at sign": "a@b",
"bracket": "a[b",
"brace": "a{b",
"pipe": "a|b",
"pound": "a£b",
"accent": "aéb",
"newline": "a\nb",
"apostrophe": "a'b",
"comma": "a,b",
} {
t.Run(name, func(t *testing.T) {
if err := userapi.ValidateLocalpart(localpart, "warp.example"); !errors.Is(err, userapi.ErrInvalidLocalpart) {
t.Errorf("ValidateLocalpart(%q) = %v, want ErrInvalidLocalpart", localpart, err)
}
})
}
}
// TestValidateLocalpartLengthDependsOnTheServerName: the 255-byte limit is
// on the whole user ID, so a long server name leaves less room for a name.
func TestValidateLocalpartLengthDependsOnTheServerName(t *testing.T) {
const shortServer = "a.example"
// "@" + localpart + ":" + server must be at most 255 bytes.
longest := strings.Repeat("x", userapi.MaxUserIDLength-len("@:"+shortServer))
if err := userapi.ValidateLocalpart(longest, shortServer); err != nil {
t.Errorf("a localpart exactly at the limit was rejected: %v", err)
}
if err := userapi.ValidateLocalpart(longest+"x", shortServer); !errors.Is(err, userapi.ErrInvalidLocalpart) {
t.Errorf("a localpart one byte over the limit = %v, want ErrInvalidLocalpart", err)
}
// The same localpart on a longer server name no longer fits.
if err := userapi.ValidateLocalpart(longest, "a-much-longer-server-name.example"); err == nil {
t.Error("the limit must count the server name")
}
}
func TestParseLocalUserID(t *testing.T) {
api, _ := newTestAPI(t, nil)
for name, tc := range map[string]struct {
input string
want string
}{
"full user ID": {"@alice:warp.example", "alice"},
"bare localpart": {"alice", "alice"},
"uppercase localpart": {"@ALICE:warp.example", "alice"},
"uppercase bare": {"Alice", "alice"},
"uppercase server name": {"@alice:WARP.Example", "alice"},
} {
t.Run(name, func(t *testing.T) {
got, err := api.ParseLocalUserID(tc.input)
if err != nil {
t.Fatalf("ParseLocalUserID(%q) = %v", tc.input, err)
}
if got != tc.want {
t.Errorf("ParseLocalUserID(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
// A user ID on another server is its own case: it is well formed, and
// federation is what would change the answer.
if _, err := api.ParseLocalUserID("@bob:other.example"); !errors.Is(err, userapi.ErrForeignServer) {
t.Errorf("a remote user ID = %v, want ErrForeignServer", err)
}
for name, input := range map[string]string{
"empty": "",
"no server name": "@alice",
"empty server": "@alice:",
"illegal rune": "@not valid:warp.example",
"bare illegal": "not valid",
} {
t.Run(name, func(t *testing.T) {
if _, err := api.ParseLocalUserID(input); err == nil {
t.Errorf("ParseLocalUserID(%q) returned no error", input)
}
})
}
}
func TestValidateDeviceID(t *testing.T) {
for _, deviceID := range []string{"ABCDEFGHIJ", "my_device_id", "device with spaces", "日本語"} {
if err := userapi.ValidateDeviceID(deviceID); err != nil {
t.Errorf("ValidateDeviceID(%q) = %v, want nil (device IDs are opaque)", deviceID, err)
}
}
for name, deviceID := range map[string]string{
"empty": "",
"too long": strings.Repeat("x", userapi.MaxDeviceIDLength+1),
"newline": "a\nb",
"control character": "a\x00b",
} {
t.Run(name, func(t *testing.T) {
if err := userapi.ValidateDeviceID(deviceID); !errors.Is(err, userapi.ErrInvalidDeviceID) {
t.Errorf("ValidateDeviceID(%q) = %v, want ErrInvalidDeviceID", deviceID, err)
}
})
}
}
func TestGenerateDeviceID(t *testing.T) {
seen := map[string]bool{}
for range 100 {
id, err := userapi.GenerateDeviceID()
if err != nil {
t.Fatalf("GenerateDeviceID: %v", err)
}
if err := userapi.ValidateDeviceID(id); err != nil {
t.Fatalf("a generated device ID is invalid: %v", err)
}
if seen[id] {
t.Fatalf("GenerateDeviceID repeated %q within 100 calls", id)
}
seen[id] = true
}
}
func TestTokensAreDistinctAndHashed(t *testing.T) {
access, err := userapi.GenerateAccessToken()
if err != nil {
t.Fatal(err)
}
refresh, err := userapi.GenerateRefreshToken()
if err != nil {
t.Fatal(err)
}
// The prefixes exist so that a client sending the wrong one gets a
// clear rejection, and so a token is recognisable in a log.
if !strings.HasPrefix(access, "warp_at_") {
t.Errorf("access token %q has no recognisable prefix", access)
}
if !strings.HasPrefix(refresh, "warp_rt_") {
t.Errorf("refresh token %q has no recognisable prefix", refresh)
}
hash := userapi.HashToken(access)
if hash == access || len(hash) != 64 {
t.Errorf("HashToken(%q) = %q, want a 64-character hex digest", access, hash)
}
if userapi.HashToken(access) != hash {
t.Error("HashToken is not deterministic")
}
if userapi.HashToken(refresh) == hash {
t.Error("two different tokens hashed the same")
}
second, err := userapi.GenerateAccessToken()
if err != nil {
t.Fatal(err)
}
if second == access {
t.Error("two generated access tokens are identical")
}
}
+60
View File
@@ -0,0 +1,60 @@
# Warp as a Complement homeserver image.
#
# Complement (https://github.com/matrix-org/complement) is the official
# Matrix integration suite: it runs one container per homeserver per test and
# drives it over the real HTTP API. Its image contract is short, and this
# file exists to meet it:
#
# - EXPOSE 8008 (client) and 8448 (federation).
# - Take the server name from the SERVER_NAME environment variable.
# - Keep storage inside the container.
# - Survive the entrypoint being run more than once.
# - Answer GET /_matrix/client/versions with a 200.
#
# Build it from the repository root, because the build context is the Go
# module one directory up:
#
# docker build -t complement-warp -f server/test/complement/Dockerfile .
#
# Federation (port 8448, served over TLS with a certificate signed by the CA
# Complement mounts at /complement/ca) arrives in Phase 5. Until then this
# image is for the federation=off profile: the C-S tests. The port is still
# exposed, because Complement expects it to be.
FROM golang:1.26-alpine AS build
# The workspace file is not copied, so the build must not look for one.
ENV GOWORK=off
ENV CGO_ENABLED=0
WORKDIR /src
# Dependencies first, so that editing Go sources does not re-download them.
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server/ ./
RUN go build -trimpath -o /out/warp ./cmd/warp
FROM alpine:3.21
# curl is what the health check uses; ca-certificates is for the outbound
# federation requests of Phase 5.
RUN apk add --no-cache ca-certificates curl
COPY --from=build /out/warp /usr/local/bin/warp
COPY server/test/complement/entrypoint.sh /usr/local/bin/warp-complement
RUN chmod +x /usr/local/bin/warp-complement
# Everything mutable lives here: the SQLite database and the signing key.
VOLUME /data
EXPOSE 8008 8448
# Complement waits for the container to become healthy before it starts a
# test, so the interval is short and the retries generous: a cold start has
# to run the migrations first.
HEALTHCHECK --interval=1s --timeout=2s --start-period=1s --retries=60 \
CMD curl -fsS http://localhost:8008/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/warp-complement"]
+62 -8
View File
@@ -1,13 +1,67 @@
# Complement
[Complement](https://github.com/matrix-org/complement) is the official
Matrix integration suite and the project's primary progress metric.
Matrix integration suite and this project's primary progress metric
(PLAN.md section 9). It runs one container per homeserver per test and
drives it over the real HTTP API.
It is wired up in Phase 1, once registration and login exist — before that
there is nothing for it to test. Two profiles are planned, per PLAN.md
section 9: `federation=off` (Client-Server tests only) and
`federation=on`.
```sh
make complement # the current target set
bash run.sh TestLogin # one test
COMPLEMENT_DEBUG=1 make complement # with the container logs
```
What will live here: the Dockerfile Complement boots, the image entrypoint
that generates a signing key and a config per test homeserver, and the
list of tests expected to pass.
Needs Docker. [run.sh](run.sh) builds the image, clones Complement into
`.complement/` at the repository root (git-ignored) and runs the subset for
the phases that are done.
## Files
| | |
|---|---|
| [Dockerfile](Dockerfile) | the homeserver image, built from the repository root |
| [entrypoint.sh](entrypoint.sh) | the deployment, entirely `WARP_*` environment variables |
| [run.sh](run.sh) | build, clone, run |
## Profiles
**`federation=off`** — what runs today. Only the Client-Server tests;
`WARP_FEDERATION_ENABLED=false`, port 8448 exposed but unserved.
**`federation=on`** — Phase 5. It needs a TLS listener on 8448 with a
certificate signed by the CA that Complement mounts at `/complement/ca`,
which is the first thing to add here when federation lands.
## The test set
`COMPLEMENT_TESTS` in `run.sh` lists what is expected to pass. Phase 1:
- `TestRegistration`
- `TestLogin`
- `TestDeviceManagement`
Extend it as each phase lands rather than switching to the whole suite: a
pass count that drops because a later phase is missing is noise, not
information.
Two notes on the environment, both in `entrypoint.sh`:
- **Rate limiting is off.** The whole suite comes from the one address the
test runner has, so the limiter would spend the run returning
`M_LIMIT_EXCEEDED` and tell us nothing about the server. Synapse's own
Complement image does the same.
- **Registration is on**, because the tests create their own users.
## Tests that need later phases
Some subtests of the three above reach for endpoints that are not Phase 1
work. `TestDeviceManagement` calls `/sync` after creating a session, and
`TestLogout` checks that a revoked token cannot sync. Phase 1 answers
`/sync` with an empty but well-formed response for exactly that reason —
there are no rooms yet, so an empty sync is the complete truth, and it makes
token revocation observable. Phase 4 replaces it with the real syncapi.
The shared-secret registration subtests of `TestRegistration` probe
`/_synapse/admin/v1/register` and skip themselves when it 404s, which it
does here: that endpoint is Synapse's, and `warp create-account` covers the
same need.
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# Start Warp the way Complement needs it.
#
# There is no configuration file: every setting Warp has can come from a
# WARP_* environment variable, so the whole deployment is the few lines
# below. Complement may run this entrypoint more than once against the same
# container, so everything here is idempotent.
set -eu
DATA_DIR="${WARP_DATA_DIR:-/data}"
mkdir -p "$DATA_DIR"
# Complement names the homeservers hs1, hs2, ... and passes the name in.
export WARP_SERVER_NAME="${SERVER_NAME:-hs1}"
export WARP_PRIVATE_KEY="$DATA_DIR/signing.key"
export WARP_DB_DRIVER=sqlite
export WARP_DB_CONNECTION_STRING="file:$DATA_DIR/warp.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)"
export WARP_DB_AUTO_MIGRATE=true
export WARP_LISTEN_ADDRESS=":8008"
# The suite registers and logs in constantly, all from the one address the
# test runner has. Rate limiting would turn that into a wall of
# M_LIMIT_EXCEEDED and tell us nothing about the server. Synapse's own
# Complement image does the same.
export WARP_RATE_LIMIT_ENABLED=false
# Open registration: the tests create their own users.
export WARP_REGISTRATION_ENABLED=true
# Federation stays off unless the profile asks for it. Phase 5 adds the
# TLS listener on 8448 that the federation profile needs; until then this
# image only serves the Client-Server API.
export WARP_FEDERATION_ENABLED="${WARP_FEDERATION_ENABLED:-false}"
# Complement captures the container's output, so text at debug level is the
# most useful thing to have when a test fails.
export WARP_LOG_LEVEL="${WARP_LOG_LEVEL:-debug}"
export WARP_LOG_FORMAT="${WARP_LOG_FORMAT:-text}"
if [ ! -f "$WARP_PRIVATE_KEY" ]; then
warp generate-keys --output "$WARP_PRIVATE_KEY"
fi
# Migrations also run on boot, but doing them here keeps a schema failure
# out of the request path and makes it obvious in the container log.
warp migrate
exec warp serve
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Run Complement against a freshly built Warp image.
#
# server/test/complement/run.sh # the Phase 1 test set
# server/test/complement/run.sh TestLogin # one test
# COMPLEMENT_TESTS='Test.*' server/test/complement/run.sh
#
# Complement is a separate Go module, so it has to be checked out and run on
# its own. This script keeps that checkout in a cache directory rather than
# vendoring it: it is a test harness we track, not a dependency we pin.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
: "${COMPLEMENT_REPO:=https://github.com/matrix-org/complement.git}"
: "${COMPLEMENT_REF:=main}"
: "${COMPLEMENT_DIR:=${REPO_ROOT}/.complement}"
: "${COMPLEMENT_IMAGE:=complement-warp}"
# The Phase 1 target set, from PLAN.md section 7. Add to it as phases land
# rather than switching to the whole suite: a pass count that goes down
# because a later phase is missing is noise.
: "${COMPLEMENT_TESTS:=TestRegistration|TestLogin|TestDeviceManagement}"
if [[ $# -gt 0 ]]; then
COMPLEMENT_TESTS="$(IFS='|'; echo "$*")"
fi
docker="${DOCKER:-docker}"
if ! command -v "$docker" >/dev/null 2>&1; then
echo "complement: $docker is not installed; Complement needs a container runtime" >&2
exit 1
fi
echo "complement: building $COMPLEMENT_IMAGE"
"$docker" build -t "$COMPLEMENT_IMAGE" -f "${REPO_ROOT}/server/test/complement/Dockerfile" "$REPO_ROOT"
if [[ ! -d "$COMPLEMENT_DIR" ]]; then
echo "complement: cloning $COMPLEMENT_REPO into $COMPLEMENT_DIR"
git clone --depth 1 --branch "$COMPLEMENT_REF" "$COMPLEMENT_REPO" "$COMPLEMENT_DIR"
else
git -C "$COMPLEMENT_DIR" fetch --depth 1 origin "$COMPLEMENT_REF"
git -C "$COMPLEMENT_DIR" checkout -q FETCH_HEAD
fi
echo "complement: running '${COMPLEMENT_TESTS}' with federation off"
cd "$COMPLEMENT_DIR"
# COMPLEMENT_SPAWN_HS_TIMEOUT_SECS is generous because the first container
# of a run has to apply the migrations on a cold SQLite file.
COMPLEMENT_BASE_IMAGE="$COMPLEMENT_IMAGE" \
COMPLEMENT_SPAWN_HS_TIMEOUT_SECS="${COMPLEMENT_SPAWN_HS_TIMEOUT_SECS:-60}" \
COMPLEMENT_DEBUG="${COMPLEMENT_DEBUG:-0}" \
go test -count=1 -v -timeout 20m -run "^(${COMPLEMENT_TESTS})$" ./tests/csapi/...
+46
View File
@@ -63,6 +63,43 @@ client_api:
# if only your own web client talks to it. WARP_CORS_ALLOWED_ORIGINS
cors_allowed_origins: ["*"]
# Take the client address from X-Forwarded-For instead of the socket.
# Turn this on ONLY behind a reverse proxy that overwrites the header:
# anyone can send it, and the rate limiter keys on the result.
# WARP_TRUST_X_FORWARDED_FOR
trust_x_forwarded_for: false
# Expire access tokens after this long. 0 (the default) issues tokens
# that never expire, which is what clients without refresh-token
# support need. With a lifetime set, clients that ask for
# refresh_token on login get one. WARP_ACCESS_TOKEN_LIFETIME
access_token_lifetime: 0s
# How long a refresh token may be exchanged for a new access token.
# 0 means no expiry. WARP_REFRESH_TOKEN_LIFETIME
refresh_token_lifetime: 0s
# Per-endpoint rate limiting. Each bucket allows `burst` requests back
# to back and returns one slot every `interval`, per client address.
# Login and registration get their own, tighter, buckets because they
# are the two endpoints worth brute-forcing.
#
# Only `enabled` has an environment override (the three buckets share a
# type, so per-bucket variables would collide): set
# WARP_RATE_LIMIT_ENABLED=false to turn the whole thing off, which is
# what a test harness such as Complement wants.
rate_limit:
enabled: true # WARP_RATE_LIMIT_ENABLED
default:
burst: 30
interval: 100ms
login:
burst: 10
interval: 1s
register:
burst: 5
interval: 2s
federation:
# Off by default: a new install is a private homeserver until you say
# otherwise. WARP_FEDERATION_ENABLED
@@ -81,9 +118,18 @@ federation:
allow_runtime_toggle: true
registration:
# Off by default. Make the first account with
# `warp create-account --username you --admin`, then turn registration
# on from the admin API if you want others to sign up.
enabled: false # WARP_REGISTRATION_ENABLED
allow_runtime_toggle: true # WARP_REGISTRATION_ALLOW_RUNTIME_TOGGLE
# Non-empty = registration also requires one of these tokens, as the
# m.login.registration_token stage of the sign-up flow. Per-token use
# counts and expiry arrive with admin token management in Phase 9.
# WARP_REGISTRATION_TOKENS
tokens: []
logging:
level: info # debug, info, warn, error WARP_LOG_LEVEL
format: text # text or json WARP_LOG_FORMAT