From 6004bb01de71cd6a5584666fd5b6a466b7b70e77 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:18:37 +0200 Subject: [PATCH] Phase 1: accounts and sessions 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 --- .github/workflows/ci.yml | 50 ++ .gitignore | 11 +- Makefile | 16 +- README.md | 60 +- docs/README.md | 3 +- docs/admin-api.md | 137 ++++ docs/configuration.md | 31 + docs/development.md | 56 +- server/cmd/warp/commands.go | 191 ++++++ server/cmd/warp/createaccount.go | 175 +++++ server/cmd/warp/main.go | 78 +++ server/go.mod | 8 +- server/go.sum | 20 +- server/internal/adminapi/adminapi.go | 316 +++++++++ server/internal/adminapi/adminapi_test.go | 412 +++++++++++ server/internal/adminapi/users.go | 149 ++++ server/internal/clientapi/accountdata.go | 154 +++++ server/internal/clientapi/accountdata_test.go | 167 +++++ server/internal/clientapi/auth.go | 101 +++ server/internal/clientapi/capabilities.go | 64 ++ server/internal/clientapi/clientapi.go | 204 ++++++ server/internal/clientapi/clientapi_test.go | 268 ++++++++ server/internal/clientapi/devices.go | 189 +++++ server/internal/clientapi/devices_test.go | 250 +++++++ server/internal/clientapi/login.go | 249 +++++++ server/internal/clientapi/login_test.go | 525 ++++++++++++++ server/internal/clientapi/profile.go | 157 +++++ server/internal/clientapi/profile_test.go | 147 ++++ server/internal/clientapi/register.go | 254 +++++++ server/internal/clientapi/register_test.go | 391 +++++++++++ server/internal/clientapi/sync.go | 73 ++ server/internal/clientapi/uia.go | 295 ++++++++ server/internal/clientapi/wellknown_test.go | 52 ++ server/internal/config/config.go | 107 +++ server/internal/config/phase1_test.go | 139 ++++ server/internal/httputil/errors.go | 30 + server/internal/httputil/json.go | 33 +- server/internal/ratelimit/ratelimit.go | 164 +++++ server/internal/ratelimit/ratelimit_test.go | 165 +++++ server/internal/setup/phase1_test.go | 169 +++++ server/internal/setup/routes.go | 12 +- server/internal/setup/server.go | 52 ++ .../internal/storage/pocketbase/pocketbase.go | 95 ++- server/internal/storage/postgres/errors.go | 44 ++ .../internal/storage/postgres/errors_test.go | 51 ++ .../postgres/migrations/0002_userapi.sql | 69 ++ server/internal/storage/postgres/postgres.go | 16 +- server/internal/storage/sqlite/errors.go | 31 + server/internal/storage/sqlite/errors_test.go | 60 ++ .../sqlite/migrations/0002_userapi.sql | 69 ++ server/internal/storage/sqlite/sqlite.go | 5 +- server/internal/storage/sqlstore/sqlstore.go | 15 + server/internal/storage/sqlstore/users.go | 644 ++++++++++++++++++ server/internal/storage/storage.go | 3 + server/internal/storage/users.go | 226 ++++++ server/internal/storage/users_test.go | 494 ++++++++++++++ server/internal/userapi/password.go | 137 ++++ server/internal/userapi/password_test.go | 160 +++++ server/internal/userapi/tokens.go | 100 +++ server/internal/userapi/userapi.go | 527 ++++++++++++++ server/internal/userapi/userapi_test.go | 323 +++++++++ server/internal/userapi/userid.go | 119 ++++ server/internal/userapi/userid_test.go | 203 ++++++ server/test/complement/Dockerfile | 60 ++ server/test/complement/README.md | 70 +- server/test/complement/entrypoint.sh | 50 ++ server/test/complement/run.sh | 54 ++ warp.yaml.example | 46 ++ 68 files changed, 9729 insertions(+), 66 deletions(-) create mode 100644 docs/admin-api.md create mode 100644 server/cmd/warp/commands.go create mode 100644 server/cmd/warp/createaccount.go create mode 100644 server/cmd/warp/main.go create mode 100644 server/internal/adminapi/adminapi.go create mode 100644 server/internal/adminapi/adminapi_test.go create mode 100644 server/internal/adminapi/users.go create mode 100644 server/internal/clientapi/accountdata.go create mode 100644 server/internal/clientapi/accountdata_test.go create mode 100644 server/internal/clientapi/auth.go create mode 100644 server/internal/clientapi/capabilities.go create mode 100644 server/internal/clientapi/clientapi.go create mode 100644 server/internal/clientapi/clientapi_test.go create mode 100644 server/internal/clientapi/devices.go create mode 100644 server/internal/clientapi/devices_test.go create mode 100644 server/internal/clientapi/login.go create mode 100644 server/internal/clientapi/login_test.go create mode 100644 server/internal/clientapi/profile.go create mode 100644 server/internal/clientapi/profile_test.go create mode 100644 server/internal/clientapi/register.go create mode 100644 server/internal/clientapi/register_test.go create mode 100644 server/internal/clientapi/sync.go create mode 100644 server/internal/clientapi/uia.go create mode 100644 server/internal/clientapi/wellknown_test.go create mode 100644 server/internal/config/phase1_test.go create mode 100644 server/internal/ratelimit/ratelimit.go create mode 100644 server/internal/ratelimit/ratelimit_test.go create mode 100644 server/internal/setup/phase1_test.go create mode 100644 server/internal/storage/postgres/errors.go create mode 100644 server/internal/storage/postgres/errors_test.go create mode 100644 server/internal/storage/postgres/migrations/0002_userapi.sql create mode 100644 server/internal/storage/sqlite/errors.go create mode 100644 server/internal/storage/sqlite/errors_test.go create mode 100644 server/internal/storage/sqlite/migrations/0002_userapi.sql create mode 100644 server/internal/storage/sqlstore/users.go create mode 100644 server/internal/storage/users.go create mode 100644 server/internal/storage/users_test.go create mode 100644 server/internal/userapi/password.go create mode 100644 server/internal/userapi/password_test.go create mode 100644 server/internal/userapi/tokens.go create mode 100644 server/internal/userapi/userapi.go create mode 100644 server/internal/userapi/userapi_test.go create mode 100644 server/internal/userapi/userid.go create mode 100644 server/internal/userapi/userid_test.go create mode 100644 server/test/complement/Dockerfile create mode 100755 server/test/complement/entrypoint.sh create mode 100755 server/test/complement/run.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cb3c7b..4e35737 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" diff --git a/.gitignore b/.gitignore index 27a1b4d..a7d1daa 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile index ac21c23..1b2b9a8 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 23886ed..d409f71 100644 --- a/README.md +++ b/README.md @@ -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 `. `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/ ``` diff --git a/docs/README.md b/docs/README.md index 8a618cf..6dc7d70 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/admin-api.md b/docs/admin-api.md new file mode 100644 index 0000000..50e1f84 --- /dev/null +++ b/docs/admin-api.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index 4b2904f..b0ccbf8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/docs/development.md b/docs/development.md index dbe8609..242b963 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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}' +``` diff --git a/server/cmd/warp/commands.go b/server/cmd/warp/commands.go new file mode 100644 index 0000000..9e69e38 --- /dev/null +++ b/server/cmd/warp/commands.go @@ -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 +} diff --git a/server/cmd/warp/createaccount.go b/server/cmd/warp/createaccount.go new file mode 100644 index 0000000..d588097 --- /dev/null +++ b/server/cmd/warp/createaccount.go @@ -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 +} diff --git a/server/cmd/warp/main.go b/server/cmd/warp/main.go new file mode 100644 index 0000000..49d4bff --- /dev/null +++ b/server/cmd/warp/main.go @@ -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 [flags]\n\nCommands:\n") + for _, c := range commands() { + fmt.Fprintf(w, " %-14s %s\n", c.name, c.summary) + } + fmt.Fprint(w, "\nRun `warp --help` for the flags of a command.\n") +} diff --git a/server/go.mod b/server/go.mod index c7781ac..10b8ce4 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/go.sum b/server/go.sum index 52277ea..a11e156 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= diff --git a/server/internal/adminapi/adminapi.go b/server/internal/adminapi/adminapi.go new file mode 100644 index 0000000..5a99340 --- /dev/null +++ b/server/internal/adminapi/adminapi.go @@ -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 +} diff --git a/server/internal/adminapi/adminapi_test.go b/server/internal/adminapi/adminapi_test.go new file mode 100644 index 0000000..2cf9929 --- /dev/null +++ b/server/internal/adminapi/adminapi_test.go @@ -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()) + } +} diff --git a/server/internal/adminapi/users.go b/server/internal/adminapi/users.go new file mode 100644 index 0000000..e59a0bc --- /dev/null +++ b/server/internal/adminapi/users.go @@ -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) +} diff --git a/server/internal/clientapi/accountdata.go b/server/internal/clientapi/accountdata.go new file mode 100644 index 0000000..ad6f6a1 --- /dev/null +++ b/server/internal/clientapi/accountdata.go @@ -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 +} diff --git a/server/internal/clientapi/accountdata_test.go b/server/internal/clientapi/accountdata_test.go new file mode 100644 index 0000000..b8f07d5 --- /dev/null +++ b/server/internal/clientapi/accountdata_test.go @@ -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 } diff --git a/server/internal/clientapi/auth.go b/server/internal/clientapi/auth.go new file mode 100644 index 0000000..fdabe01 --- /dev/null +++ b/server/internal/clientapi/auth.go @@ -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 ", 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 +} diff --git a/server/internal/clientapi/capabilities.go b/server/internal/clientapi/capabilities.go new file mode 100644 index 0000000..2197631 --- /dev/null +++ b/server/internal/clientapi/capabilities.go @@ -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 +} diff --git a/server/internal/clientapi/clientapi.go b/server/internal/clientapi/clientapi.go new file mode 100644 index 0000000..44df818 --- /dev/null +++ b/server/internal/clientapi/clientapi.go @@ -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)))) +} diff --git a/server/internal/clientapi/clientapi_test.go b/server/internal/clientapi/clientapi_test.go new file mode 100644 index 0000000..dabb397 --- /dev/null +++ b/server/internal/clientapi/clientapi_test.go @@ -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 } +} diff --git a/server/internal/clientapi/devices.go b/server/internal/clientapi/devices.go new file mode 100644 index 0000000..1bf4506 --- /dev/null +++ b/server/internal/clientapi/devices.go @@ -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 + } +} diff --git a/server/internal/clientapi/devices_test.go b/server/internal/clientapi/devices_test.go new file mode 100644 index 0000000..f7efba7 --- /dev/null +++ b/server/internal/clientapi/devices_test.go @@ -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"]) + } +} diff --git a/server/internal/clientapi/login.go b/server/internal/clientapi/login.go new file mode 100644 index 0000000..413e6dd --- /dev/null +++ b/server/internal/clientapi/login.go @@ -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 +} diff --git a/server/internal/clientapi/login_test.go b/server/internal/clientapi/login_test.go new file mode 100644 index 0000000..c7d69aa --- /dev/null +++ b/server/internal/clientapi/login_test.go @@ -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 +} diff --git a/server/internal/clientapi/profile.go b/server/internal/clientapi/profile.go new file mode 100644 index 0000000..0211eba --- /dev/null +++ b/server/internal/clientapi/profile.go @@ -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 +} diff --git a/server/internal/clientapi/profile_test.go b/server/internal/clientapi/profile_test.go new file mode 100644 index 0000000..be4f7fc --- /dev/null +++ b/server/internal/clientapi/profile_test.go @@ -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") +} diff --git a/server/internal/clientapi/register.go b/server/internal/clientapi/register.go new file mode 100644 index 0000000..f1abdd2 --- /dev/null +++ b/server/internal/clientapi/register.go @@ -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 +} diff --git a/server/internal/clientapi/register_test.go b/server/internal/clientapi/register_test.go new file mode 100644 index 0000000..22efa04 --- /dev/null +++ b/server/internal/clientapi/register_test.go @@ -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 @: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") +} diff --git a/server/internal/clientapi/sync.go b/server/internal/clientapi/sync.go new file mode 100644 index 0000000..55ecd9f --- /dev/null +++ b/server/internal/clientapi/sync.go @@ -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 +} diff --git a/server/internal/clientapi/uia.go b/server/internal/clientapi/uia.go new file mode 100644 index 0000000..f8be648 --- /dev/null +++ b/server/internal/clientapi/uia.go @@ -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 +} diff --git a/server/internal/clientapi/wellknown_test.go b/server/internal/clientapi/wellknown_test.go new file mode 100644 index 0000000..11ffdea --- /dev/null +++ b/server/internal/clientapi/wellknown_test.go @@ -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") + } +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 2c27202..ae46413 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -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) diff --git a/server/internal/config/phase1_test.go b/server/internal/config/phase1_test.go new file mode 100644 index 0000000..2767220 --- /dev/null +++ b/server/internal/config/phase1_test.go @@ -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) + } +} diff --git a/server/internal/httputil/errors.go b/server/internal/httputil/errors.go index 1f7da99..dfdb8f5 100644 --- a/server/internal/httputil/errors.go +++ b/server/internal/httputil/errors.go @@ -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...) +} diff --git a/server/internal/httputil/json.go b/server/internal/httputil/json.go index d2ebac2..d58a851 100644 --- a/server/internal/httputil/json.go +++ b/server/internal/httputil/json.go @@ -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 diff --git a/server/internal/ratelimit/ratelimit.go b/server/internal/ratelimit/ratelimit.go new file mode 100644 index 0000000..692f7ef --- /dev/null +++ b/server/internal/ratelimit/ratelimit.go @@ -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 + } +} diff --git a/server/internal/ratelimit/ratelimit_test.go b/server/internal/ratelimit/ratelimit_test.go new file mode 100644 index 0000000..0877b9a --- /dev/null +++ b/server/internal/ratelimit/ratelimit_test.go @@ -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) + } +} diff --git a/server/internal/setup/phase1_test.go b/server/internal/setup/phase1_test.go new file mode 100644 index 0000000..b4e9a03 --- /dev/null +++ b/server/internal/setup/phase1_test.go @@ -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) + } +} diff --git a/server/internal/setup/routes.go b/server/internal/setup/routes.go index 5f16d55..fc9e8b2 100644 --- a/server/internal/setup/routes.go +++ b/server/internal/setup/routes.go @@ -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()) diff --git a/server/internal/setup/server.go b/server/internal/setup/server.go index bdeff1e..f005bae 100644 --- a/server/internal/setup/server.go +++ b/server/internal/setup/server.go @@ -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 } diff --git a/server/internal/storage/pocketbase/pocketbase.go b/server/internal/storage/pocketbase/pocketbase.go index 31df58d..96d82a7 100644 --- a/server/internal/storage/pocketbase/pocketbase.go +++ b/server/internal/storage/pocketbase/pocketbase.go @@ -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{} +) diff --git a/server/internal/storage/postgres/errors.go b/server/internal/storage/postgres/errors.go new file mode 100644 index 0000000..4d7640e --- /dev/null +++ b/server/internal/storage/postgres/errors.go @@ -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() +} diff --git a/server/internal/storage/postgres/errors_test.go b/server/internal/storage/postgres/errors_test.go new file mode 100644 index 0000000..c843284 --- /dev/null +++ b/server/internal/storage/postgres/errors_test.go @@ -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" } diff --git a/server/internal/storage/postgres/migrations/0002_userapi.sql b/server/internal/storage/postgres/migrations/0002_userapi.sql new file mode 100644 index 0000000..c080d45 --- /dev/null +++ b/server/internal/storage/postgres/migrations/0002_userapi.sql @@ -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) +); diff --git a/server/internal/storage/postgres/postgres.go b/server/internal/storage/postgres/postgres.go index 0534f67..6bd0710 100644 --- a/server/internal/storage/postgres/postgres.go +++ b/server/internal/storage/postgres/postgres.go @@ -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() diff --git a/server/internal/storage/sqlite/errors.go b/server/internal/storage/sqlite/errors.go new file mode 100644 index 0000000..298679a --- /dev/null +++ b/server/internal/storage/sqlite/errors.go @@ -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 + } +} diff --git a/server/internal/storage/sqlite/errors_test.go b/server/internal/storage/sqlite/errors_test.go new file mode 100644 index 0000000..d5a0dfb --- /dev/null +++ b/server/internal/storage/sqlite/errors_test.go @@ -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") + } +} diff --git a/server/internal/storage/sqlite/migrations/0002_userapi.sql b/server/internal/storage/sqlite/migrations/0002_userapi.sql new file mode 100644 index 0000000..90e313c --- /dev/null +++ b/server/internal/storage/sqlite/migrations/0002_userapi.sql @@ -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) +); diff --git a/server/internal/storage/sqlite/sqlite.go b/server/internal/storage/sqlite/sqlite.go index 38e9b87..c6d128c 100644 --- a/server/internal/storage/sqlite/sqlite.go +++ b/server/internal/storage/sqlite/sqlite.go @@ -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() diff --git a/server/internal/storage/sqlstore/sqlstore.go b/server/internal/storage/sqlstore/sqlstore.go index 8f9a9d9..16ec8d8 100644 --- a/server/internal/storage/sqlstore/sqlstore.go +++ b/server/internal/storage/sqlstore/sqlstore.go @@ -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() diff --git a/server/internal/storage/sqlstore/users.go b/server/internal/storage/sqlstore/users.go new file mode 100644 index 0000000..f7df0f4 --- /dev/null +++ b/server/internal/storage/sqlstore/users.go @@ -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) diff --git a/server/internal/storage/storage.go b/server/internal/storage/storage.go index 10c719d..d517387 100644 --- a/server/internal/storage/storage.go +++ b/server/internal/storage/storage.go @@ -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 } diff --git a/server/internal/storage/users.go b/server/internal/storage/users.go new file mode 100644 index 0000000..75b2037 --- /dev/null +++ b/server/internal/storage/users.go @@ -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 +} diff --git a/server/internal/storage/users_test.go b/server/internal/storage/users_test.go new file mode 100644 index 0000000..e367a55 --- /dev/null +++ b/server/internal/storage/users_test.go @@ -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) + } + }) + } +} diff --git a/server/internal/userapi/password.go b/server/internal/userapi/password.go new file mode 100644 index 0000000..edb4472 --- /dev/null +++ b/server/internal/userapi/password.go @@ -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$$ +// +// 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 +}() diff --git a/server/internal/userapi/password_test.go b/server/internal/userapi/password_test.go new file mode 100644 index 0000000..2f9b7fe --- /dev/null +++ b/server/internal/userapi/password_test.go @@ -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") + } +} diff --git a/server/internal/userapi/tokens.go b/server/internal/userapi/tokens.go new file mode 100644 index 0000000..4827368 --- /dev/null +++ b/server/internal/userapi/tokens.go @@ -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 +} diff --git a/server/internal/userapi/userapi.go b/server/internal/userapi/userapi.go new file mode 100644 index 0000000..f169ed8 --- /dev/null +++ b/server/internal/userapi/userapi.go @@ -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() +} diff --git a/server/internal/userapi/userapi_test.go b/server/internal/userapi/userapi_test.go new file mode 100644 index 0000000..bf743ca --- /dev/null +++ b/server/internal/userapi/userapi_test.go @@ -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) + } +} diff --git a/server/internal/userapi/userid.go b/server/internal/userapi/userid.go new file mode 100644 index 0000000..afb27e7 --- /dev/null +++ b/server/internal/userapi/userid.go @@ -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 +} diff --git a/server/internal/userapi/userid_test.go b/server/internal/userapi/userid_test.go new file mode 100644 index 0000000..f1a8d6a --- /dev/null +++ b/server/internal/userapi/userid_test.go @@ -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") + } +} diff --git a/server/test/complement/Dockerfile b/server/test/complement/Dockerfile new file mode 100644 index 0000000..2ac0208 --- /dev/null +++ b/server/test/complement/Dockerfile @@ -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"] diff --git a/server/test/complement/README.md b/server/test/complement/README.md index 9866a24..660f64e 100644 --- a/server/test/complement/README.md +++ b/server/test/complement/README.md @@ -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. diff --git a/server/test/complement/entrypoint.sh b/server/test/complement/entrypoint.sh new file mode 100755 index 0000000..a33e0bc --- /dev/null +++ b/server/test/complement/entrypoint.sh @@ -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 diff --git a/server/test/complement/run.sh b/server/test/complement/run.sh new file mode 100755 index 0000000..20de58c --- /dev/null +++ b/server/test/complement/run.sh @@ -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/... diff --git a/warp.yaml.example b/warp.yaml.example index a6b661e..c70ff01 100644 --- a/warp.yaml.example +++ b/warp.yaml.example @@ -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