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