Phase 1: accounts and sessions
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 1. A client can now register, log in, manage its devices and set its profile; an operator can create accounts and flip the runtime switches. There are still no rooms and no real /sync. userapi: accounts, argon2id passwords in PHC format, devices, access and refresh tokens stored as SHA-256 hashes, account data and the admin flag. Its storage interface is storage.UserStore, implemented once in sqlstore with `?` placeholders; the postgres driver supplies a rebind function and unique-violation recognition rather than keeping a second copy of thirty statements. Timestamps in these tables are int64 milliseconds, the unit the Client-Server API uses on the wire. Client-Server API: register (user-interactive auth with m.login.dummy and optional registration tokens), register/available, token validity, login with m.login.password, logout, logout/all, refresh, account/whoami, capabilities, devices/* with password-gated deletion, profile/*, global and per-room account data. GET /sync is a deliberate placeholder: it answers a valid token with an empty but well-formed response and refuses a revoked one. Phase 1 has no rooms, so that is the whole truth, and it is what makes session revocation observable. Phase 4 replaces it with the syncapi. Auth middleware resolves a bearer token (header or the legacy query parameter) to a device and user, answering an expired token with the soft logout the spec asks for. Rate limiting is a token bucket per endpoint class keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms. adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix token, GET/PUT /federation and /registration driving internal/settings and reporting a config-pinned switch as a 409, GET/POST /users, GET /stats. warp create-account [--admin] creates the first account straight in the database, so a new install with registration off has a way in. Complement is wired up for the federation=off profile: an image meeting its contract, an entrypoint that configures Warp entirely from WARP_* variables, and a runner that builds, clones and executes the target set (TestRegistration, TestLogin, TestDeviceManagement). It has not been run yet -- there is no container runtime on the development machine -- so the three tests' assertions were read from the Complement sources and pinned as httptest tests instead, and CI gained a job that runs the real thing. Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern, meant for the built binary, also matched the directory server/cmd/warp and silently excluded the entire command package from the repository. The binary patterns are now anchored to the repository root, and cmd/warp is committed for the first time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b4fb60a8ab
commit
6004bb01de
68 files changed
+9729
-66
No files matched your search
@@ -97,6 +97,21 @@ jobs:
|
||||
WARP_TEST_POSTGRES_DSN: postgres://warp:warp@localhost:5432/warp_test?sslmode=disable
|
||||
run: go test -count=1 ./internal/storage/...
|
||||
|
||||
complement:
|
||||
name: Complement (federation off)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
# Complement is a separate Go module with its own dependencies; the
|
||||
# runner script clones it into .complement/.
|
||||
- name: Run the Complement subset for the phases that are done
|
||||
run: bash server/test/complement/run.sh
|
||||
|
||||
lint:
|
||||
name: golangci-lint
|
||||
runs-on: ubuntu-latest
|
||||
@@ -154,6 +169,41 @@ jobs:
|
||||
status="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8008/.well-known/matrix/server)"
|
||||
test "$status" = "404"
|
||||
|
||||
# Registration is off in a fresh install, so the first account is
|
||||
# made from the command line. Everything from here on is the
|
||||
# Phase 1 surface, through the real binary.
|
||||
./bin/warp create-account --username ci-admin --password hunter2hunter2 --admin
|
||||
token="$(curl -fsS http://127.0.0.1:8008/_matrix/client/v3/login -d '{
|
||||
"type": "m.login.password",
|
||||
"identifier": {"type": "m.id.user", "user": "ci-admin"},
|
||||
"password": "hunter2hunter2"
|
||||
}' | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')"
|
||||
test -n "$token"
|
||||
|
||||
curl -fsS http://127.0.0.1:8008/_matrix/client/v3/account/whoami \
|
||||
-H "Authorization: Bearer $token" | grep -q '@ci-admin:ci.example'
|
||||
curl -fsS http://127.0.0.1:8008/_matrix/client/v3/capabilities \
|
||||
-H "Authorization: Bearer $token" | grep -q 'm.room_versions'
|
||||
curl -fsS http://127.0.0.1:8008/_warp/admin/v1/stats \
|
||||
-H "Authorization: Bearer $token" | grep -q '"admins":1'
|
||||
|
||||
# An unauthenticated request must be refused, not served.
|
||||
status="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8008/_warp/admin/v1/stats)"
|
||||
test "$status" = "401"
|
||||
|
||||
# Registration is refused while the switch is off, and accepted
|
||||
# once the admin API flips it — with no restart.
|
||||
status="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||||
http://127.0.0.1:8008/_matrix/client/v3/register -d '{}')"
|
||||
test "$status" = "403"
|
||||
|
||||
curl -fsS -X PUT http://127.0.0.1:8008/_warp/admin/v1/registration \
|
||||
-H "Authorization: Bearer $token" -d '{"enabled":true}' | grep -q '"enabled":true'
|
||||
|
||||
status="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||||
http://127.0.0.1:8008/_matrix/client/v3/register -d '{}')"
|
||||
test "$status" = "401"
|
||||
|
||||
# Graceful shutdown: SIGTERM must stop the process cleanly.
|
||||
kill -TERM "$server_pid"
|
||||
wait "$server_pid"
|
||||
+8
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,26 +9,39 @@ what exists today.
|
||||
|
||||
## Status
|
||||
|
||||
**Phase 0 — bootstrap. Complete.** There is a server binary that boots,
|
||||
serves its discovery endpoints and nothing else yet: no accounts, no rooms,
|
||||
no sync. Those are Phases 1 to 4.
|
||||
**Phase 1 — accounts and sessions. Complete.** A client can register, log
|
||||
in, manage its devices and set its profile. There are no rooms and no real
|
||||
`/sync` yet: those are Phases 3 and 4.
|
||||
|
||||
What works now:
|
||||
Client-Server API:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /_matrix/client/versions` | spec versions this build claims |
|
||||
| `GET /.well-known/matrix/client` | client discovery |
|
||||
| `GET /.well-known/matrix/server` | server discovery — **only while federation is on** |
|
||||
| `GET /healthz` | liveness, including a database ping |
|
||||
| `GET /metrics` | Prometheus |
|
||||
| `POST /register`, `GET /register/available` | sign-up, with `m.login.dummy` and optional registration tokens |
|
||||
| `GET`, `POST /login` · `POST /logout`, `/logout/all` · `POST /refresh` | sessions, with `m.login.password` and refresh tokens |
|
||||
| `GET /account/whoami` · `GET /capabilities` | who am I, and what this build can do |
|
||||
| `GET /devices`, `GET`/`PUT`/`DELETE /devices/{id}` · `POST /delete_devices` | device management; deletion re-asks for the password |
|
||||
| `GET /profile/{user}` and its `displayname` / `avatar_url` sub-resources | profiles — reads are public, writes are your own |
|
||||
| `GET`/`PUT /user/{user}/account_data/{type}` and the per-room variant | account data, stored and returned byte for byte |
|
||||
| `GET /sync` | a placeholder: an empty, well-formed response, because there is nothing to sync yet |
|
||||
| `GET /_matrix/client/versions` · `/.well-known/matrix/client` | discovery |
|
||||
| `/.well-known/matrix/server` | server discovery — **only while federation is on** |
|
||||
| `GET /healthz` · `GET /metrics` | liveness with a database ping, and Prometheus |
|
||||
| anything else under `/_matrix/` | `404 M_UNRECOGNIZED`, as the spec requires |
|
||||
|
||||
Also in place: YAML + environment configuration, structured logging,
|
||||
graceful shutdown, the storage interfaces with a working SQLite and
|
||||
PostgreSQL driver (and a PocketBase stub), the embedded migration runner,
|
||||
the `server_settings` table with the runtime federation and registration
|
||||
switches, and `warp generate-keys`.
|
||||
Admin API under `/_warp/admin/v1`: the federation and registration
|
||||
switches, user listing and creation, and the dashboard counters. See
|
||||
[docs/admin-api.md](docs/admin-api.md).
|
||||
|
||||
Also in place: argon2id passwords, hashed access and refresh tokens,
|
||||
per-endpoint rate limiting, YAML + environment configuration, structured
|
||||
logging, graceful shutdown, the storage interfaces with a working SQLite and
|
||||
PostgreSQL driver (and a PocketBase stub), the embedded migration runner and
|
||||
the runtime settings service.
|
||||
|
||||
Complement, the official integration suite, is wired up for the
|
||||
federation-off profile: `make complement` runs `TestRegistration`,
|
||||
`TestLogin` and `TestDeviceManagement`.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -41,6 +54,7 @@ cp warp.yaml.example warp.yaml
|
||||
|
||||
make build
|
||||
./bin/warp generate-keys
|
||||
./bin/warp create-account --username you --admin
|
||||
./bin/warp serve
|
||||
```
|
||||
|
||||
@@ -51,6 +65,10 @@ curl localhost:8008/healthz
|
||||
curl localhost:8008/_matrix/client/versions
|
||||
```
|
||||
|
||||
Registration is off in a fresh install, which is why the first account is
|
||||
made from the command line. Turn it on from the admin API when you want
|
||||
others to sign up.
|
||||
|
||||
Without `make`, the same from the `server` directory:
|
||||
|
||||
```sh
|
||||
@@ -79,8 +97,14 @@ security-relevant setting should never be silently ignored.
|
||||
| `warp serve` | run the homeserver |
|
||||
| `warp migrate [--status]` | apply (or list) pending database migrations |
|
||||
| `warp generate-keys [--output path] [--key-id id]` | create the ed25519 signing key |
|
||||
| `warp create-account --username name [--admin]` | create a local account, straight in the database |
|
||||
| `warp version` | version and compiled-in storage drivers |
|
||||
|
||||
`create-account` talks to the database rather than to a running server, so
|
||||
the first account on a new install can be made before anything is listening
|
||||
and without turning registration on. It prompts for the password twice;
|
||||
`--password-file` and `--password` are there for scripts.
|
||||
|
||||
The signing key is written as a single line, the same format Synapse uses:
|
||||
`ed25519 <key-id> <unpadded-base64-seed>`. `generate-keys` refuses to
|
||||
overwrite an existing key — losing it would invalidate every event this
|
||||
@@ -107,6 +131,7 @@ make test # unit tests (SQLite)
|
||||
make lint # golangci-lint
|
||||
make check # vet + test, what CI runs
|
||||
make cover # coverage.html
|
||||
make complement # the Complement subset for the phases that are done (needs Docker)
|
||||
|
||||
# the storage tests against PostgreSQL as well
|
||||
WARP_TEST_POSTGRES_DSN='postgres://warp:warp@localhost/warp_test?sslmode=disable' make test-postgres
|
||||
@@ -119,19 +144,24 @@ Linux.
|
||||
|
||||
```
|
||||
server/ the homeserver (Go module)
|
||||
cmd/warp/ the binary: serve, migrate, generate-keys, version
|
||||
cmd/warp/ the binary: serve, migrate, generate-keys, create-account, version
|
||||
internal/
|
||||
config/ YAML + env configuration
|
||||
logging/ metrics/ slog and Prometheus setup
|
||||
httputil/ Matrix error envelope, CORS, router, middleware
|
||||
ratelimit/ per-endpoint token buckets
|
||||
federation/ the federation gate and allow/deny policy
|
||||
settings/ runtime settings service (the switches)
|
||||
userapi/ accounts, passwords, devices, tokens, account data
|
||||
clientapi/ the Client-Server API handlers
|
||||
adminapi/ /_warp/admin/v1
|
||||
setup/ wiring: storage, routes, graceful shutdown
|
||||
matrix/signing/ ed25519 server keys
|
||||
storage/ interfaces + driver registry
|
||||
migrations/ embedded migration runner
|
||||
sqlstore/ shared database/sql implementation
|
||||
sqlite/ postgres/ pocketbase/
|
||||
test/complement/ the Complement homeserver image and runner
|
||||
apps/ clients: ui, admin, web, desktop, mobile (Phase 1+)
|
||||
docs/
|
||||
```
|
||||
+2
-1
@@ -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.
|
||||
@@ -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.
|
||||
@@ -40,18 +40,49 @@ List-valued variables are comma-separated. A single `-` clears a list:
|
||||
| `database.conn_max_lifetime` | `WARP_DB_CONN_MAX_LIFETIME` | `1h` | |
|
||||
| `database.auto_migrate` | `WARP_DB_AUTO_MIGRATE` | `true` | `false` makes startup fail while migrations are pending. |
|
||||
| `client_api.cors_allowed_origins` | `WARP_CORS_ALLOWED_ORIGINS` | `["*"]` | The spec expects any origin. |
|
||||
| `client_api.trust_x_forwarded_for` | `WARP_TRUST_X_FORWARDED_FOR` | `false` | Only behind a proxy that overwrites the header; the rate limiter keys on the result. |
|
||||
| `client_api.access_token_lifetime` | `WARP_ACCESS_TOKEN_LIFETIME` | `0s` | `0` = never expire. With a lifetime, clients may ask for a refresh token at login. |
|
||||
| `client_api.refresh_token_lifetime` | `WARP_REFRESH_TOKEN_LIFETIME` | `0s` | `0` = never expire. |
|
||||
| `client_api.rate_limit.enabled` | `WARP_RATE_LIMIT_ENABLED` | `true` | |
|
||||
| `client_api.rate_limit.default.burst` / `.interval` | — | `30` / `100ms` | Every rate-limited endpoint without its own bucket. |
|
||||
| `client_api.rate_limit.login.burst` / `.interval` | — | `10` / `1s` | `/login` and `/refresh`. |
|
||||
| `client_api.rate_limit.register.burst` / `.interval` | — | `5` / `2s` | `/register` and `/register/available`. |
|
||||
| `federation.enabled` | `WARP_FEDERATION_ENABLED` | `false` | |
|
||||
| `federation.allowlist` | `WARP_FEDERATION_ALLOWLIST` | `[]` | Non-empty = federate only with these. |
|
||||
| `federation.denylist` | `WARP_FEDERATION_DENYLIST` | `[]` | Always wins over the allowlist. |
|
||||
| `federation.allow_runtime_toggle` | `WARP_FEDERATION_ALLOW_RUNTIME_TOGGLE` | `true` | See below. |
|
||||
| `registration.enabled` | `WARP_REGISTRATION_ENABLED` | `false` | |
|
||||
| `registration.allow_runtime_toggle` | `WARP_REGISTRATION_ALLOW_RUNTIME_TOGGLE` | `true` | |
|
||||
| `registration.tokens` | `WARP_REGISTRATION_TOKENS` | `[]` | Non-empty = sign-up also needs one of these, as the `m.login.registration_token` stage. |
|
||||
| `logging.level` | `WARP_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error`. |
|
||||
| `logging.format` | `WARP_LOG_FORMAT` | `text` | `text` or `json`. |
|
||||
| `logging.add_source` | `WARP_LOG_ADD_SOURCE` | `false` | File and line in every record. |
|
||||
| `metrics.enabled` | `WARP_METRICS_ENABLED` | `true` | |
|
||||
| `metrics.path` | `WARP_METRICS_PATH` | `/metrics` | Served on the same listener. |
|
||||
|
||||
The three rate limit buckets share one Go type, so per-bucket environment
|
||||
variables would all collide on the same name; they are YAML only.
|
||||
`WARP_RATE_LIMIT_ENABLED=false` turns the whole limiter off, which is what a
|
||||
test harness wants.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
Each bucket allows `burst` requests back to back and returns one slot every
|
||||
`interval`, keyed per client address. Over the limit is
|
||||
`429 M_LIMIT_EXCEEDED` with a `retry_after_ms` the client is expected to
|
||||
honour.
|
||||
|
||||
Buckets are per endpoint class, so a client hammering `/login` cannot spend
|
||||
the budget of `/profile`. The key is the client address and not the user,
|
||||
because the limiter runs before the access token is resolved — a limiter
|
||||
that had to query the database to find its own key would be a denial of
|
||||
service of its own. Behind a reverse proxy, set
|
||||
`client_api.trust_x_forwarded_for: true`, or every client shares one bucket.
|
||||
|
||||
The admin API under `/_warp/admin/v1` is not rate limited: it needs an admin
|
||||
token, and bulk operations from the panel are exactly the traffic a limiter
|
||||
would get in the way of.
|
||||
|
||||
## Settings that change at runtime
|
||||
|
||||
`federation.enabled` and `registration.enabled` are also stored in the
|
||||
|
||||
+52
-4
@@ -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}'
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Command warp is the Matrix homeserver: one binary, one process.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// warp serve [--config warp.yaml]
|
||||
// warp migrate [--config warp.yaml] [--status]
|
||||
// warp generate-keys [--config warp.yaml] [--output path] [--key-id id]
|
||||
// warp create-account [--config warp.yaml] --username alice [--admin]
|
||||
// warp version
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// command is one subcommand.
|
||||
type command struct {
|
||||
name string
|
||||
summary string
|
||||
run func(ctx context.Context, args []string) error
|
||||
}
|
||||
|
||||
func commands() []command {
|
||||
return []command{
|
||||
{"serve", "run the homeserver", runServe},
|
||||
{"migrate", "apply pending database migrations", runMigrate},
|
||||
{"generate-keys", "create the ed25519 signing key", runGenerateKeys},
|
||||
{"create-account", "create a local user account", runCreateAccount},
|
||||
{"version", "print the version and exit", runVersion},
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Ctrl-C and SIGTERM cancel the context; the serve command turns that
|
||||
// into a graceful shutdown.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := run(ctx, os.Args[1:]); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "warp: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
usage(os.Stderr)
|
||||
return errors.New("no command given")
|
||||
}
|
||||
switch args[0] {
|
||||
case "-h", "--help", "help":
|
||||
usage(os.Stdout)
|
||||
return nil
|
||||
}
|
||||
for _, c := range commands() {
|
||||
if c.name == args[0] {
|
||||
return c.run(ctx, args[1:])
|
||||
}
|
||||
}
|
||||
usage(os.Stderr)
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
|
||||
func usage(w *os.File) {
|
||||
fmt.Fprint(w, "warp — a Matrix homeserver\n\nUsage:\n warp <command> [flags]\n\nCommands:\n")
|
||||
for _, c := range commands() {
|
||||
fmt.Fprintf(w, " %-14s %s\n", c.name, c.summary)
|
||||
}
|
||||
fmt.Fprint(w, "\nRun `warp <command> --help` for the flags of a command.\n")
|
||||
}
|
||||
+5
-3
@@ -5,6 +5,8 @@ go 1.26.4
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/prometheus/client_golang v1.25.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/term v0.47.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.60.1
|
||||
)
|
||||
@@ -26,9 +28,9 @@ require (
|
||||
github.com/prometheus/procfs v0.22.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
modernc.org/libc v1.77.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
+12
-8
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
|
||||
// MaxAccountDataSize caps one account data blob. The spec sets no limit, but
|
||||
// account data is sent down /sync in full on every initial sync, so an
|
||||
// unbounded blob is an unbounded sync.
|
||||
const MaxAccountDataSize = 64 * 1024
|
||||
|
||||
// typeFullyRead is the account data type the spec reserves: it is set
|
||||
// through POST /rooms/{roomId}/read_markers, which keeps the read marker and
|
||||
// the receipt in step, so writing it directly is refused.
|
||||
const typeFullyRead = "m.fully_read"
|
||||
|
||||
// handleGetAccountData implements
|
||||
// GET /_matrix/client/v3/user/{userId}/account_data/{type}.
|
||||
func (c *ClientAPI) handleGetAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
return c.getAccountData(w, r, auth, "")
|
||||
}
|
||||
|
||||
// handleGetRoomAccountData implements the per-room variant.
|
||||
func (c *ClientAPI) handleGetRoomAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
roomID := r.PathValue("roomID")
|
||||
if err := validateRoomID(roomID); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.getAccountData(w, r, auth, roomID)
|
||||
}
|
||||
|
||||
func (c *ClientAPI) getAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated, roomID string) error {
|
||||
// Account data is private, so even a read is only ever of one's own —
|
||||
// an admin reading it would be reading a user's client settings, which
|
||||
// the admin API has no business doing either.
|
||||
localpart, err := c.requireOwnAccountData(auth, r.PathValue("userID"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dataType := r.PathValue("dataType")
|
||||
if dataType == "" {
|
||||
return httputil.MissingParam("An account data type is required")
|
||||
}
|
||||
|
||||
content, err := c.users.AccountData(r.Context(), localpart, roomID, dataType)
|
||||
switch {
|
||||
case errors.Is(err, userapi.ErrNoSuchAccountData):
|
||||
return httputil.NotFound("Account data not found")
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
|
||||
// The stored bytes are the client's own JSON and go back out verbatim,
|
||||
// so they are written directly rather than round-tripped through a map.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(content); err != nil {
|
||||
httputil.LoggerFrom(r.Context()).Debug("writing account data failed", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePutAccountData implements
|
||||
// PUT /_matrix/client/v3/user/{userId}/account_data/{type}.
|
||||
func (c *ClientAPI) handlePutAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
return c.putAccountData(w, r, auth, "")
|
||||
}
|
||||
|
||||
// handlePutRoomAccountData implements the per-room variant.
|
||||
func (c *ClientAPI) handlePutRoomAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
roomID := r.PathValue("roomID")
|
||||
if err := validateRoomID(roomID); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.putAccountData(w, r, auth, roomID)
|
||||
}
|
||||
|
||||
func (c *ClientAPI) putAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated, roomID string) error {
|
||||
localpart, err := c.requireOwnAccountData(auth, r.PathValue("userID"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dataType := r.PathValue("dataType")
|
||||
if dataType == "" {
|
||||
return httputil.MissingParam("An account data type is required")
|
||||
}
|
||||
if dataType == typeFullyRead {
|
||||
return httputil.BadJSON("%s may only be set through /rooms/{roomId}/read_markers", typeFullyRead)
|
||||
}
|
||||
|
||||
// The content is stored as the client sent it, so it is read as raw
|
||||
// JSON and only checked for being a JSON object at all.
|
||||
var content json.RawMessage
|
||||
if err := httputil.DecodeJSON(w, r, &content); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(content) > MaxAccountDataSize {
|
||||
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeTooLarge,
|
||||
"Account data must be at most %d bytes", MaxAccountDataSize)
|
||||
}
|
||||
if !json.Valid(content) {
|
||||
return httputil.NotJSON()
|
||||
}
|
||||
if trimmed := strings.TrimSpace(string(content)); !strings.HasPrefix(trimmed, "{") {
|
||||
return httputil.BadJSON("Account data content must be a JSON object")
|
||||
}
|
||||
|
||||
if err := c.users.SetAccountData(r.Context(), localpart, roomID, dataType, content); err != nil {
|
||||
return err
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, struct{}{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireOwnAccountData resolves the path's user ID and insists it is the
|
||||
// caller's own. Unlike a profile, account data is not something an admin may
|
||||
// reach into: it is the user's client configuration.
|
||||
func (c *ClientAPI) requireOwnAccountData(auth userapi.Authenticated, pathUserID string) (string, error) {
|
||||
localpart, err := c.users.ParseLocalUserID(pathUserID)
|
||||
switch {
|
||||
case errors.Is(err, userapi.ErrForeignServer):
|
||||
return "", httputil.Forbidden("Cannot access the account data of another server")
|
||||
case err != nil:
|
||||
return "", httputil.InvalidParam("%s is not a valid user ID", pathUserID)
|
||||
}
|
||||
if localpart != auth.Localpart {
|
||||
return "", httputil.Forbidden("Cannot access the account data of another user")
|
||||
}
|
||||
return localpart, nil
|
||||
}
|
||||
|
||||
// validateRoomID checks the shape of a room ID in a path.
|
||||
//
|
||||
// Phase 1 has no rooms, so there is nothing to check it against: room
|
||||
// account data is simply a second key space, and a client may set it for a
|
||||
// room it has not joined. The full check — that the room exists and the user
|
||||
// is in it — belongs to the roomserver in Phase 3.
|
||||
func validateRoomID(roomID string) error {
|
||||
switch {
|
||||
case roomID == "":
|
||||
return httputil.MissingParam("A room ID is required")
|
||||
case !strings.HasPrefix(roomID, "!"):
|
||||
return httputil.InvalidParam("%s is not a valid room ID", roomID)
|
||||
case len(roomID) > 255:
|
||||
return httputil.InvalidParam("Room IDs must be at most 255 bytes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package clientapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// accountDataPath builds the global account data path for a user.
|
||||
func accountDataPath(userID, dataType string) string {
|
||||
return "/_matrix/client/v3/user/" + userID + "/account_data/" + dataType
|
||||
}
|
||||
|
||||
// roomAccountDataPath builds the per-room variant.
|
||||
func roomAccountDataPath(userID, roomID, dataType string) string {
|
||||
return "/_matrix/client/v3/user/" + userID + "/rooms/" + roomID + "/account_data/" + dataType
|
||||
}
|
||||
|
||||
func TestAccountDataRoundTrip(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
acc := ts.register("alice", "superuser")
|
||||
path := accountDataPath(acc.UserID, "test.key")
|
||||
|
||||
expectError(t, ts.get(path, acc.AccessToken), http.StatusNotFound, "M_NOT_FOUND")
|
||||
|
||||
rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken,
|
||||
body: map[string]any{"value": "first"}})
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
|
||||
rec = ts.get(path, acc.AccessToken)
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if got := jsonBody(t, rec)["value"]; got != "first" {
|
||||
t.Errorf("value = %v, want first", got)
|
||||
}
|
||||
|
||||
rec = ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken,
|
||||
body: map[string]any{"value": "second"}})
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
|
||||
rec = ts.get(path, acc.AccessToken)
|
||||
if got := jsonBody(t, rec)["value"]; got != "second" {
|
||||
t.Errorf("value after overwriting = %v, want second", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomAccountDataRoundTrip(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
acc := ts.register("alice", "superuser")
|
||||
const roomID = "!room:warp.example"
|
||||
path := roomAccountDataPath(acc.UserID, roomID, "test.key")
|
||||
|
||||
rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken,
|
||||
body: map[string]any{"value": "room first"}})
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
|
||||
rec = ts.get(path, acc.AccessToken)
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if got := jsonBody(t, rec)["value"]; got != "room first" {
|
||||
t.Errorf("value = %v, want %q", got, "room first")
|
||||
}
|
||||
|
||||
// Room account data is its own key space: the global entry of the same
|
||||
// type is untouched.
|
||||
expectError(t, ts.get(accountDataPath(acc.UserID, "test.key"), acc.AccessToken),
|
||||
http.StatusNotFound, "M_NOT_FOUND")
|
||||
}
|
||||
|
||||
func TestAccountDataIsPrivate(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
alice := ts.register("alice", "superuser")
|
||||
bob := ts.register("bob", "bobspassword")
|
||||
|
||||
rec := ts.do(request{method: http.MethodPut, path: accountDataPath(alice.UserID, "test.key"),
|
||||
token: alice.AccessToken, body: map[string]any{"value": "secret"}})
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
|
||||
// Not even an admin: this is the user's own client configuration, not
|
||||
// something the server operator has business reading.
|
||||
ts.makeAdmin(bob)
|
||||
for _, method := range []string{http.MethodGet, http.MethodPut} {
|
||||
rec := ts.do(request{method: method, path: accountDataPath(alice.UserID, "test.key"),
|
||||
token: bob.AccessToken, body: map[string]any{"value": "nosy"}})
|
||||
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDataRequiresAToken(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
acc := ts.register("alice", "superuser")
|
||||
|
||||
expectError(t, ts.get(accountDataPath(acc.UserID, "test.key"), ""),
|
||||
http.StatusUnauthorized, "M_MISSING_TOKEN")
|
||||
}
|
||||
|
||||
// TestAccountDataRejectsFullyRead covers the one type the spec reserves: it
|
||||
// has to travel with a read receipt, so writing it directly is refused.
|
||||
func TestAccountDataRejectsFullyRead(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
acc := ts.register("alice", "superuser")
|
||||
|
||||
rec := ts.do(request{method: http.MethodPut,
|
||||
path: roomAccountDataPath(acc.UserID, "!room:warp.example", "m.fully_read"),
|
||||
token: acc.AccessToken, body: map[string]any{"event_id": "$x"}})
|
||||
expectError(t, rec, http.StatusBadRequest, "M_BAD_JSON")
|
||||
}
|
||||
|
||||
func TestAccountDataRejectsNonObjects(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
acc := ts.register("alice", "superuser")
|
||||
path := accountDataPath(acc.UserID, "test.key")
|
||||
|
||||
for name, body := range map[string]any{
|
||||
"an array": []any{1, 2, 3},
|
||||
"a string": "just a string",
|
||||
"a number": 42,
|
||||
"a boolean": true,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken, body: body})
|
||||
expectError(t, rec, http.StatusBadRequest, "M_BAD_JSON")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDataRejectsOversizedContent(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
acc := ts.register("alice", "superuser")
|
||||
|
||||
rec := ts.do(request{method: http.MethodPut, path: accountDataPath(acc.UserID, "test.key"),
|
||||
token: acc.AccessToken, body: map[string]any{"value": strings.Repeat("x", 70*1024)}})
|
||||
expectError(t, rec, http.StatusBadRequest, "M_TOO_LARGE")
|
||||
}
|
||||
|
||||
func TestRoomAccountDataRejectsMalformedRoomID(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
acc := ts.register("alice", "superuser")
|
||||
|
||||
rec := ts.do(request{method: http.MethodPut,
|
||||
path: roomAccountDataPath(acc.UserID, "not-a-room-id", "test.key"),
|
||||
token: acc.AccessToken, body: map[string]any{"value": "x"}})
|
||||
expectError(t, rec, http.StatusBadRequest, "M_INVALID_PARAM")
|
||||
}
|
||||
|
||||
// TestAccountDataIsReturnedVerbatim matters because account data is the
|
||||
// client's own JSON: a server that re-serialises it can reorder keys or
|
||||
// change number formatting under a client that is comparing bytes.
|
||||
func TestAccountDataIsReturnedVerbatim(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
acc := ts.register("alice", "superuser")
|
||||
path := accountDataPath(acc.UserID, "test.key")
|
||||
|
||||
const content = `{"b":1,"a":{"nested":[1,2,3]},"big":10000000000000001}`
|
||||
rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken,
|
||||
body: rawJSON(content)})
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
|
||||
rec = ts.get(path, acc.AccessToken)
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if got := rec.Body.String(); got != content {
|
||||
t.Errorf("account data came back as %s, want %s", got, content)
|
||||
}
|
||||
}
|
||||
|
||||
// rawJSON is marshalled as-is, so a test can send bytes of its choosing.
|
||||
type rawJSON string
|
||||
|
||||
func (r rawJSON) MarshalJSON() ([]byte, error) { return []byte(r), nil }
|
||||
@@ -0,0 +1,101 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
|
||||
// AuthedHandler is a handler that needs an authenticated user. The identity
|
||||
// is passed in rather than hidden in the request context, so a handler
|
||||
// cannot forget to check it and the compiler says which endpoints are
|
||||
// protected.
|
||||
type AuthedHandler func(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error
|
||||
|
||||
// withAuth resolves the access token before calling h.
|
||||
func (c *ClientAPI) withAuth(h AuthedHandler) httputil.Handler {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
auth, err := c.authenticate(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.users.RecordActivity(r.Context(), auth, c.clientIP(r), r.UserAgent())
|
||||
return h(w, r, auth)
|
||||
}
|
||||
}
|
||||
|
||||
// authenticate turns a request's access token into an identity.
|
||||
func (c *ClientAPI) authenticate(r *http.Request) (userapi.Authenticated, error) {
|
||||
token := accessTokenFrom(r)
|
||||
if token == "" {
|
||||
return userapi.Authenticated{}, httputil.MissingToken()
|
||||
}
|
||||
|
||||
auth, err := c.users.Authenticate(r.Context(), token)
|
||||
switch {
|
||||
case err == nil:
|
||||
return auth, nil
|
||||
case errors.Is(err, userapi.ErrNoSuchToken):
|
||||
return userapi.Authenticated{}, httputil.UnknownToken(false)
|
||||
case errors.Is(err, userapi.ErrTokenExpired):
|
||||
// A soft logout keeps the client's encryption state: the token
|
||||
// aged out, the device is still real, and logging in again
|
||||
// restores the session rather than replacing it.
|
||||
return userapi.Authenticated{}, httputil.UnknownToken(true)
|
||||
case errors.Is(err, userapi.ErrAccountDeactivated):
|
||||
return userapi.Authenticated{}, httputil.UserDeactivated()
|
||||
default:
|
||||
return userapi.Authenticated{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// accessTokenFrom extracts the access token from a request.
|
||||
//
|
||||
// The Authorization header is the only form the current spec defines, but
|
||||
// the ?access_token= query parameter is still what media URLs embedded in
|
||||
// web clients and a long tail of older clients use, so both are accepted.
|
||||
func accessTokenFrom(r *http.Request) string {
|
||||
if header := r.Header.Get("Authorization"); header != "" {
|
||||
if token, ok := bearerToken(header); ok {
|
||||
return token
|
||||
}
|
||||
// A malformed Authorization header is not a reason to fall through
|
||||
// to the query string: that would let a broken header hide a
|
||||
// working token and make the failure hard to see.
|
||||
return ""
|
||||
}
|
||||
return r.URL.Query().Get("access_token")
|
||||
}
|
||||
|
||||
// bearerToken parses "Bearer <token>", case-insensitively on the scheme.
|
||||
func bearerToken(header string) (string, bool) {
|
||||
scheme, token, found := strings.Cut(header, " ")
|
||||
if !found || !strings.EqualFold(scheme, "Bearer") {
|
||||
return "", false
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
return token, token != ""
|
||||
}
|
||||
|
||||
// requireSelf checks that the user ID in a path belongs to the requester.
|
||||
//
|
||||
// Several endpoints are addressed by user ID even though they only ever
|
||||
// operate on the caller's own data. The spec's answer for someone else's is
|
||||
// M_FORBIDDEN; admins are allowed through, which is what makes the admin
|
||||
// panel able to fix up a user's profile.
|
||||
func (c *ClientAPI) requireSelf(auth userapi.Authenticated, pathUserID string) (string, error) {
|
||||
localpart, err := c.users.ParseLocalUserID(pathUserID)
|
||||
switch {
|
||||
case errors.Is(err, userapi.ErrForeignServer):
|
||||
return "", httputil.Forbidden("Cannot change data belonging to another server")
|
||||
case err != nil:
|
||||
return "", httputil.InvalidParam("%s is not a valid user ID", pathUserID)
|
||||
}
|
||||
if localpart != auth.Localpart && !auth.Admin {
|
||||
return "", httputil.Forbidden("Cannot change the data of another user")
|
||||
}
|
||||
return localpart, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))))
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package clientapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/clientapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/config"
|
||||
)
|
||||
|
||||
const registerPath = "/_matrix/client/v3/register"
|
||||
|
||||
// TestRegisterReturnsFlows covers the first half of the registration
|
||||
// handshake: an empty body gets the flows and a session, not a complaint
|
||||
// about the missing password.
|
||||
func TestRegisterReturnsFlows(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{}})
|
||||
expectStatus(t, rec, http.StatusUnauthorized)
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
|
||||
body := jsonBody(t, rec)
|
||||
if _, ok := body["session"].(string); !ok {
|
||||
t.Error("the challenge must carry a session")
|
||||
}
|
||||
if _, ok := body["params"].(map[string]any); !ok {
|
||||
t.Error("the challenge must carry a params object, even an empty one")
|
||||
}
|
||||
flows, ok := body["flows"].([]any)
|
||||
if !ok || len(flows) == 0 {
|
||||
t.Fatalf("flows = %v, want a non-empty array", body["flows"])
|
||||
}
|
||||
for _, flow := range flows {
|
||||
stages, ok := flow.(map[string]any)["stages"].([]any)
|
||||
if !ok || len(stages) == 0 {
|
||||
t.Fatalf("every flow needs a non-empty stages array, got %v", flow)
|
||||
}
|
||||
if stages[0] != "m.login.dummy" {
|
||||
t.Errorf("first stage = %v, want m.login.dummy", stages[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterCreatesAccount(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
acc := ts.register("alice", "sUp3rs3kr1t")
|
||||
|
||||
if acc.UserID != "@alice:warp.example" {
|
||||
t.Errorf("user_id = %q, want @alice:warp.example", acc.UserID)
|
||||
}
|
||||
|
||||
// The token works straight away: a registration that needs a separate
|
||||
// login is a registration that half worked.
|
||||
rec := ts.get("/_matrix/client/v3/account/whoami", acc.AccessToken)
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if got := jsonBody(t, rec)["user_id"]; got != acc.UserID {
|
||||
t.Errorf("whoami = %v, want %s", got, acc.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterDowncasesUsername pins the behaviour clients rely on: a
|
||||
// localpart is lowercase, so a capital in the request is normalised rather
|
||||
// than rejected.
|
||||
func TestRegisterDowncasesUsername(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
acc := ts.register("user-UPPER", "sUp3rs3kr1t")
|
||||
|
||||
if acc.UserID != "@user-upper:warp.example" {
|
||||
t.Errorf("user_id = %q, want @user-upper:warp.example", acc.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterHonoursRequestedDeviceID(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath,
|
||||
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
|
||||
session := jsonBody(t, rec)["session"]
|
||||
|
||||
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
|
||||
"username": "alice",
|
||||
"password": "sUp3rs3kr1t",
|
||||
"device_id": "my_device_id",
|
||||
"initial_device_display_name": "a phone",
|
||||
"auth": map[string]any{"type": "m.login.dummy", "session": session},
|
||||
}})
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if got := jsonBody(t, rec)["device_id"]; got != "my_device_id" {
|
||||
t.Errorf("device_id = %v, want my_device_id", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterRejectsBadUsernamesBeforeUIA is the half of the ordering that
|
||||
// is easy to get wrong: a username that can never work is rejected with a
|
||||
// 400 straight away, rather than after the client has completed a flow.
|
||||
func TestRegisterRejectsBadUsernamesBeforeUIA(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
|
||||
for _, ch := range []string{"!", `"`, ":", "?", `\`, "@", "[", "]", "{", "|", "}", "£", "é", "\n", "'", ","} {
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath,
|
||||
body: map[string]any{"username": "user-" + ch + "-reject", "password": "sUp3rs3kr1t"}})
|
||||
expectError(t, rec, http.StatusBadRequest, "M_INVALID_USERNAME")
|
||||
}
|
||||
|
||||
// The characters the spec does allow must still get through.
|
||||
for _, ch := range []string{"q", "3", ".", "_", "=", "-", "/", "+"} {
|
||||
ts.register("chrtest"+ch, "sUp3rs3kr1t")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsOverlongUsername(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
|
||||
"username": strings.Repeat("a", 256),
|
||||
"password": "sUp3rs3kr1t",
|
||||
}})
|
||||
expectError(t, rec, http.StatusBadRequest, "M_INVALID_USERNAME")
|
||||
}
|
||||
|
||||
// TestRegisterRejectsTakenUsernameBeforeUIA is the other half: the name is
|
||||
// checked before the challenge, so a client is not asked to authenticate
|
||||
// only to be told the name was gone all along.
|
||||
func TestRegisterRejectsTakenUsernameBeforeUIA(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
ts.register("alice", "sUp3rs3kr1t")
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath,
|
||||
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
|
||||
expectError(t, rec, http.StatusBadRequest, "M_USER_IN_USE")
|
||||
|
||||
// Capitalisation does not get around it, because the name is
|
||||
// normalised first.
|
||||
rec = ts.do(request{method: http.MethodPost, path: registerPath,
|
||||
body: map[string]any{"username": "ALICE", "password": "sUp3rs3kr1t"}})
|
||||
expectError(t, rec, http.StatusBadRequest, "M_USER_IN_USE")
|
||||
}
|
||||
|
||||
// TestRegisterRequiresTheSession covers the rule that makes the flow a flow:
|
||||
// an `auth` without the session the server handed out starts again rather
|
||||
// than completing.
|
||||
func TestRegisterRequiresTheSession(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
|
||||
"username": "alice",
|
||||
"password": "sUp3rs3kr1t",
|
||||
"auth": map[string]any{"type": "m.login.dummy"},
|
||||
}})
|
||||
expectStatus(t, rec, http.StatusUnauthorized)
|
||||
|
||||
// An unknown session is treated the same way, with a fresh one offered.
|
||||
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
|
||||
"username": "alice",
|
||||
"password": "sUp3rs3kr1t",
|
||||
"auth": map[string]any{"type": "m.login.dummy", "session": "not-a-session"},
|
||||
}})
|
||||
expectStatus(t, rec, http.StatusUnauthorized)
|
||||
if _, ok := jsonBody(t, rec)["session"].(string); !ok {
|
||||
t.Error("a rejected session must come with a new one to try")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsUnknownStage(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath,
|
||||
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
|
||||
session := jsonBody(t, rec)["session"]
|
||||
|
||||
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
|
||||
"username": "alice",
|
||||
"password": "sUp3rs3kr1t",
|
||||
"auth": map[string]any{"type": "m.login.recaptcha", "session": session},
|
||||
}})
|
||||
expectError(t, rec, http.StatusBadRequest, "M_UNRECOGNIZED")
|
||||
}
|
||||
|
||||
func TestRegisterRequiresPasswordAfterUIA(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath,
|
||||
body: map[string]any{"username": "alice"}})
|
||||
expectStatus(t, rec, http.StatusUnauthorized)
|
||||
session := jsonBody(t, rec)["session"]
|
||||
|
||||
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
|
||||
"username": "alice",
|
||||
"auth": map[string]any{"type": "m.login.dummy", "session": session},
|
||||
}})
|
||||
expectError(t, rec, http.StatusBadRequest, "M_MISSING_PARAM")
|
||||
}
|
||||
|
||||
func TestRegisterInhibitLogin(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath,
|
||||
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
|
||||
session := jsonBody(t, rec)["session"]
|
||||
|
||||
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
|
||||
"username": "alice",
|
||||
"password": "sUp3rs3kr1t",
|
||||
"inhibit_login": true,
|
||||
"auth": map[string]any{"type": "m.login.dummy", "session": session},
|
||||
}})
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
|
||||
body := jsonBody(t, rec)
|
||||
if body["user_id"] != "@alice:warp.example" {
|
||||
t.Errorf("user_id = %v", body["user_id"])
|
||||
}
|
||||
for _, field := range []string{"access_token", "device_id"} {
|
||||
if _, present := body[field]; present {
|
||||
t.Errorf("inhibit_login must suppress %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterGeneratesUsername covers the case where the client leaves the
|
||||
// choice of name to the server.
|
||||
func TestRegisterGeneratesUsername(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath,
|
||||
body: map[string]any{"password": "sUp3rs3kr1t"}})
|
||||
session := jsonBody(t, rec)["session"]
|
||||
|
||||
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
|
||||
"password": "sUp3rs3kr1t",
|
||||
"auth": map[string]any{"type": "m.login.dummy", "session": session},
|
||||
}})
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
|
||||
userID, _ := jsonBody(t, rec)["user_id"].(string)
|
||||
if !strings.HasPrefix(userID, "@") || !strings.HasSuffix(userID, ":warp.example") {
|
||||
t.Errorf("generated user_id = %q, want @<something>:warp.example", userID)
|
||||
}
|
||||
if len(userID) <= len("@:warp.example") {
|
||||
t.Errorf("generated user_id %q has an empty localpart", userID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSessionIsSpentOnce(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath,
|
||||
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
|
||||
session := jsonBody(t, rec)["session"]
|
||||
|
||||
complete := map[string]any{
|
||||
"username": "alice",
|
||||
"password": "sUp3rs3kr1t",
|
||||
"auth": map[string]any{"type": "m.login.dummy", "session": session},
|
||||
}
|
||||
expectStatus(t, ts.do(request{method: http.MethodPost, path: registerPath, body: complete}), http.StatusOK)
|
||||
|
||||
// Replaying the identical request must not create a second account; the
|
||||
// name is taken now, and the session is gone either way.
|
||||
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: complete})
|
||||
expectError(t, rec, http.StatusBadRequest, "M_USER_IN_USE")
|
||||
}
|
||||
|
||||
func TestRegisterRespectsTheRegistrationSwitch(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
if err := ts.settings.SetRegistrationEnabled(t.Context(), false); err != nil {
|
||||
t.Fatalf("SetRegistrationEnabled: %v", err)
|
||||
}
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath,
|
||||
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
|
||||
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
|
||||
|
||||
// And comes back without a restart, which is the point of the switch.
|
||||
if err := ts.settings.SetRegistrationEnabled(t.Context(), true); err != nil {
|
||||
t.Fatalf("SetRegistrationEnabled: %v", err)
|
||||
}
|
||||
ts.register("alice", "sUp3rs3kr1t")
|
||||
}
|
||||
|
||||
func TestRegisterRejectsGuests(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath + "?kind=guest",
|
||||
body: map[string]any{}})
|
||||
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
|
||||
}
|
||||
|
||||
// TestRegisterWithRegistrationToken covers the second stage a server with
|
||||
// invite-only sign-up adds to the flow.
|
||||
func TestRegisterWithRegistrationToken(t *testing.T) {
|
||||
ts := newTestServer(t, func(o *clientapi.Options) {
|
||||
o.Registration = config.Registration{Enabled: true, Tokens: []string{"let-me-in"}}
|
||||
})
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: registerPath,
|
||||
body: map[string]any{"username": "alice", "password": "sUp3rs3kr1t"}})
|
||||
expectStatus(t, rec, http.StatusUnauthorized)
|
||||
|
||||
body := jsonBody(t, rec)
|
||||
flows, _ := body["flows"].([]any)
|
||||
stages, _ := flows[0].(map[string]any)["stages"].([]any)
|
||||
if len(stages) != 2 || stages[0] != "m.login.registration_token" {
|
||||
t.Fatalf("stages = %v, want the token stage first", stages)
|
||||
}
|
||||
session := body["session"]
|
||||
|
||||
// A wrong token fails the stage: the client gets the error and the
|
||||
// flows together, so it can prompt again without starting over.
|
||||
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
|
||||
"username": "alice",
|
||||
"password": "sUp3rs3kr1t",
|
||||
"auth": map[string]any{
|
||||
"type": "m.login.registration_token", "session": session, "token": "nope",
|
||||
},
|
||||
}})
|
||||
expectStatus(t, rec, http.StatusUnauthorized)
|
||||
body = jsonBody(t, rec)
|
||||
if body["errcode"] != "M_FORBIDDEN" {
|
||||
t.Errorf("a failed stage must say why, got %s", rec.Body.String())
|
||||
}
|
||||
if body["session"] != session {
|
||||
t.Error("a failed stage must keep the session alive")
|
||||
}
|
||||
|
||||
// The right one advances the flow but does not finish it.
|
||||
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
|
||||
"username": "alice",
|
||||
"password": "sUp3rs3kr1t",
|
||||
"auth": map[string]any{
|
||||
"type": "m.login.registration_token", "session": session, "token": "let-me-in",
|
||||
},
|
||||
}})
|
||||
expectStatus(t, rec, http.StatusUnauthorized)
|
||||
completed, _ := jsonBody(t, rec)["completed"].([]any)
|
||||
if len(completed) != 1 || completed[0] != "m.login.registration_token" {
|
||||
t.Fatalf("completed = %v, want the token stage", completed)
|
||||
}
|
||||
|
||||
// And the dummy stage completes it.
|
||||
rec = ts.do(request{method: http.MethodPost, path: registerPath, body: map[string]any{
|
||||
"username": "alice",
|
||||
"password": "sUp3rs3kr1t",
|
||||
"auth": map[string]any{"type": "m.login.dummy", "session": session},
|
||||
}})
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if jsonBody(t, rec)["user_id"] != "@alice:warp.example" {
|
||||
t.Errorf("user_id = %v", jsonBody(t, rec)["user_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAvailable(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
const path = "/_matrix/client/v3/register/available"
|
||||
|
||||
rec := ts.get(path+"?username=free_name", "")
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if jsonBody(t, rec)["available"] != true {
|
||||
t.Errorf("available = %v, want true", jsonBody(t, rec)["available"])
|
||||
}
|
||||
|
||||
ts.register("taken", "sUp3rs3kr1t")
|
||||
expectError(t, ts.get(path+"?username=taken", ""), http.StatusBadRequest, "M_USER_IN_USE")
|
||||
expectError(t, ts.get(path+"?username=not,valid", ""), http.StatusBadRequest, "M_INVALID_USERNAME")
|
||||
expectError(t, ts.get(path, ""), http.StatusBadRequest, "M_MISSING_PARAM")
|
||||
}
|
||||
|
||||
func TestRegistrationTokenValidity(t *testing.T) {
|
||||
ts := newTestServer(t, func(o *clientapi.Options) {
|
||||
o.Registration = config.Registration{Enabled: true, Tokens: []string{"let-me-in"}}
|
||||
})
|
||||
const path = "/_matrix/client/v1/register/m.login.registration_token/validity"
|
||||
|
||||
rec := ts.get(path+"?token=let-me-in", "")
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if jsonBody(t, rec)["valid"] != true {
|
||||
t.Errorf("valid = %v, want true", jsonBody(t, rec)["valid"])
|
||||
}
|
||||
|
||||
rec = ts.get(path+"?token=nope", "")
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if jsonBody(t, rec)["valid"] != false {
|
||||
t.Errorf("valid = %v, want false", jsonBody(t, rec)["valid"])
|
||||
}
|
||||
|
||||
expectError(t, ts.get(path, ""), http.StatusBadRequest, "M_MISSING_PARAM")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
)
|
||||
|
||||
// User-Interactive Authentication stage types. The spec's full list is long;
|
||||
// these are the ones Warp implements. CAPTCHA, email and terms stages belong
|
||||
// to deployments with public sign-up and arrive with the rest of that story.
|
||||
const (
|
||||
stageDummy = "m.login.dummy"
|
||||
stagePassword = "m.login.password"
|
||||
stageRegistrationToken = "m.login.registration_token"
|
||||
)
|
||||
|
||||
// uiaFlow is one route through the stages. A client may satisfy any flow.
|
||||
type uiaFlow struct {
|
||||
Stages []string `json:"stages"`
|
||||
}
|
||||
|
||||
// uiaChallenge is the 401 body of an incomplete User-Interactive
|
||||
// Authentication: the flows on offer, the session to quote them against, and
|
||||
// what has been done so far.
|
||||
//
|
||||
// When a stage has just been failed it also carries an error envelope, which
|
||||
// is why errcode and error are part of this type rather than sent as a
|
||||
// separate Matrix error: the spec puts both in one 401 body so the client
|
||||
// can show the failure and retry the stage in one step.
|
||||
type uiaChallenge struct {
|
||||
Flows []uiaFlow `json:"flows"`
|
||||
Params map[string]any `json:"params"`
|
||||
Session string `json:"session"`
|
||||
Completed []string `json:"completed,omitempty"`
|
||||
|
||||
ErrCode string `json:"errcode,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// uiaSessionTTL is how long an unfinished authentication may sit idle.
|
||||
// Clients complete a flow in seconds; the window only has to be long enough
|
||||
// for a person to read a prompt and type a password.
|
||||
const uiaSessionTTL = 15 * time.Minute
|
||||
|
||||
// uiaSession tracks one client's progress through a flow set.
|
||||
type uiaSession struct {
|
||||
id string
|
||||
completed []string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// uiaStore holds the in-flight authentication sessions.
|
||||
//
|
||||
// Sessions live in memory. They are worthless after a few minutes, they are
|
||||
// only ever read by the process that wrote them (Warp is one process by
|
||||
// design), and a restart costs a client exactly one extra round trip —
|
||||
// whereas a table would need its own schema, its own sweeper and a write on
|
||||
// every stage.
|
||||
type uiaStore struct {
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*uiaSession
|
||||
sweptAt time.Time
|
||||
}
|
||||
|
||||
func newUIAStore(ttl time.Duration, now func() time.Time) *uiaStore {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &uiaStore{ttl: ttl, now: now, sessions: map[string]*uiaSession{}}
|
||||
}
|
||||
|
||||
// create starts a session.
|
||||
func (s *uiaStore) create() (*uiaSession, error) {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, fmt.Errorf("generate auth session: %w", err)
|
||||
}
|
||||
session := &uiaSession{
|
||||
id: base64.RawURLEncoding.EncodeToString(raw),
|
||||
expiresAt: s.now().Add(s.ttl),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sweep()
|
||||
s.sessions[session.id] = session
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// get returns a live session, or nil if it is unknown or expired.
|
||||
func (s *uiaStore) get(id string) *uiaSession {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
session, ok := s.sessions[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if s.now().After(session.expiresAt) {
|
||||
delete(s.sessions, id)
|
||||
return nil
|
||||
}
|
||||
// Each copy handed out is independent, so a caller cannot mutate the
|
||||
// stored progress by accident.
|
||||
return &uiaSession{id: session.id, completed: slices.Clone(session.completed), expiresAt: session.expiresAt}
|
||||
}
|
||||
|
||||
// complete records a finished stage and returns the session's new progress.
|
||||
func (s *uiaStore) complete(id, stage string) []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
session, ok := s.sessions[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !slices.Contains(session.completed, stage) {
|
||||
session.completed = append(session.completed, stage)
|
||||
}
|
||||
// Finishing a stage is progress, so the window starts again: a flow
|
||||
// with several stages must not time out halfway through.
|
||||
session.expiresAt = s.now().Add(s.ttl)
|
||||
return slices.Clone(session.completed)
|
||||
}
|
||||
|
||||
// remove forgets a session, which happens once its flow has been acted on.
|
||||
// Leaving it behind would let a replayed request create a second account or
|
||||
// delete a second device.
|
||||
func (s *uiaStore) remove(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
|
||||
// sweep drops expired sessions. It runs under the caller's lock, at most
|
||||
// once per TTL.
|
||||
func (s *uiaStore) sweep() {
|
||||
now := s.now()
|
||||
if now.Sub(s.sweptAt) < s.ttl {
|
||||
return
|
||||
}
|
||||
s.sweptAt = now
|
||||
for id, session := range s.sessions {
|
||||
if now.After(session.expiresAt) {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// uiaRequest is the `auth` dictionary a client sends. The stage-specific
|
||||
// fields are flattened into it rather than nested, which is how the spec
|
||||
// defines each stage's payload.
|
||||
type uiaRequest struct {
|
||||
Type string `json:"type"`
|
||||
Session string `json:"session"`
|
||||
|
||||
// Token is the m.login.registration_token payload.
|
||||
Token string `json:"token"`
|
||||
|
||||
// Identifier and Password are the m.login.password payload.
|
||||
Identifier loginIdentifier `json:"identifier"`
|
||||
Password string `json:"password"`
|
||||
|
||||
// User is the deprecated top-level form of Identifier, still sent by
|
||||
// older clients.
|
||||
User string `json:"user"`
|
||||
}
|
||||
|
||||
// flowSatisfied reports whether completed covers every stage of any flow.
|
||||
func flowSatisfied(flows []uiaFlow, completed []string) bool {
|
||||
for _, flow := range flows {
|
||||
done := true
|
||||
for _, stage := range flow.Stages {
|
||||
if !slices.Contains(completed, stage) {
|
||||
done = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if done {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// challenge writes a 401 asking the client to complete a stage.
|
||||
//
|
||||
// cause is optional: when a stage was attempted and failed, the client gets
|
||||
// the failure and the flows in one body, which is what lets it re-prompt
|
||||
// without starting over.
|
||||
func (c *ClientAPI) challenge(w http.ResponseWriter, flows []uiaFlow, session *uiaSession, cause *httputil.MatrixError) {
|
||||
body := uiaChallenge{
|
||||
Flows: flows,
|
||||
// Always an object, never null: clients and Complement both expect
|
||||
// `params` to be present even when no stage needs parameters.
|
||||
Params: map[string]any{},
|
||||
Session: session.id,
|
||||
Completed: session.completed,
|
||||
}
|
||||
if cause != nil {
|
||||
body.ErrCode, body.Error = cause.Code, cause.Message
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusUnauthorized, body)
|
||||
}
|
||||
|
||||
// stageHandler checks one stage of a flow.
|
||||
//
|
||||
// The two return values are different kinds of "no": a *MatrixError is a
|
||||
// stage the client may retry, and is sent back inside the 401 alongside the
|
||||
// flows; a plain error ends the request. Wrong password is the first kind,
|
||||
// authenticating as somebody else is the second.
|
||||
type stageHandler func(stage string, auth *uiaRequest) (*httputil.MatrixError, error)
|
||||
|
||||
// beginOrAdvanceUIA drives one request through a flow set.
|
||||
//
|
||||
// It returns done=true when the client has satisfied a flow and the caller
|
||||
// may go ahead; otherwise it has already written the 401 challenge. The
|
||||
// returned session ID is the one to forget once the operation succeeds.
|
||||
func (c *ClientAPI) beginOrAdvanceUIA(
|
||||
w http.ResponseWriter,
|
||||
flows []uiaFlow,
|
||||
auth *uiaRequest,
|
||||
handleStage stageHandler,
|
||||
) (sessionID string, done bool, err error) {
|
||||
// No auth at all, or an auth without the session the server handed out,
|
||||
// starts a new attempt. The spec requires the session to be quoted back,
|
||||
// so a request that drops it is not treated as a continuation.
|
||||
if auth == nil || auth.Type == "" || auth.Session == "" {
|
||||
session, createErr := c.uia.create()
|
||||
if createErr != nil {
|
||||
return "", false, createErr
|
||||
}
|
||||
c.challenge(w, flows, session, nil)
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
session := c.uia.get(auth.Session)
|
||||
if session == nil {
|
||||
// An unknown or expired session: start a fresh one rather than
|
||||
// failing, so a client that was slow can simply carry on.
|
||||
session, createErr := c.uia.create()
|
||||
if createErr != nil {
|
||||
return "", false, createErr
|
||||
}
|
||||
c.challenge(w, flows, session,
|
||||
httputil.NewError(http.StatusUnauthorized, httputil.ErrCodeUnrecognized,
|
||||
"The authentication session has expired; start again"))
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
if !stageOffered(flows, auth.Type) {
|
||||
return "", false, httputil.NewError(http.StatusBadRequest, httputil.ErrCodeUnrecognized,
|
||||
"Unknown authentication type %q", auth.Type)
|
||||
}
|
||||
|
||||
retryable, fatal := handleStage(auth.Type, auth)
|
||||
if fatal != nil {
|
||||
return "", false, fatal
|
||||
}
|
||||
if retryable != nil {
|
||||
c.challenge(w, flows, session, retryable)
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
session.completed = c.uia.complete(session.id, auth.Type)
|
||||
if !flowSatisfied(flows, session.completed) {
|
||||
c.challenge(w, flows, session, nil)
|
||||
return "", false, nil
|
||||
}
|
||||
return session.id, true, nil
|
||||
}
|
||||
|
||||
// stageOffered reports whether stage appears in any of the flows, so that a
|
||||
// client cannot complete a stage this operation never asked for.
|
||||
func stageOffered(flows []uiaFlow, stage string) bool {
|
||||
for _, flow := range flows {
|
||||
if slices.Contains(flow.Stages, stage) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package clientapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/clientapi"
|
||||
)
|
||||
|
||||
// TestLoginCarriesDiscovery covers the shortcut the spec allows: a client
|
||||
// that only knew the server name learns its API URL from the login response
|
||||
// instead of fetching /.well-known separately.
|
||||
func TestLoginCarriesDiscovery(t *testing.T) {
|
||||
ts := newTestServer(t, func(o *clientapi.Options) {
|
||||
o.ClientBaseURL = "https://matrix.warp.example"
|
||||
})
|
||||
ts.register("alice", "superuser")
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
|
||||
"type": "m.login.password",
|
||||
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
|
||||
"password": "superuser",
|
||||
}})
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
|
||||
wellKnown, ok := jsonBody(t, rec)["well_known"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("login response carries no well_known: %s", rec.Body.String())
|
||||
}
|
||||
homeserver, _ := wellKnown["m.homeserver"].(map[string]any)
|
||||
if homeserver["base_url"] != "https://matrix.warp.example" {
|
||||
t.Errorf("base_url = %v", homeserver["base_url"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginOmitsDiscoveryWhenUnconfigured keeps the response from carrying
|
||||
// an empty object a client would have to guess at.
|
||||
func TestLoginOmitsDiscoveryWhenUnconfigured(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
ts.register("alice", "superuser")
|
||||
|
||||
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
|
||||
"type": "m.login.password",
|
||||
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
|
||||
"password": "superuser",
|
||||
}})
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
|
||||
if _, present := jsonBody(t, rec)["well_known"]; present {
|
||||
t.Error("well_known must be omitted when no client base URL is configured")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/adminapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
|
||||
"github.com/dariuszjaniak/warp/server/internal/clientapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/config"
|
||||
"github.com/dariuszjaniak/warp/server/internal/federation"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
"github.com/dariuszjaniak/warp/server/internal/settings"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers" // link in the storage drivers
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
|
||||
// Server is a configured, not yet listening homeserver.
|
||||
@@ -35,6 +38,10 @@ type Server struct {
|
||||
federation *federation.Gate
|
||||
signingKey *signing.KeyPair
|
||||
|
||||
users *userapi.API
|
||||
clientAPI *clientapi.ClientAPI
|
||||
adminAPI *adminapi.AdminAPI
|
||||
|
||||
handler http.Handler
|
||||
|
||||
// addr is the address Run is listening on; empty until then.
|
||||
@@ -123,6 +130,47 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
|
||||
log.Info("federation switch applied", "enabled", snap.FederationEnabled)
|
||||
})
|
||||
|
||||
s.users, err = userapi.New(userapi.Options{
|
||||
ServerName: cfg.Global.ServerName,
|
||||
Store: s.db.Users(),
|
||||
AccessTokenLifetime: cfg.ClientAPI.AccessTokenLifetime,
|
||||
RefreshTokenLifetime: cfg.ClientAPI.RefreshTokenLifetime,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.closeDB()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.clientAPI, err = clientapi.New(clientapi.Options{
|
||||
ServerName: cfg.Global.ServerName,
|
||||
ClientBaseURL: cfg.ClientBaseURL(),
|
||||
Users: s.users,
|
||||
Settings: settingsService,
|
||||
Federation: s.federation,
|
||||
Registration: cfg.Registration,
|
||||
RateLimit: cfg.ClientAPI.RateLimit,
|
||||
TrustXForwardedFor: cfg.ClientAPI.TrustXForwardedFor,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.closeDB()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.adminAPI, err = adminapi.New(adminapi.Options{
|
||||
ServerName: cfg.Global.ServerName,
|
||||
Users: s.users,
|
||||
Settings: settingsService,
|
||||
Federation: s.federation,
|
||||
Database: s.db,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.closeDB()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.handler = httputil.Chain(s.routes(),
|
||||
httputil.Recoverer,
|
||||
httputil.RequestLogger(log),
|
||||
@@ -170,6 +218,10 @@ func (s *Server) Settings() *settings.Service { return s.settings }
|
||||
// Database exposes the storage handle.
|
||||
func (s *Server) Database() storage.Database { return s.db }
|
||||
|
||||
// Users exposes the userapi component, which `warp create-account` and the
|
||||
// tests use to create accounts without going through HTTP.
|
||||
func (s *Server) Users() *userapi.API { return s.users }
|
||||
|
||||
// SigningKey returns the server signing key, or nil when it was skipped.
|
||||
func (s *Server) SigningKey() *signing.KeyPair { return s.signingKey }
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ func (*Database) PendingMigrations(context.Context) ([]string, error) {
|
||||
// Settings implements storage.Database.
|
||||
func (*Database) Settings() storage.SettingsStore { return notImplementedSettings{} }
|
||||
|
||||
// Users implements storage.Database.
|
||||
func (*Database) Users() storage.UserStore { return notImplementedUsers{} }
|
||||
|
||||
// Close implements storage.Database.
|
||||
func (*Database) Close() error { return nil }
|
||||
|
||||
@@ -75,5 +78,93 @@ func (notImplementedSettings) Delete(context.Context, string) error {
|
||||
return storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
// Compile-time proof that the stub keeps up with the interface.
|
||||
var _ storage.Database = (*Database)(nil)
|
||||
// notImplementedUsers satisfies storage.UserStore the same way: a
|
||||
// half-wired PocketBase deployment must not accept a registration it cannot
|
||||
// store, or hand out an access token it cannot look up again.
|
||||
type notImplementedUsers struct{}
|
||||
|
||||
func (notImplementedUsers) CreateAccount(context.Context, storage.NewAccount) (storage.Account, error) {
|
||||
return storage.Account{}, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) Account(context.Context, string) (storage.Account, error) {
|
||||
return storage.Account{}, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) PasswordHash(context.Context, string) (string, error) {
|
||||
return "", storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) SetPasswordHash(context.Context, string, string) error {
|
||||
return storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) SetAdmin(context.Context, string, bool) error {
|
||||
return storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) SetDisplayName(context.Context, string, string) error {
|
||||
return storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) SetAvatarURL(context.Context, string, string) error {
|
||||
return storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) ListAccounts(context.Context, storage.AccountFilter) ([]storage.Account, int, error) {
|
||||
return nil, 0, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) Stats(context.Context) (storage.Stats, error) {
|
||||
return storage.Stats{}, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) CreateSession(context.Context, storage.NewSession) (storage.Device, error) {
|
||||
return storage.Device{}, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) RefreshSession(context.Context, storage.RefreshedSession) (storage.TokenInfo, error) {
|
||||
return storage.TokenInfo{}, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) TokenInfo(context.Context, string) (storage.TokenInfo, error) {
|
||||
return storage.TokenInfo{}, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) TouchDevice(context.Context, string, string, string, string, int64) error {
|
||||
return storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) Device(context.Context, string, string) (storage.Device, error) {
|
||||
return storage.Device{}, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) Devices(context.Context, string) ([]storage.Device, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) SetDeviceDisplayName(context.Context, string, string, string) error {
|
||||
return storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) DeleteDevices(context.Context, string, []string) (int, error) {
|
||||
return 0, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) DeleteAllDevices(context.Context, string) (int, error) {
|
||||
return 0, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) AccountData(context.Context, string, string, string) ([]byte, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedUsers) SetAccountData(context.Context, string, string, string, []byte) error {
|
||||
return storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
// Compile-time proof that the stub keeps up with the interfaces.
|
||||
var (
|
||||
_ storage.Database = (*Database)(nil)
|
||||
_ storage.UserStore = notImplementedUsers{}
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// uniqueViolation is the SQLSTATE class PostgreSQL raises for a duplicate
|
||||
// key, whichever constraint caught it.
|
||||
const uniqueViolation = "23505"
|
||||
|
||||
// isUniqueViolation reports whether err is PostgreSQL complaining about a
|
||||
// duplicate key.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation
|
||||
}
|
||||
|
||||
// rebind turns the `?` placeholders the shared component stores are written
|
||||
// with into PostgreSQL's numbered form. It is a straight scan rather than a
|
||||
// SQL parser, which is safe because the statements it sees are constants in
|
||||
// this repository: none of them contains a `?` inside a string literal, and
|
||||
// none is built from user input beyond a run of placeholders.
|
||||
func rebind(query string) string {
|
||||
if !strings.Contains(query, "?") {
|
||||
return query
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(query) + 8)
|
||||
n := 0
|
||||
for i := range len(query) {
|
||||
if query[i] != '?' {
|
||||
b.WriteByte(query[i])
|
||||
continue
|
||||
}
|
||||
n++
|
||||
b.WriteByte('$')
|
||||
b.WriteString(strconv.Itoa(n))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package postgres
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRebind covers the translation the shared component stores depend on.
|
||||
// It runs without a database, which matters: it is the one part of the
|
||||
// PostgreSQL driver that can be tested on a machine without PostgreSQL, and
|
||||
// a mistake here breaks every statement at once.
|
||||
func TestRebind(t *testing.T) {
|
||||
for name, tc := range map[string]struct{ in, want string }{
|
||||
"no placeholders": {
|
||||
in: `SELECT COUNT(*) FROM user_accounts`,
|
||||
want: `SELECT COUNT(*) FROM user_accounts`,
|
||||
},
|
||||
"one placeholder": {
|
||||
in: `SELECT password_hash FROM user_accounts WHERE localpart = ?`,
|
||||
want: `SELECT password_hash FROM user_accounts WHERE localpart = $1`,
|
||||
},
|
||||
"numbered in order": {
|
||||
in: `INSERT INTO user_accounts (localpart, admin) VALUES (?, ?)`,
|
||||
want: `INSERT INTO user_accounts (localpart, admin) VALUES ($1, $2)`,
|
||||
},
|
||||
"past nine": {
|
||||
in: `VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
want: `VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
},
|
||||
"leaves the escape clause alone": {
|
||||
in: `WHERE localpart LIKE ? ESCAPE '!'`,
|
||||
want: `WHERE localpart LIKE $1 ESCAPE '!'`,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := rebind(tc.in); got != tc.want {
|
||||
t.Errorf("rebind(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUniqueViolationIgnoresOtherErrors(t *testing.T) {
|
||||
if isUniqueViolation(nil) {
|
||||
t.Error("nil is not a unique violation")
|
||||
}
|
||||
if isUniqueViolation(errOther{}) {
|
||||
t.Error("an unrelated error was reported as a unique violation")
|
||||
}
|
||||
}
|
||||
|
||||
type errOther struct{}
|
||||
|
||||
func (errOther) Error() string { return "something else went wrong" }
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Accounts, devices, tokens and account data: the userapi tables.
|
||||
--
|
||||
-- Timestamps are milliseconds since the Unix epoch, the unit the
|
||||
-- Client-Server API uses on the wire, so values go in and out unconverted
|
||||
-- and both SQL drivers store the same numbers.
|
||||
--
|
||||
-- Tokens are stored as SHA-256 hashes, never in the clear: they are bearer
|
||||
-- credentials that by default never expire, and one hash per request is
|
||||
-- cheap next to what a leaked database dump would otherwise hand over.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_accounts (
|
||||
localpart TEXT NOT NULL PRIMARY KEY,
|
||||
-- An empty hash means the account has no password and cannot use
|
||||
-- m.login.password; it is not a hash that anything can match.
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
deactivated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_devices (
|
||||
localpart TEXT NOT NULL REFERENCES user_accounts (localpart) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
last_seen_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_seen_ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (localpart, device_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_access_tokens (
|
||||
token_hash TEXT NOT NULL PRIMARY KEY,
|
||||
localpart TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
-- 0 means the token never expires.
|
||||
expires_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (localpart, device_id)
|
||||
REFERENCES user_devices (localpart, device_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Revoking a device deletes its tokens by this index, not by table scan.
|
||||
CREATE INDEX IF NOT EXISTS user_access_tokens_device_idx
|
||||
ON user_access_tokens (localpart, device_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_refresh_tokens (
|
||||
token_hash TEXT NOT NULL PRIMARY KEY,
|
||||
localpart TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
expires_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (localpart, device_id)
|
||||
REFERENCES user_devices (localpart, device_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_refresh_tokens_device_idx
|
||||
ON user_refresh_tokens (localpart, device_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_account_data (
|
||||
localpart TEXT NOT NULL REFERENCES user_accounts (localpart) ON DELETE CASCADE,
|
||||
-- Empty for global account data; a room ID for per-room account data.
|
||||
room_id TEXT NOT NULL DEFAULT '',
|
||||
data_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
PRIMARY KEY (localpart, room_id, data_type)
|
||||
);
|
||||
@@ -69,13 +69,15 @@ func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Da
|
||||
}
|
||||
|
||||
store, err := sqlstore.New(sqlstore.Options{
|
||||
DriverName: storage.DriverPostgres,
|
||||
DB: db,
|
||||
Dialect: dialect,
|
||||
MigrationsFS: migrationFiles,
|
||||
MigrationsDir: "migrations",
|
||||
Settings: settingsQueries,
|
||||
Logger: log,
|
||||
DriverName: storage.DriverPostgres,
|
||||
DB: db,
|
||||
Dialect: dialect,
|
||||
MigrationsFS: migrationFiles,
|
||||
MigrationsDir: "migrations",
|
||||
Settings: settingsQueries,
|
||||
Rebind: rebind,
|
||||
IsUniqueViolation: isUniqueViolation,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
sqlite3 "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// SQLite extended result codes for the two constraint failures that mean
|
||||
// "that row is already there". The driver reports them numerically and does
|
||||
// not re-export the constants, so they are named here rather than left as
|
||||
// magic numbers at the call site.
|
||||
const (
|
||||
constraintUnique = 2067 // SQLITE_CONSTRAINT_UNIQUE
|
||||
constraintPrimaryKey = 1555 // SQLITE_CONSTRAINT_PRIMARYKEY
|
||||
)
|
||||
|
||||
// isUniqueViolation reports whether err is SQLite complaining about a
|
||||
// duplicate key.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var sqlErr *sqlite3.Error
|
||||
if !errors.As(err, &sqlErr) {
|
||||
return false
|
||||
}
|
||||
switch sqlErr.Code() {
|
||||
case constraintUnique, constraintPrimaryKey:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsUniqueViolation checks the error recognition against a real SQLite,
|
||||
// because it turns on an extended result code the driver does not export as
|
||||
// a constant: a version bump that changed it would otherwise turn "that name
|
||||
// is taken" into a 500.
|
||||
func TestIsUniqueViolation(t *testing.T) {
|
||||
path := filepath.ToSlash(filepath.Join(t.TempDir(), "test.db"))
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if _, err := db.ExecContext(t.Context(),
|
||||
`CREATE TABLE t (pk TEXT PRIMARY KEY, uniq TEXT UNIQUE)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insert := func(pk, uniq string) error {
|
||||
_, err := db.ExecContext(t.Context(), `INSERT INTO t (pk, uniq) VALUES (?, ?)`, pk, uniq)
|
||||
return err
|
||||
}
|
||||
if err := insert("a", "one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for name, err := range map[string]error{
|
||||
"duplicate primary key": insert("a", "two"),
|
||||
"duplicate unique key": insert("b", "one"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("the insert should have failed")
|
||||
}
|
||||
if !isUniqueViolation(err) {
|
||||
t.Errorf("isUniqueViolation(%v) = false, want true", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Something that is an error but not a constraint failure.
|
||||
_, err = db.ExecContext(t.Context(), `INSERT INTO nonexistent (x) VALUES (1)`)
|
||||
if err == nil {
|
||||
t.Fatal("inserting into a missing table should have failed")
|
||||
}
|
||||
if isUniqueViolation(err) {
|
||||
t.Errorf("isUniqueViolation(%v) = true, want false", err)
|
||||
}
|
||||
|
||||
if isUniqueViolation(nil) || isUniqueViolation(fmt.Errorf("unrelated")) {
|
||||
t.Error("isUniqueViolation accepted something that is not a SQLite error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Accounts, devices, tokens and account data: the userapi tables.
|
||||
--
|
||||
-- Timestamps are milliseconds since the Unix epoch, the unit the
|
||||
-- Client-Server API uses on the wire, so values go in and out unconverted
|
||||
-- and both SQL drivers store the same numbers.
|
||||
--
|
||||
-- Tokens are stored as SHA-256 hashes, never in the clear: they are bearer
|
||||
-- credentials that by default never expire, and one hash per request is
|
||||
-- cheap next to what a leaked database dump would otherwise hand over.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_accounts (
|
||||
localpart TEXT NOT NULL PRIMARY KEY,
|
||||
-- An empty hash means the account has no password and cannot use
|
||||
-- m.login.password; it is not a hash that anything can match.
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
admin BOOLEAN NOT NULL DEFAULT 0,
|
||||
deactivated BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_devices (
|
||||
localpart TEXT NOT NULL REFERENCES user_accounts (localpart) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
last_seen_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_seen_ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (localpart, device_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_access_tokens (
|
||||
token_hash TEXT NOT NULL PRIMARY KEY,
|
||||
localpart TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
-- 0 means the token never expires.
|
||||
expires_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (localpart, device_id)
|
||||
REFERENCES user_devices (localpart, device_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Revoking a device deletes its tokens by this index, not by table scan.
|
||||
CREATE INDEX IF NOT EXISTS user_access_tokens_device_idx
|
||||
ON user_access_tokens (localpart, device_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_refresh_tokens (
|
||||
token_hash TEXT NOT NULL PRIMARY KEY,
|
||||
localpart TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
expires_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (localpart, device_id)
|
||||
REFERENCES user_devices (localpart, device_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_refresh_tokens_device_idx
|
||||
ON user_refresh_tokens (localpart, device_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_account_data (
|
||||
localpart TEXT NOT NULL REFERENCES user_accounts (localpart) ON DELETE CASCADE,
|
||||
-- Empty for global account data; a room ID for per-room account data.
|
||||
room_id TEXT NOT NULL DEFAULT '',
|
||||
data_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
PRIMARY KEY (localpart, room_id, data_type)
|
||||
);
|
||||
@@ -93,7 +93,10 @@ func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Da
|
||||
MigrationsFS: migrationFiles,
|
||||
MigrationsDir: "migrations",
|
||||
Settings: settingsQueries,
|
||||
Logger: log,
|
||||
// SQLite already uses the `?` placeholders the shared component
|
||||
// stores are written with, so there is nothing to rebind.
|
||||
IsUniqueViolation: isUniqueViolation,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
|
||||
@@ -35,6 +35,16 @@ type Options struct {
|
||||
// Settings holds the statements for the server_settings table.
|
||||
Settings SettingsQueries
|
||||
|
||||
// Rebind translates the `?` placeholders that the shared component
|
||||
// stores are written with into this driver's style. SQLite needs no
|
||||
// translation and leaves it nil; PostgreSQL turns them into $1, $2...
|
||||
Rebind func(query string) string
|
||||
|
||||
// IsUniqueViolation recognises this driver's unique-constraint error, so
|
||||
// that a taken user name can be told apart from a database failure
|
||||
// without a check-then-insert race.
|
||||
IsUniqueViolation func(error) bool
|
||||
|
||||
// Logger is used for migration progress.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
@@ -46,6 +56,7 @@ type Store struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
settings *Settings
|
||||
users *Users
|
||||
}
|
||||
|
||||
// New builds a Store. It does not touch the database.
|
||||
@@ -61,6 +72,7 @@ func New(opts Options) (*Store, error) {
|
||||
db: opts.DB,
|
||||
log: opts.Logger,
|
||||
settings: NewSettings(opts.DB, opts.Settings),
|
||||
users: NewUsers(opts.DB, opts.Rebind, opts.IsUniqueViolation),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -79,6 +91,9 @@ func (s *Store) Close() error { return s.db.Close() }
|
||||
// Settings implements storage.Database.
|
||||
func (s *Store) Settings() storage.SettingsStore { return s.settings }
|
||||
|
||||
// Users implements storage.Database.
|
||||
func (s *Store) Users() storage.UserStore { return s.users }
|
||||
|
||||
// Migrate implements storage.Database.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
ms, err := s.migrations()
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// Users is the shared SQL implementation of storage.UserStore.
|
||||
//
|
||||
// Unlike the settings store, whose four statements each driver writes in its
|
||||
// own style, the user store has around thirty. Keeping two copies of them
|
||||
// differing only in `?` versus `$1` would be a standing invitation to fix a
|
||||
// bug in one and not the other, so the SQL is written once with `?` and the
|
||||
// driver supplies a Rebind function. The statements themselves use only
|
||||
// syntax both engines agree on: ON CONFLICT ... DO UPDATE with `excluded`,
|
||||
// LIMIT/OFFSET, and comparisons against bound values rather than boolean
|
||||
// literals.
|
||||
type Users struct {
|
||||
db *sql.DB
|
||||
rebind func(string) string
|
||||
isUnique func(error) bool
|
||||
}
|
||||
|
||||
// NewUsers builds a user store. rebind translates the `?` placeholders into
|
||||
// the driver's own style, and isUnique recognises its unique-constraint
|
||||
// error; nil means "no translation" and "never", respectively.
|
||||
func NewUsers(db *sql.DB, rebind func(string) string, isUnique func(error) bool) *Users {
|
||||
if rebind == nil {
|
||||
rebind = func(query string) string { return query }
|
||||
}
|
||||
if isUnique == nil {
|
||||
isUnique = func(error) bool { return false }
|
||||
}
|
||||
return &Users{db: db, rebind: rebind, isUnique: isUnique}
|
||||
}
|
||||
|
||||
// defaultAccountPageSize bounds an admin user listing that asks for no limit,
|
||||
// so a forgotten ?limit= cannot stream the whole user table.
|
||||
const defaultAccountPageSize = 100
|
||||
|
||||
// maxAccountPageSize caps what a caller may ask for.
|
||||
const maxAccountPageSize = 1000
|
||||
|
||||
// q rebinds a statement for this driver.
|
||||
func (u *Users) q(query string) string { return u.rebind(query) }
|
||||
|
||||
// tx runs fn in a transaction, rolling back on any error. The rollback after
|
||||
// a successful commit is a no-op, which is why it can be deferred blindly.
|
||||
func (u *Users) tx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||
tx, err := u.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const insertAccountSQL = `
|
||||
INSERT INTO user_accounts (localpart, password_hash, display_name, admin, created_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
|
||||
// CreateAccount implements storage.UserStore.
|
||||
//
|
||||
// Taken localparts are detected by inserting and letting the primary key
|
||||
// complain, rather than by checking first: the check-then-insert version has
|
||||
// a race that two simultaneous registrations of the same name would walk
|
||||
// straight into.
|
||||
func (u *Users) CreateAccount(ctx context.Context, acc storage.NewAccount) (storage.Account, error) {
|
||||
_, err := u.db.ExecContext(ctx, u.q(insertAccountSQL),
|
||||
acc.Localpart, acc.PasswordHash, acc.DisplayName, acc.Admin, acc.CreatedAtMS)
|
||||
if err != nil {
|
||||
if u.isUnique(err) {
|
||||
return storage.Account{}, fmt.Errorf("create account %q: %w", acc.Localpart, storage.ErrUserExists)
|
||||
}
|
||||
return storage.Account{}, fmt.Errorf("create account %q: %w", acc.Localpart, err)
|
||||
}
|
||||
return storage.Account{
|
||||
Localpart: acc.Localpart,
|
||||
DisplayName: acc.DisplayName,
|
||||
Admin: acc.Admin,
|
||||
CreatedAtMS: acc.CreatedAtMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const selectAccountSQL = `
|
||||
SELECT localpart, display_name, avatar_url, admin, deactivated, created_at_ms
|
||||
FROM user_accounts WHERE localpart = ?`
|
||||
|
||||
// Account implements storage.UserStore.
|
||||
func (u *Users) Account(ctx context.Context, localpart string) (storage.Account, error) {
|
||||
var acc storage.Account
|
||||
err := u.db.QueryRowContext(ctx, u.q(selectAccountSQL), localpart).Scan(
|
||||
&acc.Localpart, &acc.DisplayName, &acc.AvatarURL,
|
||||
&acc.Admin, &acc.Deactivated, &acc.CreatedAtMS)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return storage.Account{}, fmt.Errorf("account %q: %w", localpart, storage.ErrNoSuchUser)
|
||||
case err != nil:
|
||||
return storage.Account{}, fmt.Errorf("account %q: %w", localpart, err)
|
||||
}
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
const selectPasswordHashSQL = `SELECT password_hash FROM user_accounts WHERE localpart = ?`
|
||||
|
||||
// PasswordHash implements storage.UserStore.
|
||||
func (u *Users) PasswordHash(ctx context.Context, localpart string) (string, error) {
|
||||
var hash string
|
||||
err := u.db.QueryRowContext(ctx, u.q(selectPasswordHashSQL), localpart).Scan(&hash)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return "", fmt.Errorf("password for %q: %w", localpart, storage.ErrNoSuchUser)
|
||||
case err != nil:
|
||||
return "", fmt.Errorf("password for %q: %w", localpart, err)
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// SetPasswordHash implements storage.UserStore.
|
||||
func (u *Users) SetPasswordHash(ctx context.Context, localpart, hash string) error {
|
||||
return u.updateAccount(ctx, localpart,
|
||||
`UPDATE user_accounts SET password_hash = ? WHERE localpart = ?`, hash)
|
||||
}
|
||||
|
||||
// SetAdmin implements storage.UserStore.
|
||||
func (u *Users) SetAdmin(ctx context.Context, localpart string, admin bool) error {
|
||||
return u.updateAccount(ctx, localpart,
|
||||
`UPDATE user_accounts SET admin = ? WHERE localpart = ?`, admin)
|
||||
}
|
||||
|
||||
// SetDisplayName implements storage.UserStore.
|
||||
func (u *Users) SetDisplayName(ctx context.Context, localpart, displayName string) error {
|
||||
return u.updateAccount(ctx, localpart,
|
||||
`UPDATE user_accounts SET display_name = ? WHERE localpart = ?`, displayName)
|
||||
}
|
||||
|
||||
// SetAvatarURL implements storage.UserStore.
|
||||
func (u *Users) SetAvatarURL(ctx context.Context, localpart, avatarURL string) error {
|
||||
return u.updateAccount(ctx, localpart,
|
||||
`UPDATE user_accounts SET avatar_url = ? WHERE localpart = ?`, avatarURL)
|
||||
}
|
||||
|
||||
// updateAccount runs a single-column update and turns "nothing changed" into
|
||||
// ErrNoSuchUser, so callers do not have to look the account up first.
|
||||
func (u *Users) updateAccount(ctx context.Context, localpart, query string, value any) error {
|
||||
res, err := u.db.ExecContext(ctx, u.q(query), value, localpart)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update account %q: %w", localpart, err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update account %q: %w", localpart, err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("update account %q: %w", localpart, storage.ErrNoSuchUser)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// likeEscape is the escape character the search patterns use.
|
||||
//
|
||||
// It is deliberately not a backslash: a backslash in a SQL string literal
|
||||
// means different things depending on PostgreSQL's standard_conforming_
|
||||
// strings setting, and "!" means exactly one thing everywhere.
|
||||
const likeEscape = '!'
|
||||
|
||||
const (
|
||||
countAccountsSQL = `
|
||||
SELECT COUNT(*) FROM user_accounts
|
||||
WHERE LOWER(localpart) LIKE ? ESCAPE '!' OR LOWER(display_name) LIKE ? ESCAPE '!'`
|
||||
|
||||
listAccountsSQL = `
|
||||
SELECT localpart, display_name, avatar_url, admin, deactivated, created_at_ms
|
||||
FROM user_accounts
|
||||
WHERE LOWER(localpart) LIKE ? ESCAPE '!' OR LOWER(display_name) LIKE ? ESCAPE '!'
|
||||
ORDER BY localpart
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
|
||||
// ListAccounts implements storage.UserStore.
|
||||
//
|
||||
// The search is a LOWER(...) LIKE rather than ILIKE, which is PostgreSQL
|
||||
// only, and the pattern is escaped so that searching for "_" does not
|
||||
// quietly match every single-character name.
|
||||
func (u *Users) ListAccounts(ctx context.Context, filter storage.AccountFilter) ([]storage.Account, int, error) {
|
||||
pattern := likeContains(filter.Search)
|
||||
|
||||
limit := filter.Limit
|
||||
switch {
|
||||
case limit <= 0:
|
||||
limit = defaultAccountPageSize
|
||||
case limit > maxAccountPageSize:
|
||||
limit = maxAccountPageSize
|
||||
}
|
||||
offset := max(filter.Offset, 0)
|
||||
|
||||
var total int
|
||||
if err := u.db.QueryRowContext(ctx, u.q(countAccountsSQL), pattern, pattern).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count accounts: %w", err)
|
||||
}
|
||||
|
||||
rows, err := u.db.QueryContext(ctx, u.q(listAccountsSQL), pattern, pattern, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list accounts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
accounts := []storage.Account{}
|
||||
for rows.Next() {
|
||||
var acc storage.Account
|
||||
if err := rows.Scan(&acc.Localpart, &acc.DisplayName, &acc.AvatarURL,
|
||||
&acc.Admin, &acc.Deactivated, &acc.CreatedAtMS); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan account: %w", err)
|
||||
}
|
||||
accounts = append(accounts, acc)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("list accounts: %w", err)
|
||||
}
|
||||
return accounts, total, nil
|
||||
}
|
||||
|
||||
// likeContains turns a search term into a LIKE pattern matching it
|
||||
// anywhere, escaping the two wildcards and the escape character itself. The
|
||||
// statements using it declare ESCAPE explicitly, because SQLite has no
|
||||
// default escape character at all.
|
||||
func likeContains(search string) string {
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
var b strings.Builder
|
||||
b.Grow(len(search) + 2)
|
||||
b.WriteByte('%')
|
||||
for _, r := range search {
|
||||
if r == '%' || r == '_' || r == likeEscape {
|
||||
b.WriteByte(likeEscape)
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
b.WriteByte('%')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
const statsSQL = `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM user_accounts),
|
||||
(SELECT COUNT(*) FROM user_accounts WHERE admin = ?),
|
||||
(SELECT COUNT(*) FROM user_accounts WHERE deactivated = ?),
|
||||
(SELECT COUNT(*) FROM user_devices)`
|
||||
|
||||
// Stats implements storage.UserStore.
|
||||
func (u *Users) Stats(ctx context.Context) (storage.Stats, error) {
|
||||
var s storage.Stats
|
||||
// The flags are compared against bound values rather than TRUE, which
|
||||
// SQLite only learned in 3.23 and which PostgreSQL spells differently
|
||||
// from an integer.
|
||||
err := u.db.QueryRowContext(ctx, u.q(statsSQL), true, true).
|
||||
Scan(&s.Users, &s.Admins, &s.Deactivated, &s.Devices)
|
||||
if err != nil {
|
||||
return storage.Stats{}, fmt.Errorf("user stats: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
const (
|
||||
selectDeviceForUpdateSQL = `
|
||||
SELECT display_name, created_at_ms FROM user_devices
|
||||
WHERE localpart = ? AND device_id = ?`
|
||||
|
||||
insertDeviceSQL = `
|
||||
INSERT INTO user_devices
|
||||
(localpart, device_id, display_name, created_at_ms, last_seen_ms, last_seen_ip, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
touchDeviceSQL = `
|
||||
UPDATE user_devices SET last_seen_ms = ?, last_seen_ip = ?, user_agent = ?
|
||||
WHERE localpart = ? AND device_id = ?`
|
||||
|
||||
deleteDeviceTokensSQL = `DELETE FROM user_access_tokens WHERE localpart = ? AND device_id = ?`
|
||||
|
||||
deleteDeviceRefreshTokensSQL = `DELETE FROM user_refresh_tokens WHERE localpart = ? AND device_id = ?`
|
||||
|
||||
insertAccessTokenSQL = `
|
||||
INSERT INTO user_access_tokens (token_hash, localpart, device_id, created_at_ms, expires_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
|
||||
insertRefreshTokenSQL = `
|
||||
INSERT INTO user_refresh_tokens (token_hash, localpart, device_id, created_at_ms, expires_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
|
||||
accountExistsSQL = `SELECT 1 FROM user_accounts WHERE localpart = ?`
|
||||
)
|
||||
|
||||
// CreateSession implements storage.UserStore.
|
||||
//
|
||||
// Logging in on a device ID that already exists keeps the device — and its
|
||||
// display name, because initial_device_display_name only applies to a device
|
||||
// being created — but revokes the tokens it held, which is what the spec
|
||||
// requires.
|
||||
func (u *Users) CreateSession(ctx context.Context, s storage.NewSession) (storage.Device, error) {
|
||||
device := storage.Device{
|
||||
Localpart: s.Localpart,
|
||||
ID: s.DeviceID,
|
||||
DisplayName: s.DisplayName,
|
||||
CreatedAtMS: s.CreatedAtMS,
|
||||
LastSeenMS: s.CreatedAtMS,
|
||||
LastSeenIP: s.LastSeenIP,
|
||||
UserAgent: s.UserAgent,
|
||||
}
|
||||
|
||||
err := u.tx(ctx, func(tx *sql.Tx) error {
|
||||
var exists int
|
||||
switch err := tx.QueryRowContext(ctx, u.q(accountExistsSQL), s.Localpart).Scan(&exists); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return fmt.Errorf("create session for %q: %w", s.Localpart, storage.ErrNoSuchUser)
|
||||
case err != nil:
|
||||
return fmt.Errorf("create session for %q: %w", s.Localpart, err)
|
||||
}
|
||||
|
||||
var existingName string
|
||||
var existingCreated int64
|
||||
err := tx.QueryRowContext(ctx, u.q(selectDeviceForUpdateSQL), s.Localpart, s.DeviceID).
|
||||
Scan(&existingName, &existingCreated)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if _, err := tx.ExecContext(ctx, u.q(insertDeviceSQL),
|
||||
s.Localpart, s.DeviceID, s.DisplayName, s.CreatedAtMS,
|
||||
s.CreatedAtMS, s.LastSeenIP, s.UserAgent); err != nil {
|
||||
return fmt.Errorf("create device %q: %w", s.DeviceID, err)
|
||||
}
|
||||
case err != nil:
|
||||
return fmt.Errorf("read device %q: %w", s.DeviceID, err)
|
||||
default:
|
||||
device.DisplayName = existingName
|
||||
device.CreatedAtMS = existingCreated
|
||||
if _, err := tx.ExecContext(ctx, u.q(touchDeviceSQL),
|
||||
s.CreatedAtMS, s.LastSeenIP, s.UserAgent, s.Localpart, s.DeviceID); err != nil {
|
||||
return fmt.Errorf("update device %q: %w", s.DeviceID, err)
|
||||
}
|
||||
for _, query := range []string{deleteDeviceTokensSQL, deleteDeviceRefreshTokensSQL} {
|
||||
if _, err := tx.ExecContext(ctx, u.q(query), s.Localpart, s.DeviceID); err != nil {
|
||||
return fmt.Errorf("revoke tokens of device %q: %w", s.DeviceID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, u.q(insertAccessTokenSQL),
|
||||
s.AccessTokenHash, s.Localpart, s.DeviceID,
|
||||
s.CreatedAtMS, s.AccessTokenExpiresAtMS); err != nil {
|
||||
return fmt.Errorf("store access token: %w", err)
|
||||
}
|
||||
if s.RefreshTokenHash != "" {
|
||||
if _, err := tx.ExecContext(ctx, u.q(insertRefreshTokenSQL),
|
||||
s.RefreshTokenHash, s.Localpart, s.DeviceID,
|
||||
s.CreatedAtMS, s.RefreshTokenExpiresAtMS); err != nil {
|
||||
return fmt.Errorf("store refresh token: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return storage.Device{}, err
|
||||
}
|
||||
return device, nil
|
||||
}
|
||||
|
||||
const (
|
||||
selectRefreshTokenSQL = `
|
||||
SELECT localpart, device_id, expires_at_ms FROM user_refresh_tokens WHERE token_hash = ?`
|
||||
|
||||
deleteRefreshTokenSQL = `DELETE FROM user_refresh_tokens WHERE token_hash = ?`
|
||||
|
||||
selectAccountFlagsSQL = `SELECT admin, deactivated FROM user_accounts WHERE localpart = ?`
|
||||
)
|
||||
|
||||
// RefreshSession implements storage.UserStore.
|
||||
//
|
||||
// The spent refresh token and the access token it had minted both go away
|
||||
// immediately. The spec allows keeping the old access token alive for a grace
|
||||
// period; refusing it at once is the stricter reading and means a stolen
|
||||
// token stops working the moment the real client refreshes.
|
||||
func (u *Users) RefreshSession(ctx context.Context, r storage.RefreshedSession) (storage.TokenInfo, error) {
|
||||
var info storage.TokenInfo
|
||||
|
||||
err := u.tx(ctx, func(tx *sql.Tx) error {
|
||||
var expiresAt int64
|
||||
err := tx.QueryRowContext(ctx, u.q(selectRefreshTokenSQL), r.RefreshTokenHash).
|
||||
Scan(&info.Localpart, &info.DeviceID, &expiresAt)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return fmt.Errorf("refresh token: %w", storage.ErrNoSuchToken)
|
||||
case err != nil:
|
||||
return fmt.Errorf("refresh token: %w", err)
|
||||
}
|
||||
if expiresAt != 0 && expiresAt <= r.RefreshedAtMS {
|
||||
// Clean the dead token up on the way past; nothing else sweeps.
|
||||
if _, err := tx.ExecContext(ctx, u.q(deleteRefreshTokenSQL), r.RefreshTokenHash); err != nil {
|
||||
return fmt.Errorf("delete expired refresh token: %w", err)
|
||||
}
|
||||
return fmt.Errorf("refresh token has expired: %w", storage.ErrNoSuchToken)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, u.q(deleteRefreshTokenSQL), r.RefreshTokenHash); err != nil {
|
||||
return fmt.Errorf("spend refresh token: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, u.q(deleteDeviceTokensSQL), info.Localpart, info.DeviceID); err != nil {
|
||||
return fmt.Errorf("revoke previous access token: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, u.q(insertAccessTokenSQL),
|
||||
r.NewAccessTokenHash, info.Localpart, info.DeviceID,
|
||||
r.RefreshedAtMS, r.AccessTokenExpiresAtMS); err != nil {
|
||||
return fmt.Errorf("store access token: %w", err)
|
||||
}
|
||||
if r.NewRefreshTokenHash != "" {
|
||||
if _, err := tx.ExecContext(ctx, u.q(insertRefreshTokenSQL),
|
||||
r.NewRefreshTokenHash, info.Localpart, info.DeviceID,
|
||||
r.RefreshedAtMS, r.RefreshTokenExpiresAtMS); err != nil {
|
||||
return fmt.Errorf("store refresh token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.QueryRowContext(ctx, u.q(selectAccountFlagsSQL), info.Localpart).
|
||||
Scan(&info.Admin, &info.Deactivated); err != nil {
|
||||
return fmt.Errorf("read account %q: %w", info.Localpart, err)
|
||||
}
|
||||
info.ExpiresAtMS = r.AccessTokenExpiresAtMS
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return storage.TokenInfo{}, err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
const selectTokenInfoSQL = `
|
||||
SELECT t.localpart, t.device_id, t.expires_at_ms, a.admin, a.deactivated
|
||||
FROM user_access_tokens t
|
||||
JOIN user_accounts a ON a.localpart = t.localpart
|
||||
WHERE t.token_hash = ?`
|
||||
|
||||
// TokenInfo implements storage.UserStore.
|
||||
func (u *Users) TokenInfo(ctx context.Context, tokenHash string) (storage.TokenInfo, error) {
|
||||
var info storage.TokenInfo
|
||||
err := u.db.QueryRowContext(ctx, u.q(selectTokenInfoSQL), tokenHash).Scan(
|
||||
&info.Localpart, &info.DeviceID, &info.ExpiresAtMS, &info.Admin, &info.Deactivated)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return storage.TokenInfo{}, storage.ErrNoSuchToken
|
||||
case err != nil:
|
||||
return storage.TokenInfo{}, fmt.Errorf("look up access token: %w", err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// TouchDevice implements storage.UserStore. A device that has gone away
|
||||
// between authenticating and recording the visit is not an error: the
|
||||
// request is being answered either way.
|
||||
func (u *Users) TouchDevice(ctx context.Context, localpart, deviceID, ip, userAgent string, seenAtMS int64) error {
|
||||
if _, err := u.db.ExecContext(ctx, u.q(touchDeviceSQL),
|
||||
seenAtMS, ip, userAgent, localpart, deviceID); err != nil {
|
||||
return fmt.Errorf("touch device %q: %w", deviceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
selectDeviceSQL = `
|
||||
SELECT localpart, device_id, display_name, created_at_ms, last_seen_ms, last_seen_ip, user_agent
|
||||
FROM user_devices WHERE localpart = ? AND device_id = ?`
|
||||
|
||||
selectDevicesSQL = `
|
||||
SELECT localpart, device_id, display_name, created_at_ms, last_seen_ms, last_seen_ip, user_agent
|
||||
FROM user_devices WHERE localpart = ? ORDER BY device_id`
|
||||
|
||||
updateDeviceDisplayNameSQL = `
|
||||
UPDATE user_devices SET display_name = ? WHERE localpart = ? AND device_id = ?`
|
||||
)
|
||||
|
||||
// Device implements storage.UserStore.
|
||||
func (u *Users) Device(ctx context.Context, localpart, deviceID string) (storage.Device, error) {
|
||||
var d storage.Device
|
||||
err := u.db.QueryRowContext(ctx, u.q(selectDeviceSQL), localpart, deviceID).Scan(
|
||||
&d.Localpart, &d.ID, &d.DisplayName, &d.CreatedAtMS,
|
||||
&d.LastSeenMS, &d.LastSeenIP, &d.UserAgent)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return storage.Device{}, fmt.Errorf("device %q: %w", deviceID, storage.ErrNoSuchDevice)
|
||||
case err != nil:
|
||||
return storage.Device{}, fmt.Errorf("device %q: %w", deviceID, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Devices implements storage.UserStore.
|
||||
func (u *Users) Devices(ctx context.Context, localpart string) ([]storage.Device, error) {
|
||||
rows, err := u.db.QueryContext(ctx, u.q(selectDevicesSQL), localpart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list devices of %q: %w", localpart, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
devices := []storage.Device{}
|
||||
for rows.Next() {
|
||||
var d storage.Device
|
||||
if err := rows.Scan(&d.Localpart, &d.ID, &d.DisplayName, &d.CreatedAtMS,
|
||||
&d.LastSeenMS, &d.LastSeenIP, &d.UserAgent); err != nil {
|
||||
return nil, fmt.Errorf("scan device: %w", err)
|
||||
}
|
||||
devices = append(devices, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list devices of %q: %w", localpart, err)
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// SetDeviceDisplayName implements storage.UserStore.
|
||||
func (u *Users) SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error {
|
||||
res, err := u.db.ExecContext(ctx, u.q(updateDeviceDisplayNameSQL), displayName, localpart, deviceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename device %q: %w", deviceID, err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename device %q: %w", deviceID, err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("rename device %q: %w", deviceID, storage.ErrNoSuchDevice)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteDevices implements storage.UserStore.
|
||||
//
|
||||
// Tokens are deleted explicitly rather than left to ON DELETE CASCADE: SQLite
|
||||
// only enforces foreign keys when the connection asks it to, so relying on
|
||||
// the cascade would make revocation depend on a DSN pragma.
|
||||
func (u *Users) DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) (int, error) {
|
||||
if len(deviceIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
args := make([]any, 0, len(deviceIDs)+1)
|
||||
args = append(args, localpart)
|
||||
for _, id := range deviceIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
in := "(" + strings.TrimSuffix(strings.Repeat("?,", len(deviceIDs)), ",") + ")"
|
||||
|
||||
var deleted int
|
||||
err := u.tx(ctx, func(tx *sql.Tx) error {
|
||||
for _, table := range []string{"user_access_tokens", "user_refresh_tokens"} {
|
||||
query := "DELETE FROM " + table + " WHERE localpart = ? AND device_id IN " + in
|
||||
if _, err := tx.ExecContext(ctx, u.q(query), args...); err != nil {
|
||||
return fmt.Errorf("revoke tokens: %w", err)
|
||||
}
|
||||
}
|
||||
res, err := tx.ExecContext(ctx,
|
||||
u.q("DELETE FROM user_devices WHERE localpart = ? AND device_id IN "+in), args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete devices: %w", err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete devices: %w", err)
|
||||
}
|
||||
deleted = int(affected)
|
||||
return nil
|
||||
})
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
// DeleteAllDevices implements storage.UserStore.
|
||||
func (u *Users) DeleteAllDevices(ctx context.Context, localpart string) (int, error) {
|
||||
var deleted int
|
||||
err := u.tx(ctx, func(tx *sql.Tx) error {
|
||||
for _, table := range []string{"user_access_tokens", "user_refresh_tokens"} {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
u.q("DELETE FROM "+table+" WHERE localpart = ?"), localpart); err != nil {
|
||||
return fmt.Errorf("revoke tokens of %q: %w", localpart, err)
|
||||
}
|
||||
}
|
||||
res, err := tx.ExecContext(ctx,
|
||||
u.q("DELETE FROM user_devices WHERE localpart = ?"), localpart)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete devices of %q: %w", localpart, err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete devices of %q: %w", localpart, err)
|
||||
}
|
||||
deleted = int(affected)
|
||||
return nil
|
||||
})
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
const (
|
||||
selectAccountDataSQL = `
|
||||
SELECT content FROM user_account_data
|
||||
WHERE localpart = ? AND room_id = ? AND data_type = ?`
|
||||
|
||||
upsertAccountDataSQL = `
|
||||
INSERT INTO user_account_data (localpart, room_id, data_type, content)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (localpart, room_id, data_type)
|
||||
DO UPDATE SET content = excluded.content`
|
||||
)
|
||||
|
||||
// AccountData implements storage.UserStore.
|
||||
func (u *Users) AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error) {
|
||||
var content string
|
||||
err := u.db.QueryRowContext(ctx, u.q(selectAccountDataSQL), localpart, roomID, dataType).Scan(&content)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, fmt.Errorf("account data %q: %w", dataType, storage.ErrNoSuchAccountData)
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("account data %q: %w", dataType, err)
|
||||
}
|
||||
return []byte(content), nil
|
||||
}
|
||||
|
||||
// SetAccountData implements storage.UserStore. The content is stored as text
|
||||
// rather than a JSON column type: the two engines disagree on what JSON
|
||||
// normalisation does, and account data has to come back out byte for byte.
|
||||
func (u *Users) SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error {
|
||||
if _, err := u.db.ExecContext(ctx, u.q(upsertAccountDataSQL),
|
||||
localpart, roomID, dataType, string(content)); err != nil {
|
||||
return fmt.Errorf("set account data %q: %w", dataType, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time proof that the shared implementation satisfies the interface.
|
||||
var _ storage.UserStore = (*Users)(nil)
|
||||
@@ -61,6 +61,9 @@ type Database interface {
|
||||
// settings service (federation and registration switches).
|
||||
Settings() SettingsStore
|
||||
|
||||
// Users is the userapi store: accounts, devices, tokens, account data.
|
||||
Users() UserStore
|
||||
|
||||
// Close releases the connection pool.
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Errors the user store returns for the cases a caller has to tell apart.
|
||||
// Everything else comes back wrapped and becomes a 500.
|
||||
var (
|
||||
// ErrUserExists is returned by CreateAccount for a taken localpart.
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
|
||||
// ErrNoSuchUser is returned for an unknown localpart.
|
||||
ErrNoSuchUser = errors.New("no such user")
|
||||
|
||||
// ErrNoSuchDevice is returned for an unknown device of a known user.
|
||||
ErrNoSuchDevice = errors.New("no such device")
|
||||
|
||||
// ErrNoSuchToken is returned for an access or refresh token that is
|
||||
// unknown or has already been rotated away.
|
||||
ErrNoSuchToken = errors.New("no such token")
|
||||
|
||||
// ErrNoSuchAccountData is returned for an account data type the user
|
||||
// has never set.
|
||||
ErrNoSuchAccountData = errors.New("no such account data")
|
||||
)
|
||||
|
||||
// Account is a local user.
|
||||
//
|
||||
// Only the localpart is stored: the server name is fixed for the lifetime of
|
||||
// a deployment — it is baked into every ID — so repeating it on every row
|
||||
// would buy nothing. Virtual hosting, if it ever happens, is a migration.
|
||||
//
|
||||
// Timestamps throughout the user store are milliseconds since the Unix
|
||||
// epoch, the unit the Client-Server API uses on the wire. Nothing needs
|
||||
// converting on the way in or out, and both SQL drivers hold the same value.
|
||||
type Account struct {
|
||||
Localpart string
|
||||
DisplayName string
|
||||
AvatarURL string
|
||||
Admin bool
|
||||
Deactivated bool
|
||||
CreatedAtMS int64
|
||||
}
|
||||
|
||||
// NewAccount describes an account to create.
|
||||
type NewAccount struct {
|
||||
Localpart string
|
||||
|
||||
// PasswordHash is an encoded argon2id hash, or empty for an account
|
||||
// that cannot log in with a password.
|
||||
PasswordHash string
|
||||
|
||||
DisplayName string
|
||||
Admin bool
|
||||
CreatedAtMS int64
|
||||
}
|
||||
|
||||
// Device is one login session's device record.
|
||||
type Device struct {
|
||||
Localpart string
|
||||
ID string
|
||||
DisplayName string
|
||||
CreatedAtMS int64
|
||||
|
||||
LastSeenMS int64
|
||||
LastSeenIP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// NewSession is a device plus the tokens issued for it: one login or one
|
||||
// registration. The store writes all of it in a single transaction, so a
|
||||
// client never sees a device without a working token, or the reverse.
|
||||
//
|
||||
// Tokens are stored as hashes. They are bearer credentials that by default
|
||||
// never expire; hashing costs one SHA-256 per request and keeps a leaked
|
||||
// database dump from handing over live sessions.
|
||||
type NewSession struct {
|
||||
Localpart string
|
||||
DeviceID string
|
||||
DisplayName string
|
||||
CreatedAtMS int64
|
||||
|
||||
AccessTokenHash string
|
||||
|
||||
// AccessTokenExpiresAtMS is 0 for a token that never expires.
|
||||
AccessTokenExpiresAtMS int64
|
||||
|
||||
// RefreshTokenHash is empty when the client did not ask for one.
|
||||
RefreshTokenHash string
|
||||
RefreshTokenExpiresAtMS int64
|
||||
|
||||
LastSeenIP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// RefreshedSession rotates the token pair of an existing device.
|
||||
type RefreshedSession struct {
|
||||
// RefreshTokenHash identifies the token being spent.
|
||||
RefreshTokenHash string
|
||||
|
||||
NewAccessTokenHash string
|
||||
AccessTokenExpiresAtMS int64
|
||||
NewRefreshTokenHash string
|
||||
RefreshTokenExpiresAtMS int64
|
||||
RefreshedAtMS int64
|
||||
}
|
||||
|
||||
// TokenInfo is everything the auth middleware needs from an access token, in
|
||||
// one query: who owns it, on which device, and whether the account may still
|
||||
// be used.
|
||||
type TokenInfo struct {
|
||||
Localpart string
|
||||
DeviceID string
|
||||
Admin bool
|
||||
Deactivated bool
|
||||
|
||||
// ExpiresAtMS is 0 for a token that never expires.
|
||||
ExpiresAtMS int64
|
||||
}
|
||||
|
||||
// AccountFilter narrows and pages the admin user list.
|
||||
type AccountFilter struct {
|
||||
// Search matches a substring of the localpart or the display name,
|
||||
// case-insensitively. Empty matches everything.
|
||||
Search string
|
||||
|
||||
// Limit caps the returned rows; 0 means the store's own default.
|
||||
Limit int
|
||||
|
||||
// Offset skips rows, for paging.
|
||||
Offset int
|
||||
}
|
||||
|
||||
// Stats are the counts the admin dashboard shows.
|
||||
type Stats struct {
|
||||
Users int
|
||||
Admins int
|
||||
Deactivated int
|
||||
Devices int
|
||||
}
|
||||
|
||||
// UserStore persists accounts, devices, tokens and account data: the
|
||||
// storage half of the userapi component.
|
||||
//
|
||||
// Every method takes a localpart the caller has already normalised
|
||||
// (lower-cased and validated); the store does no interpretation of its own.
|
||||
type UserStore interface {
|
||||
// CreateAccount inserts an account, returning ErrUserExists when the
|
||||
// localpart is taken.
|
||||
CreateAccount(ctx context.Context, acc NewAccount) (Account, error)
|
||||
|
||||
// Account reads one account, returning ErrNoSuchUser if it is absent.
|
||||
Account(ctx context.Context, localpart string) (Account, error)
|
||||
|
||||
// PasswordHash returns the stored argon2id hash, which is empty for an
|
||||
// account without a password.
|
||||
PasswordHash(ctx context.Context, localpart string) (string, error)
|
||||
|
||||
// SetPasswordHash replaces the password hash.
|
||||
SetPasswordHash(ctx context.Context, localpart, hash string) error
|
||||
|
||||
// SetAdmin grants or revokes the admin flag.
|
||||
SetAdmin(ctx context.Context, localpart string, admin bool) error
|
||||
|
||||
// SetDisplayName updates the profile display name.
|
||||
SetDisplayName(ctx context.Context, localpart, displayName string) error
|
||||
|
||||
// SetAvatarURL updates the profile avatar.
|
||||
SetAvatarURL(ctx context.Context, localpart, avatarURL string) error
|
||||
|
||||
// ListAccounts returns a page of accounts ordered by localpart, plus
|
||||
// the total number of accounts matching the filter.
|
||||
ListAccounts(ctx context.Context, filter AccountFilter) ([]Account, int, error)
|
||||
|
||||
// Stats returns the aggregate counts for the admin dashboard.
|
||||
Stats(ctx context.Context) (Stats, error)
|
||||
|
||||
// CreateSession creates or replaces a device and issues its tokens.
|
||||
// Re-using an existing device ID revokes the tokens that device had,
|
||||
// which is what the spec requires of a login naming a device already
|
||||
// in use.
|
||||
CreateSession(ctx context.Context, s NewSession) (Device, error)
|
||||
|
||||
// RefreshSession spends a refresh token and issues a new pair,
|
||||
// returning the owner of the device. It returns ErrNoSuchToken if the
|
||||
// token is unknown or has already been spent.
|
||||
RefreshSession(ctx context.Context, r RefreshedSession) (TokenInfo, error)
|
||||
|
||||
// TokenInfo resolves an access token hash to its owner, returning
|
||||
// ErrNoSuchToken when there is no such token. An expired token is
|
||||
// returned with its expiry rather than rejected here, so the caller can
|
||||
// answer with the soft logout the spec asks for.
|
||||
TokenInfo(ctx context.Context, tokenHash string) (TokenInfo, error)
|
||||
|
||||
// TouchDevice records the last-seen timestamp, address and user agent
|
||||
// of a device. It runs on authenticated requests, so it has to stay a
|
||||
// single cheap statement.
|
||||
TouchDevice(ctx context.Context, localpart, deviceID, ip, userAgent string, seenAtMS int64) error
|
||||
|
||||
// Device reads one device, returning ErrNoSuchDevice if it is absent.
|
||||
Device(ctx context.Context, localpart, deviceID string) (Device, error)
|
||||
|
||||
// Devices lists a user's devices, ordered by device ID.
|
||||
Devices(ctx context.Context, localpart string) ([]Device, error)
|
||||
|
||||
// SetDeviceDisplayName renames a device, returning ErrNoSuchDevice if
|
||||
// it is absent.
|
||||
SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error
|
||||
|
||||
// DeleteDevices removes devices and every token issued to them,
|
||||
// returning how many existed. Unknown device IDs are ignored.
|
||||
DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) (int, error)
|
||||
|
||||
// DeleteAllDevices removes every device of a user and their tokens.
|
||||
DeleteAllDevices(ctx context.Context, localpart string) (int, error)
|
||||
|
||||
// AccountData reads one account data blob; roomID is empty for global
|
||||
// account data. It returns ErrNoSuchAccountData when unset.
|
||||
AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error)
|
||||
|
||||
// SetAccountData writes one account data blob, overwriting any previous
|
||||
// value.
|
||||
SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// migratedTestDB opens a driver, brings its schema up to date and empties
|
||||
// the userapi tables, which is what every user-store test needs before it
|
||||
// can touch one.
|
||||
func migratedTestDB(t *testing.T, driver string) storage.Database {
|
||||
t.Helper()
|
||||
db := openTestDB(t, driver)
|
||||
if err := db.Migrate(t.Context()); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
resetUserTables(t, driver)
|
||||
return db
|
||||
}
|
||||
|
||||
// resetUserTables empties the userapi tables so that each test starts from a
|
||||
// known state.
|
||||
//
|
||||
// SQLite gets a fresh file per test and needs nothing. The PostgreSQL tests
|
||||
// all share the one database the environment points at, so without this the
|
||||
// second test to create "alice" would collide with the first — and would do
|
||||
// so only in CI, where PostgreSQL is available.
|
||||
func resetUserTables(t *testing.T, driver string) {
|
||||
t.Helper()
|
||||
if driver != storage.DriverPostgres {
|
||||
return
|
||||
}
|
||||
|
||||
// The storage interface has no "delete an account", on purpose:
|
||||
// deactivation is Phase 9 and deletion may never exist. Clearing the
|
||||
// tables is a test concern, so the test does it directly.
|
||||
db, err := sql.Open("pgx", os.Getenv("WARP_TEST_POSTGRES_DSN"))
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres to reset the user tables: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
if _, err := db.ExecContext(t.Context(), `TRUNCATE
|
||||
user_account_data, user_refresh_tokens, user_access_tokens,
|
||||
user_devices, user_accounts`); err != nil {
|
||||
t.Fatalf("reset the user tables: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newAccount is the minimum CreateAccount needs.
|
||||
func newAccount(localpart string) storage.NewAccount {
|
||||
return storage.NewAccount{
|
||||
Localpart: localpart,
|
||||
PasswordHash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaA",
|
||||
CreatedAtMS: 1_700_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAccounts(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
users := migratedTestDB(t, driver).Users()
|
||||
|
||||
if _, err := users.Account(ctx, "nobody"); !errors.Is(err, storage.ErrNoSuchUser) {
|
||||
t.Errorf("Account of an unknown user = %v, want ErrNoSuchUser", err)
|
||||
}
|
||||
|
||||
acc, err := users.CreateAccount(ctx, newAccount("alice"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
if acc.Localpart != "alice" || acc.Admin || acc.Deactivated {
|
||||
t.Errorf("CreateAccount returned %+v", acc)
|
||||
}
|
||||
|
||||
// A taken localpart must be reported as such, not as a generic
|
||||
// database failure: registration turns it into M_USER_IN_USE.
|
||||
if _, err := users.CreateAccount(ctx, newAccount("alice")); !errors.Is(err, storage.ErrUserExists) {
|
||||
t.Errorf("duplicate CreateAccount = %v, want ErrUserExists", err)
|
||||
}
|
||||
|
||||
stored, err := users.Account(ctx, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("Account: %v", err)
|
||||
}
|
||||
if stored.CreatedAtMS != 1_700_000_000_000 {
|
||||
t.Errorf("created_at_ms = %d, want 1700000000000", stored.CreatedAtMS)
|
||||
}
|
||||
|
||||
hash, err := users.PasswordHash(ctx, "alice")
|
||||
if err != nil || hash == "" {
|
||||
t.Fatalf("PasswordHash = %q, %v", hash, err)
|
||||
}
|
||||
if err := users.SetPasswordHash(ctx, "alice", "newhash"); err != nil {
|
||||
t.Fatalf("SetPasswordHash: %v", err)
|
||||
}
|
||||
if hash, _ = users.PasswordHash(ctx, "alice"); hash != "newhash" {
|
||||
t.Errorf("PasswordHash after update = %q", hash)
|
||||
}
|
||||
|
||||
if err := users.SetAdmin(ctx, "alice", true); err != nil {
|
||||
t.Fatalf("SetAdmin: %v", err)
|
||||
}
|
||||
if err := users.SetDisplayName(ctx, "alice", "Alice"); err != nil {
|
||||
t.Fatalf("SetDisplayName: %v", err)
|
||||
}
|
||||
if err := users.SetAvatarURL(ctx, "alice", "mxc://warp.example/abc"); err != nil {
|
||||
t.Fatalf("SetAvatarURL: %v", err)
|
||||
}
|
||||
stored, err = users.Account(ctx, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("Account: %v", err)
|
||||
}
|
||||
if !stored.Admin {
|
||||
t.Error("the admin flag did not survive the round trip")
|
||||
}
|
||||
if stored.DisplayName != "Alice" || stored.AvatarURL != "mxc://warp.example/abc" {
|
||||
t.Errorf("profile = %+v", stored)
|
||||
}
|
||||
|
||||
// Updating an absent account is ErrNoSuchUser, so that callers
|
||||
// do not have to look it up first.
|
||||
if err := users.SetDisplayName(ctx, "nobody", "x"); !errors.Is(err, storage.ErrNoSuchUser) {
|
||||
t.Errorf("SetDisplayName on an unknown user = %v, want ErrNoSuchUser", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAccountListingAndStats(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
users := migratedTestDB(t, driver).Users()
|
||||
|
||||
for _, localpart := range []string{"amy", "bob", "bobby", "zoe_x"} {
|
||||
if _, err := users.CreateAccount(ctx, newAccount(localpart)); err != nil {
|
||||
t.Fatalf("CreateAccount(%s): %v", localpart, err)
|
||||
}
|
||||
}
|
||||
if err := users.SetAdmin(ctx, "amy", true); err != nil {
|
||||
t.Fatalf("SetAdmin: %v", err)
|
||||
}
|
||||
if err := users.SetDisplayName(ctx, "zoe_x", "Zoe"); err != nil {
|
||||
t.Fatalf("SetDisplayName: %v", err)
|
||||
}
|
||||
|
||||
all, total, err := users.ListAccounts(ctx, storage.AccountFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
if total != 4 || len(all) != 4 {
|
||||
t.Fatalf("ListAccounts returned %d of %d, want 4 of 4", len(all), total)
|
||||
}
|
||||
if all[0].Localpart != "amy" || all[3].Localpart != "zoe_x" {
|
||||
t.Errorf("accounts are not ordered by localpart: %v", all)
|
||||
}
|
||||
|
||||
page, total, err := users.ListAccounts(ctx, storage.AccountFilter{Limit: 2, Offset: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts paged: %v", err)
|
||||
}
|
||||
if total != 4 || len(page) != 2 || page[0].Localpart != "bob" {
|
||||
t.Errorf("paged listing = %v (total %d)", page, total)
|
||||
}
|
||||
|
||||
found, total, err := users.ListAccounts(ctx, storage.AccountFilter{Search: "BOB"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts search: %v", err)
|
||||
}
|
||||
if total != 2 || len(found) != 2 {
|
||||
t.Errorf("case-insensitive search for bob found %d, want 2", total)
|
||||
}
|
||||
|
||||
// A display name is searchable too.
|
||||
if _, total, err = users.ListAccounts(ctx, storage.AccountFilter{Search: "zoe"}); err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
} else if total != 1 {
|
||||
t.Errorf("searching display names found %d, want 1", total)
|
||||
}
|
||||
|
||||
// "_" is a LIKE wildcard; escaping it is what keeps this from
|
||||
// matching every three-letter localpart.
|
||||
if _, total, err = users.ListAccounts(ctx, storage.AccountFilter{Search: "e_x"}); err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
} else if total != 1 {
|
||||
t.Errorf("searching for a literal underscore found %d, want 1", total)
|
||||
}
|
||||
|
||||
stats, err := users.Stats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Stats: %v", err)
|
||||
}
|
||||
if stats.Users != 4 || stats.Admins != 1 || stats.Deactivated != 0 || stats.Devices != 0 {
|
||||
t.Errorf("Stats = %+v", stats)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserSessions(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
users := migratedTestDB(t, driver).Users()
|
||||
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
session := storage.NewSession{
|
||||
Localpart: "alice",
|
||||
DeviceID: "DEV1",
|
||||
DisplayName: "first login",
|
||||
CreatedAtMS: 1000,
|
||||
AccessTokenHash: "hash-a",
|
||||
LastSeenIP: "10.0.0.1",
|
||||
UserAgent: "warp-test",
|
||||
}
|
||||
device, err := users.CreateSession(ctx, session)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
if device.ID != "DEV1" || device.DisplayName != "first login" {
|
||||
t.Errorf("CreateSession returned %+v", device)
|
||||
}
|
||||
|
||||
// A session for an unknown account must fail loudly rather than
|
||||
// leave a device with no owner.
|
||||
orphan := session
|
||||
orphan.Localpart, orphan.AccessTokenHash = "nobody", "hash-orphan"
|
||||
if _, err := users.CreateSession(ctx, orphan); !errors.Is(err, storage.ErrNoSuchUser) {
|
||||
t.Errorf("CreateSession for an unknown user = %v, want ErrNoSuchUser", err)
|
||||
}
|
||||
|
||||
info, err := users.TokenInfo(ctx, "hash-a")
|
||||
if err != nil {
|
||||
t.Fatalf("TokenInfo: %v", err)
|
||||
}
|
||||
if info.Localpart != "alice" || info.DeviceID != "DEV1" || info.ExpiresAtMS != 0 {
|
||||
t.Errorf("TokenInfo = %+v", info)
|
||||
}
|
||||
if _, err := users.TokenInfo(ctx, "nope"); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Errorf("TokenInfo of an unknown token = %v, want ErrNoSuchToken", err)
|
||||
}
|
||||
|
||||
// Logging in again on the same device keeps the device and its
|
||||
// display name, and revokes the token it had.
|
||||
again := session
|
||||
again.AccessTokenHash = "hash-b"
|
||||
again.DisplayName = "ignored on an existing device"
|
||||
again.CreatedAtMS = 2000
|
||||
device, err = users.CreateSession(ctx, again)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession (same device): %v", err)
|
||||
}
|
||||
if device.DisplayName != "first login" {
|
||||
t.Errorf("display name = %q; initial_device_display_name applies to new devices only", device.DisplayName)
|
||||
}
|
||||
if device.CreatedAtMS != 1000 {
|
||||
t.Errorf("created_at_ms = %d; re-login must not reset it", device.CreatedAtMS)
|
||||
}
|
||||
if _, err := users.TokenInfo(ctx, "hash-a"); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Error("the previous access token of a re-used device was not revoked")
|
||||
}
|
||||
if _, err := users.TokenInfo(ctx, "hash-b"); err != nil {
|
||||
t.Errorf("the new access token does not work: %v", err)
|
||||
}
|
||||
|
||||
stored, err := users.Device(ctx, "alice", "DEV1")
|
||||
if err != nil {
|
||||
t.Fatalf("Device: %v", err)
|
||||
}
|
||||
if stored.LastSeenMS != 2000 || stored.LastSeenIP != "10.0.0.1" {
|
||||
t.Errorf("device = %+v", stored)
|
||||
}
|
||||
if _, err := users.Device(ctx, "alice", "GONE"); !errors.Is(err, storage.ErrNoSuchDevice) {
|
||||
t.Errorf("Device of an unknown device = %v, want ErrNoSuchDevice", err)
|
||||
}
|
||||
|
||||
if err := users.TouchDevice(ctx, "alice", "DEV1", "10.0.0.2", "warp-test/2", 3000); err != nil {
|
||||
t.Fatalf("TouchDevice: %v", err)
|
||||
}
|
||||
if stored, err = users.Device(ctx, "alice", "DEV1"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if stored.LastSeenMS != 3000 || stored.LastSeenIP != "10.0.0.2" {
|
||||
t.Errorf("TouchDevice did not record the visit: %+v", stored)
|
||||
}
|
||||
|
||||
if err := users.SetDeviceDisplayName(ctx, "alice", "DEV1", "renamed"); err != nil {
|
||||
t.Fatalf("SetDeviceDisplayName: %v", err)
|
||||
}
|
||||
if err := users.SetDeviceDisplayName(ctx, "alice", "GONE", "x"); !errors.Is(err, storage.ErrNoSuchDevice) {
|
||||
t.Errorf("renaming an unknown device = %v, want ErrNoSuchDevice", err)
|
||||
}
|
||||
|
||||
second := storage.NewSession{
|
||||
Localpart: "alice",
|
||||
DeviceID: "DEV2",
|
||||
CreatedAtMS: 4000,
|
||||
AccessTokenHash: "hash-c",
|
||||
}
|
||||
if _, err := users.CreateSession(ctx, second); err != nil {
|
||||
t.Fatalf("CreateSession (second device): %v", err)
|
||||
}
|
||||
devices, err := users.Devices(ctx, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("Devices: %v", err)
|
||||
}
|
||||
if len(devices) != 2 || devices[0].ID != "DEV1" || devices[1].ID != "DEV2" {
|
||||
t.Errorf("Devices = %v", devices)
|
||||
}
|
||||
|
||||
deleted, err := users.DeleteDevices(ctx, "alice", []string{"DEV1", "NEVER-EXISTED"})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteDevices: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Errorf("DeleteDevices reported %d, want 1", deleted)
|
||||
}
|
||||
if _, err := users.TokenInfo(ctx, "hash-b"); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Error("deleting a device must revoke its access tokens")
|
||||
}
|
||||
|
||||
deleted, err = users.DeleteAllDevices(ctx, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteAllDevices: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Errorf("DeleteAllDevices reported %d, want 1", deleted)
|
||||
}
|
||||
if _, err := users.TokenInfo(ctx, "hash-c"); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Error("logout/all must revoke every token")
|
||||
}
|
||||
if devices, err = users.Devices(ctx, "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(devices) != 0 {
|
||||
t.Errorf("devices survived DeleteAllDevices: %v", devices)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRefreshTokens(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
users := migratedTestDB(t, driver).Users()
|
||||
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
if _, err := users.CreateSession(ctx, storage.NewSession{
|
||||
Localpart: "alice",
|
||||
DeviceID: "DEV1",
|
||||
CreatedAtMS: 1000,
|
||||
AccessTokenHash: "access-1",
|
||||
AccessTokenExpiresAtMS: 5000,
|
||||
RefreshTokenHash: "refresh-1",
|
||||
RefreshTokenExpiresAtMS: 0,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
info, err := users.RefreshSession(ctx, storage.RefreshedSession{
|
||||
RefreshTokenHash: "refresh-1",
|
||||
NewAccessTokenHash: "access-2",
|
||||
AccessTokenExpiresAtMS: 9000,
|
||||
NewRefreshTokenHash: "refresh-2",
|
||||
RefreshedAtMS: 4000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshSession: %v", err)
|
||||
}
|
||||
if info.Localpart != "alice" || info.DeviceID != "DEV1" {
|
||||
t.Errorf("RefreshSession returned %+v", info)
|
||||
}
|
||||
|
||||
// Both halves of the old pair are gone: a replayed refresh token
|
||||
// is the signal that one of the two copies is a thief.
|
||||
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
|
||||
RefreshTokenHash: "refresh-1",
|
||||
NewAccessTokenHash: "access-3",
|
||||
NewRefreshTokenHash: "refresh-3",
|
||||
RefreshedAtMS: 4100,
|
||||
}); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Errorf("replaying a spent refresh token = %v, want ErrNoSuchToken", err)
|
||||
}
|
||||
if _, err := users.TokenInfo(ctx, "access-1"); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Error("refreshing must invalidate the previous access token")
|
||||
}
|
||||
if info, err = users.TokenInfo(ctx, "access-2"); err != nil {
|
||||
t.Fatalf("the refreshed access token does not work: %v", err)
|
||||
} else if info.ExpiresAtMS != 9000 {
|
||||
t.Errorf("expires_at_ms = %d, want 9000", info.ExpiresAtMS)
|
||||
}
|
||||
|
||||
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
|
||||
RefreshTokenHash: "nonexistent",
|
||||
NewAccessTokenHash: "access-4",
|
||||
RefreshedAtMS: 4200,
|
||||
}); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Errorf("unknown refresh token = %v, want ErrNoSuchToken", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRefreshTokenExpiry(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
users := migratedTestDB(t, driver).Users()
|
||||
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
if _, err := users.CreateSession(ctx, storage.NewSession{
|
||||
Localpart: "alice",
|
||||
DeviceID: "DEV1",
|
||||
CreatedAtMS: 1000,
|
||||
AccessTokenHash: "access-1",
|
||||
RefreshTokenHash: "refresh-1",
|
||||
RefreshTokenExpiresAtMS: 2000,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
|
||||
RefreshTokenHash: "refresh-1",
|
||||
NewAccessTokenHash: "access-2",
|
||||
RefreshedAtMS: 2001,
|
||||
}); !errors.Is(err, storage.ErrNoSuchToken) {
|
||||
t.Errorf("expired refresh token = %v, want ErrNoSuchToken", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAccountData(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
users := migratedTestDB(t, driver).Users()
|
||||
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
if _, err := users.AccountData(ctx, "alice", "", "m.push_rules"); !errors.Is(err, storage.ErrNoSuchAccountData) {
|
||||
t.Errorf("unset account data = %v, want ErrNoSuchAccountData", err)
|
||||
}
|
||||
|
||||
global := []byte(`{"theme":"dark"}`)
|
||||
if err := users.SetAccountData(ctx, "alice", "", "im.warp.prefs", global); err != nil {
|
||||
t.Fatalf("SetAccountData: %v", err)
|
||||
}
|
||||
got, err := users.AccountData(ctx, "alice", "", "im.warp.prefs")
|
||||
if err != nil {
|
||||
t.Fatalf("AccountData: %v", err)
|
||||
}
|
||||
if string(got) != string(global) {
|
||||
t.Errorf("account data came back as %s, want %s", got, global)
|
||||
}
|
||||
|
||||
// Overwriting replaces rather than conflicting.
|
||||
if err := users.SetAccountData(ctx, "alice", "", "im.warp.prefs", []byte(`{"theme":"light"}`)); err != nil {
|
||||
t.Fatalf("SetAccountData (overwrite): %v", err)
|
||||
}
|
||||
if got, err = users.AccountData(ctx, "alice", "", "im.warp.prefs"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if string(got) != `{"theme":"light"}` {
|
||||
t.Errorf("overwritten account data = %s", got)
|
||||
}
|
||||
|
||||
// Room account data is a separate key space from global data of
|
||||
// the same type.
|
||||
if err := users.SetAccountData(ctx, "alice", "!room:warp.example", "im.warp.prefs", []byte(`{"muted":true}`)); err != nil {
|
||||
t.Fatalf("SetAccountData (room): %v", err)
|
||||
}
|
||||
if got, err = users.AccountData(ctx, "alice", "!room:warp.example", "im.warp.prefs"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if string(got) != `{"muted":true}` {
|
||||
t.Errorf("room account data = %s", got)
|
||||
}
|
||||
if got, err = users.AccountData(ctx, "alice", "", "im.warp.prefs"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if string(got) != `{"theme":"light"}` {
|
||||
t.Errorf("room account data overwrote the global value: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package userapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Argon2id parameters. These are OWASP's recommended settings for argon2id
|
||||
// (19 MiB, two passes, one lane): strong enough to make a stolen hash
|
||||
// expensive to attack, cheap enough that a login does not become a denial of
|
||||
// service against the server itself.
|
||||
//
|
||||
// The values are recorded in every hash, so raising them later is a matter
|
||||
// of changing these constants; old hashes keep verifying with the parameters
|
||||
// they were made with, and are rewritten the next time the password changes.
|
||||
const (
|
||||
argonMemoryKiB = 19 * 1024
|
||||
argonTime = 2
|
||||
argonLanes = 1
|
||||
argonSaltLen = 16
|
||||
argonKeyLen = 32
|
||||
)
|
||||
|
||||
// MaxPasswordLength caps a password at 512 bytes. Argon2id does not care how
|
||||
// long its input is, but accepting unbounded input means accepting unbounded
|
||||
// work in the hash function.
|
||||
const MaxPasswordLength = 512
|
||||
|
||||
// ErrPasswordTooLong is returned when a password exceeds MaxPasswordLength.
|
||||
var ErrPasswordTooLong = fmt.Errorf("password must be at most %d bytes", MaxPasswordLength)
|
||||
|
||||
// ErrInvalidPasswordHash is returned when a stored hash cannot be parsed. It
|
||||
// means the row is corrupt, not that the password was wrong.
|
||||
var ErrInvalidPasswordHash = errors.New("stored password hash is malformed")
|
||||
|
||||
// HashPassword returns an encoded argon2id hash in the PHC string format:
|
||||
//
|
||||
// $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
|
||||
//
|
||||
// The format is the de facto standard for password hashes, so the parameters
|
||||
// travel with the hash and an operator can tell at a glance what a row was
|
||||
// written with.
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(password) > MaxPasswordLength {
|
||||
return "", ErrPasswordTooLong
|
||||
}
|
||||
salt := make([]byte, argonSaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("generate password salt: %w", err)
|
||||
}
|
||||
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemoryKiB, argonLanes, argonKeyLen)
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemoryKiB, argonTime, argonLanes,
|
||||
b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
||||
}
|
||||
|
||||
// VerifyPassword reports whether password matches encoded. An unparseable
|
||||
// hash is an error rather than a mismatch, so a corrupt row shows up in the
|
||||
// logs instead of looking like a wrong password forever.
|
||||
func VerifyPassword(encoded, password string) (bool, error) {
|
||||
if len(password) > MaxPasswordLength {
|
||||
return false, ErrPasswordTooLong
|
||||
}
|
||||
params, salt, want, err := decodeHash(encoded)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, params.time, params.memory, params.lanes, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||
}
|
||||
|
||||
// b64 is the unpadded base64 the PHC format uses.
|
||||
var b64 = base64.RawStdEncoding
|
||||
|
||||
type argonParams struct {
|
||||
memory uint32
|
||||
time uint32
|
||||
lanes uint8
|
||||
}
|
||||
|
||||
// decodeHash parses a PHC argon2id string.
|
||||
func decodeHash(encoded string) (argonParams, []byte, []byte, error) {
|
||||
// "", "argon2id", "v=19", "m=...,t=...,p=...", salt, hash
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[0] != "" {
|
||||
return argonParams{}, nil, nil, ErrInvalidPasswordHash
|
||||
}
|
||||
if parts[1] != "argon2id" {
|
||||
return argonParams{}, nil, nil, fmt.Errorf("%w: unsupported algorithm %q", ErrInvalidPasswordHash, parts[1])
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return argonParams{}, nil, nil, ErrInvalidPasswordHash
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return argonParams{}, nil, nil, fmt.Errorf("%w: unsupported argon2 version %d", ErrInvalidPasswordHash, version)
|
||||
}
|
||||
|
||||
var p argonParams
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.memory, &p.time, &p.lanes); err != nil {
|
||||
return argonParams{}, nil, nil, ErrInvalidPasswordHash
|
||||
}
|
||||
if p.memory == 0 || p.time == 0 || p.lanes == 0 {
|
||||
return argonParams{}, nil, nil, ErrInvalidPasswordHash
|
||||
}
|
||||
|
||||
salt, err := b64.DecodeString(parts[4])
|
||||
if err != nil || len(salt) == 0 {
|
||||
return argonParams{}, nil, nil, ErrInvalidPasswordHash
|
||||
}
|
||||
key, err := b64.DecodeString(parts[5])
|
||||
if err != nil || len(key) == 0 {
|
||||
return argonParams{}, nil, nil, ErrInvalidPasswordHash
|
||||
}
|
||||
return p, salt, key, nil
|
||||
}
|
||||
|
||||
// dummyPasswordHash is verified against when the named account does not
|
||||
// exist or has no password, so that a login attempt costs the same either
|
||||
// way. Without it, the time a rejection takes would tell an attacker which
|
||||
// user names are real.
|
||||
var dummyPasswordHash = func() string {
|
||||
hash, err := HashPassword("warp: this password hashes nothing")
|
||||
if err != nil {
|
||||
// HashPassword only fails on a short read from crypto/rand, which
|
||||
// at init time means the process cannot work at all.
|
||||
panic("userapi: cannot build the dummy password hash: " + err.Error())
|
||||
}
|
||||
return hash
|
||||
}()
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
Executable
+50
@@ -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
|
||||
Executable
+54
@@ -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/...
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user