diff --git a/client/auth.go b/client/auth.go index 8935ecb2..13c629a8 100644 --- a/client/auth.go +++ b/client/auth.go @@ -99,22 +99,39 @@ func (c *CSAPI) ConsumeRefreshToken(t ct.TestLike, refreshToken string) (newAcce } // RegisterUser will register the user with given parameters and -// return user ID, access token and device ID. It fails the test on network error. +// return user ID, access token and device ID. It fails the test on network error, +// or if registration fails for another reason (e.g. server has non-dummy requirements). func (c *CSAPI) RegisterUser(t ct.TestLike, localpart, password string) (userID, accessToken, deviceID string) { t.Helper() - reqBody := map[string]interface{}{ - "auth": map[string]string{ - "type": "m.login.dummy", - }, + reqBody := map[string]any{ "username": localpart, "password": password, } - res := c.MustDo(t, "POST", []string{"_matrix", "client", "v3", "register"}, WithJSONBody(t, reqBody)) + // First request is expected to receive a UIA challenge + res := c.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, WithJSONBody(t, reqBody)) + if res.StatusCode != 401 { + ct.Fatalf(t, "Expected 401 Unauthorized, got %d", res.StatusCode) + } body, err := io.ReadAll(res.Body) if err != nil { ct.Fatalf(t, "unable to read response body: %v", err) } + session := GetJSONFieldStr(t, body, "session") + + // Now actually register the user + reqBody["auth"] = map[string]any{ + "session": session, + "type": "m.login.dummy", + } + if session == "" { + delete(reqBody["auth"].(map[string]any), "session") + } + res = c.MustDo(t, "POST", []string{"_matrix", "client", "v3", "register"}, WithJSONBody(t, reqBody)) + body, err = io.ReadAll(res.Body) + if err != nil { + ct.Fatalf(t, "unable to read response body: %v", err) + } userID = GetJSONFieldStr(t, body, "user_id") accessToken = GetJSONFieldStr(t, body, "access_token") diff --git a/tests/csapi/apidoc_register_test.go b/tests/csapi/apidoc_register_test.go index b876abea..ca5c0a49 100644 --- a/tests/csapi/apidoc_register_test.go +++ b/tests/csapi/apidoc_register_test.go @@ -6,7 +6,9 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "io/ioutil" + "maps" "net/http" "net/url" "testing" @@ -63,17 +65,11 @@ func TestRegistration(t *testing.T) { }) // sytest: POST /register can create a user t.Run("POST /register can create a user", func(t *testing.T) { - // Venator: https://github.com/matrix-org/complement/issues/893 - runtime.SkipIf(t, runtime.Venator) t.Parallel() - res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithRawBody(json.RawMessage(`{ - "auth": { - "type": "m.login.dummy" - }, - "username": "post-can-create-a-user", - "password": "sUp3rs3kr1t" - }`))) + reqBody, _ := startUIASession(t, unauthedClient, "post-can-create-a-user", "sUp3rs3kr1t", nil) + res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) must.MatchResponse(t, res, match.HTTPResponse{ + StatusCode: 200, JSON: []match.JSON{ match.JSONKeyTypeEqual("access_token", gjson.String), match.JSONKeyTypeEqual("user_id", gjson.String), @@ -82,17 +78,11 @@ func TestRegistration(t *testing.T) { }) // sytest: POST /register downcases capitals in usernames t.Run("POST /register downcases capitals in usernames", func(t *testing.T) { - // Venator: https://github.com/matrix-org/complement/issues/893 - runtime.SkipIf(t, runtime.Venator) t.Parallel() - res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithRawBody(json.RawMessage(`{ - "auth": { - "type": "m.login.dummy" - }, - "username": "user-UPPER", - "password": "sUp3rs3kr1t" - }`))) + reqBody, _ := startUIASession(t, unauthedClient, "user-UPPER", "sUp3rs3kr1t", nil) + res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) must.MatchResponse(t, res, match.HTTPResponse{ + StatusCode: 200, JSON: []match.JSON{ match.JSONKeyTypeEqual("access_token", gjson.String), match.JSONKeyEqual("user_id", "@user-upper:hs1"), @@ -101,19 +91,12 @@ func TestRegistration(t *testing.T) { }) // sytest: POST /register returns the same device_id as that in the request t.Run("POST /register returns the same device_id as that in the request", func(t *testing.T) { - // Venator: https://github.com/matrix-org/complement/issues/893 - runtime.SkipIf(t, runtime.Venator) t.Parallel() deviceID := "my_device_id" - res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithRawBody(json.RawMessage(`{ - "auth": { - "type": "m.login.dummy" - }, - "username": "user-device", - "password": "sUp3rs3kr1t", - "device_id": "`+deviceID+`" - }`))) + reqBody, _ := startUIASession(t, unauthedClient, "user-device", deviceID, map[string]any{"device_id": deviceID}) + res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) must.MatchResponse(t, res, match.HTTPResponse{ + StatusCode: 200, JSON: []match.JSON{ match.JSONKeyTypeEqual("access_token", gjson.String), match.JSONKeyEqual("device_id", deviceID), @@ -122,8 +105,6 @@ func TestRegistration(t *testing.T) { }) // sytest: POST /register rejects registration of usernames with '$q' t.Run("POST /register rejects usernames with special characters", func(t *testing.T) { - // Venator: https://github.com/matrix-org/complement/issues/893 - runtime.SkipIf(t, runtime.Venator) t.Parallel() specialChars := []string{ `!`, @@ -143,14 +124,13 @@ func TestRegistration(t *testing.T) { `'`, } for _, ch := range specialChars { - res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, - client.WithJSONBody(t, map[string]interface{}{ - "auth": map[string]string{ - "type": "m.login.dummy", - }, - "username": "user-" + ch + "-reject-please", - "password": "sUp3rs3kr1t", - })) + reqBody := map[string]any{ + "username": "user-" + ch + "-reject-please", + "password": "sUp3rs3kr1t", + } + res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) + // N.B. servers are expected to validate request bodies before handling UIA, + // so 400 is expected here, not 401. must.MatchResponse(t, res, match.HTTPResponse{ StatusCode: 400, JSON: []match.JSON{ @@ -160,37 +140,30 @@ func TestRegistration(t *testing.T) { } }) t.Run("POST /register rejects if user already exists", func(t *testing.T) { - // Venator: https://github.com/matrix-org/complement/issues/893 - runtime.SkipIf(t, runtime.Venator) + // Dendrite: auth is validated before input, meaning the second register request needs to start a fresh + // auth session. This conflicts with Synapse, which forbids a second session being started, as it + // validates the input before auth. Skip on Dendrite for now. + runtime.SkipIf(t, runtime.Dendrite) t.Parallel() - res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithRawBody(json.RawMessage(`{ - "auth": { - "type": "m.login.dummy" - }, - "username": "post-can-create-a-user-once", - "password": "sUp3rs3kr1t" - }`))) + reqBody, _ := startUIASession(t, unauthedClient, "post-can-create-a-user-once", "sUp3rs3kr1t", nil) + res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) must.MatchResponse(t, res, match.HTTPResponse{ JSON: []match.JSON{ match.JSONKeyTypeEqual("access_token", gjson.String), match.JSONKeyTypeEqual("user_id", gjson.String), }, }) - res = unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithRawBody(json.RawMessage(`{ - "auth": { - "type": "m.login.dummy" - }, - "username": "post-can-create-a-user-once", - "password": "anotherSuperSecret" - }`))) + delete(reqBody, "auth") + res = unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) must.MatchResponse(t, res, match.HTTPResponse{ StatusCode: 400, + JSON: []match.JSON{ + match.JSONKeyEqual("errcode", "M_USER_IN_USE"), + }, }) }) // sytest: POST /register allows registration of usernames with '$chr' t.Run("POST /register allows registration of usernames with ", func(t *testing.T) { - // Venator: https://github.com/matrix-org/complement/issues/893 - runtime.SkipIf(t, runtime.Venator) testChars := []rune("q3._=-/") for x := range testChars { localpart := fmt.Sprintf("chrtestuser%s", string(testChars[x])) @@ -321,6 +294,25 @@ func TestRegistration(t *testing.T) { }, }) }) + // Test that subsequent calls to /_matrix/client/v3/register after receiving a UIA + // challenge fail if the session is not provided. + t.Run("Registration without a session fails", func(t *testing.T) { + // Many implementations historically did not enforce this requirement strictly + runtime.SkipIf(t, runtime.Synapse, runtime.Dendrite, runtime.Conduit) + t.Parallel() + reqBody, session := startUIASession(t, unauthedClient, "auth-requires-session", "sUp3rs3kr1t", nil) + if session == "" { + t.Skip("Homeserver does not require a session for UIA") + } + delete(reqBody["auth"].(map[string]any), "session") + // Re-send the same request without the session. + // Since session is required if it is provided by the homeserver, this should + // return an error + res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) + must.MatchResponse(t, res, match.HTTPResponse{ + StatusCode: 401, + }) + }) }) } @@ -359,3 +351,29 @@ func registerSharedSecret(t *testing.T, c *client.CSAPI, user, pass string, isAd resp = c.Do(t, "POST", []string{"_synapse", "admin", "v1", "register"}, client.WithJSONBody(t, reqBody)) return resp } + +// startUIASession starts a UIA session and returns the updated request body, +// and associated session token, failing the test if the response is not a UIA challenge. +func startUIASession(t *testing.T, c *client.CSAPI, user, pass string, extra map[string]any) (map[string]any, string) { + reqBody := map[string]any{ + "username": user, + "password": pass, + } + if extra != nil { + maps.Copy(reqBody, extra) + } + res := c.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) + if res.StatusCode != 401 { + t.Fatalf("expected status code 401 (UIA challenge), got %d", res.StatusCode) + } + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + session := client.GetJSONFieldStr(t, body, "session") + reqBody["auth"] = map[string]any{"session": session, "type": "m.login.dummy"} + if session == "" { + delete(reqBody["auth"].(map[string]any), "session") + } + return reqBody, session +} diff --git a/tests/csapi/power_levels_test.go b/tests/csapi/power_levels_test.go index a28e6b81..030631b3 100644 --- a/tests/csapi/power_levels_test.go +++ b/tests/csapi/power_levels_test.go @@ -129,7 +129,7 @@ func TestPowerLevels(t *testing.T) { func(body gjson.Result) error { // This key should be missing for room v12+ if gomatrixserverlib.MustGetRoomVersion(defaultRoomVersion).PrivilegedCreators() { - must.MatchGJSON(t, body, match.JSONKeyMissing("users." + client.GjsonEscape(alice.UserID))) + must.MatchGJSON(t, body, match.JSONKeyMissing("users."+client.GjsonEscape(alice.UserID))) return nil } else { userDefault := int(body.Get("users_default").Num)