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 965c0857..874f96c1 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,14 +65,10 @@ func TestRegistration(t *testing.T) { // sytest: POST /register can create a user t.Run("POST /register can create a user", func(t *testing.T) { 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), @@ -80,14 +78,10 @@ func TestRegistration(t *testing.T) { // sytest: POST /register downcases capitals in usernames t.Run("POST /register downcases capitals in usernames", func(t *testing.T) { 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"), @@ -98,15 +92,10 @@ func TestRegistration(t *testing.T) { t.Run("POST /register returns the same device_id as that in the request", func(t *testing.T) { 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), @@ -134,14 +123,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{ @@ -152,28 +140,22 @@ func TestRegistration(t *testing.T) { }) t.Run("POST /register rejects if user already exists", func(t *testing.T) { 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" - }`))) + // Since servers validate the request body before handling auth, this returns 400 + 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' @@ -308,6 +290,23 @@ 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) { + 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, + }) + }) }) } @@ -346,3 +345,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 +}