diff options
Diffstat (limited to 'clientapi/routing')
-rw-r--r-- | clientapi/routing/account_data.go | 76 | ||||
-rw-r--r-- | clientapi/routing/createroom.go | 337 | ||||
-rw-r--r-- | clientapi/routing/device.go | 155 | ||||
-rw-r--r-- | clientapi/routing/directory.go | 183 | ||||
-rw-r--r-- | clientapi/routing/filter.go | 123 | ||||
-rw-r--r-- | clientapi/routing/joinroom.go | 333 | ||||
-rw-r--r-- | clientapi/routing/login.go | 152 | ||||
-rw-r--r-- | clientapi/routing/logout.go | 71 | ||||
-rw-r--r-- | clientapi/routing/membership.go | 217 | ||||
-rw-r--r-- | clientapi/routing/memberships.go | 60 | ||||
-rw-r--r-- | clientapi/routing/profile.go | 292 | ||||
-rw-r--r-- | clientapi/routing/register.go | 958 | ||||
-rw-r--r-- | clientapi/routing/register_test.go | 209 | ||||
-rw-r--r-- | clientapi/routing/routing.go | 413 | ||||
-rw-r--r-- | clientapi/routing/sendevent.go | 153 | ||||
-rw-r--r-- | clientapi/routing/sendtyping.go | 80 | ||||
-rw-r--r-- | clientapi/routing/threepid.go | 178 | ||||
-rw-r--r-- | clientapi/routing/voip.go | 78 | ||||
-rw-r--r-- | clientapi/routing/whoami.go | 34 |
19 files changed, 4102 insertions, 0 deletions
diff --git a/clientapi/routing/account_data.go b/clientapi/routing/account_data.go new file mode 100644 index 00000000..30e00f72 --- /dev/null +++ b/clientapi/routing/account_data.go @@ -0,0 +1,76 @@ +// Copyright 2017 Vector Creations Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "io/ioutil" + "net/http" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/clientapi/producers" + "github.com/matrix-org/gomatrixserverlib" + + "github.com/matrix-org/util" +) + +// SaveAccountData implements PUT /user/{userId}/[rooms/{roomId}/]account_data/{type} +func SaveAccountData( + req *http.Request, accountDB *accounts.Database, device *authtypes.Device, + userID string, roomID string, dataType string, syncProducer *producers.SyncAPIProducer, +) util.JSONResponse { + if req.Method != http.MethodPut { + return util.JSONResponse{ + Code: http.StatusMethodNotAllowed, + JSON: jsonerror.NotFound("Bad method"), + } + } + + if userID != device.UserID { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("userID does not match the current user"), + } + } + + localpart, _, err := gomatrixserverlib.SplitID('@', userID) + if err != nil { + return httputil.LogThenError(req, err) + } + + defer req.Body.Close() // nolint: errcheck + + body, err := ioutil.ReadAll(req.Body) + if err != nil { + return httputil.LogThenError(req, err) + } + + if err := accountDB.SaveAccountData( + req.Context(), localpart, roomID, dataType, string(body), + ); err != nil { + return httputil.LogThenError(req, err) + } + + if err := syncProducer.SendData(userID, roomID, dataType); err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} diff --git a/clientapi/routing/createroom.go b/clientapi/routing/createroom.go new file mode 100644 index 00000000..a7187c49 --- /dev/null +++ b/clientapi/routing/createroom.go @@ -0,0 +1,337 @@ +// Copyright 2017 Vector Creations Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "fmt" + "net/http" + "strings" + "time" + + appserviceAPI "github.com/matrix-org/dendrite/appservice/api" + roomserverAPI "github.com/matrix-org/dendrite/roomserver/api" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/clientapi/producers" + "github.com/matrix-org/dendrite/common" + "github.com/matrix-org/dendrite/common/config" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" + log "github.com/sirupsen/logrus" +) + +// https://matrix.org/docs/spec/client_server/r0.2.0.html#post-matrix-client-r0-createroom +type createRoomRequest struct { + Invite []string `json:"invite"` + Name string `json:"name"` + Visibility string `json:"visibility"` + Topic string `json:"topic"` + Preset string `json:"preset"` + CreationContent map[string]interface{} `json:"creation_content"` + InitialState []fledglingEvent `json:"initial_state"` + RoomAliasName string `json:"room_alias_name"` + GuestCanJoin bool `json:"guest_can_join"` +} + +const ( + presetPrivateChat = "private_chat" + presetTrustedPrivateChat = "trusted_private_chat" + presetPublicChat = "public_chat" +) + +const ( + joinRulePublic = "public" + joinRuleInvite = "invite" +) +const ( + historyVisibilityShared = "shared" + // TODO: These should be implemented once history visibility is implemented + // historyVisibilityWorldReadable = "world_readable" + // historyVisibilityInvited = "invited" +) + +func (r createRoomRequest) Validate() *util.JSONResponse { + whitespace := "\t\n\x0b\x0c\r " // https://docs.python.org/2/library/string.html#string.whitespace + // https://github.com/matrix-org/synapse/blob/v0.19.2/synapse/handlers/room.py#L81 + // Synapse doesn't check for ':' but we will else it will break parsers badly which split things into 2 segments. + if strings.ContainsAny(r.RoomAliasName, whitespace+":") { + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("room_alias_name cannot contain whitespace or ':'"), + } + } + for _, userID := range r.Invite { + // TODO: We should put user ID parsing code into gomatrixserverlib and use that instead + // (see https://github.com/matrix-org/gomatrixserverlib/blob/3394e7c7003312043208aa73727d2256eea3d1f6/eventcontent.go#L347 ) + // It should be a struct (with pointers into a single string to avoid copying) and + // we should update all refs to use UserID types rather than strings. + // https://github.com/matrix-org/synapse/blob/v0.19.2/synapse/types.py#L92 + if _, _, err := gomatrixserverlib.SplitID('@', userID); err != nil { + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("user id must be in the form @localpart:domain"), + } + } + } + switch r.Preset { + case presetPrivateChat, presetTrustedPrivateChat, presetPublicChat, "": + default: + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("preset must be any of 'private_chat', 'trusted_private_chat', 'public_chat'"), + } + } + + return nil +} + +// https://matrix.org/docs/spec/client_server/r0.2.0.html#post-matrix-client-r0-createroom +type createRoomResponse struct { + RoomID string `json:"room_id"` + RoomAlias string `json:"room_alias,omitempty"` // in synapse not spec +} + +// fledglingEvent is a helper representation of an event used when creating many events in succession. +type fledglingEvent struct { + Type string `json:"type"` + StateKey string `json:"state_key"` + Content interface{} `json:"content"` +} + +// CreateRoom implements /createRoom +func CreateRoom( + req *http.Request, device *authtypes.Device, + cfg config.Dendrite, producer *producers.RoomserverProducer, + accountDB *accounts.Database, aliasAPI roomserverAPI.RoomserverAliasAPI, + asAPI appserviceAPI.AppServiceQueryAPI, +) util.JSONResponse { + // TODO (#267): Check room ID doesn't clash with an existing one, and we + // probably shouldn't be using pseudo-random strings, maybe GUIDs? + roomID := fmt.Sprintf("!%s:%s", util.RandomString(16), cfg.Matrix.ServerName) + return createRoom(req, device, cfg, roomID, producer, accountDB, aliasAPI, asAPI) +} + +// createRoom implements /createRoom +// nolint: gocyclo +func createRoom( + req *http.Request, device *authtypes.Device, + cfg config.Dendrite, roomID string, producer *producers.RoomserverProducer, + accountDB *accounts.Database, aliasAPI roomserverAPI.RoomserverAliasAPI, + asAPI appserviceAPI.AppServiceQueryAPI, +) util.JSONResponse { + logger := util.GetLogger(req.Context()) + userID := device.UserID + var r createRoomRequest + resErr := httputil.UnmarshalJSONRequest(req, &r) + if resErr != nil { + return *resErr + } + // TODO: apply rate-limit + + if resErr = r.Validate(); resErr != nil { + return *resErr + } + + evTime, err := httputil.ParseTSParam(req) + if err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.InvalidArgumentValue(err.Error()), + } + } + // TODO: visibility/presets/raw initial state/creation content + // TODO: Create room alias association + // Make sure this doesn't fall into an application service's namespace though! + + logger.WithFields(log.Fields{ + "userID": userID, + "roomID": roomID, + }).Info("Creating new room") + + profile, err := appserviceAPI.RetreiveUserProfile(req.Context(), userID, asAPI, accountDB) + if err != nil { + return httputil.LogThenError(req, err) + } + + membershipContent := common.MemberContent{ + Membership: "join", + DisplayName: profile.DisplayName, + AvatarURL: profile.AvatarURL, + } + + var joinRules, historyVisibility string + switch r.Preset { + case presetPrivateChat: + joinRules = joinRuleInvite + historyVisibility = historyVisibilityShared + case presetTrustedPrivateChat: + joinRules = joinRuleInvite + historyVisibility = historyVisibilityShared + // TODO If trusted_private_chat, all invitees are given the same power level as the room creator. + case presetPublicChat: + joinRules = joinRulePublic + historyVisibility = historyVisibilityShared + default: + // Default room rules, r.Preset was previously checked for valid values so + // only a request with no preset should end up here. + joinRules = joinRuleInvite + historyVisibility = historyVisibilityShared + } + + var builtEvents []gomatrixserverlib.Event + + // send events into the room in order of: + // 1- m.room.create + // 2- room creator join member + // 3- m.room.power_levels + // 4- m.room.canonical_alias (opt) TODO + // 5- m.room.join_rules + // 6- m.room.history_visibility + // 7- m.room.guest_access (opt) + // 8- other initial state items + // 9- m.room.name (opt) + // 10- m.room.topic (opt) + // 11- invite events (opt) - with is_direct flag if applicable TODO + // 12- 3pid invite events (opt) TODO + // 13- m.room.aliases event for HS (if alias specified) TODO + // This differs from Synapse slightly. Synapse would vary the ordering of 3-7 + // depending on if those events were in "initial_state" or not. This made it + // harder to reason about, hence sticking to a strict static ordering. + // TODO: Synapse has txn/token ID on each event. Do we need to do this here? + eventsToMake := []fledglingEvent{ + {"m.room.create", "", common.CreateContent{Creator: userID}}, + {"m.room.member", userID, membershipContent}, + {"m.room.power_levels", "", common.InitialPowerLevelsContent(userID)}, + // TODO: m.room.canonical_alias + {"m.room.join_rules", "", common.JoinRulesContent{JoinRule: joinRules}}, + {"m.room.history_visibility", "", common.HistoryVisibilityContent{HistoryVisibility: historyVisibility}}, + } + if r.GuestCanJoin { + eventsToMake = append(eventsToMake, fledglingEvent{"m.room.guest_access", "", common.GuestAccessContent{GuestAccess: "can_join"}}) + } + eventsToMake = append(eventsToMake, r.InitialState...) + if r.Name != "" { + eventsToMake = append(eventsToMake, fledglingEvent{"m.room.name", "", common.NameContent{Name: r.Name}}) + } + if r.Topic != "" { + eventsToMake = append(eventsToMake, fledglingEvent{"m.room.topic", "", common.TopicContent{Topic: r.Topic}}) + } + // TODO: invite events + // TODO: 3pid invite events + // TODO: m.room.aliases + + authEvents := gomatrixserverlib.NewAuthEvents(nil) + for i, e := range eventsToMake { + depth := i + 1 // depth starts at 1 + + builder := gomatrixserverlib.EventBuilder{ + Sender: userID, + RoomID: roomID, + Type: e.Type, + StateKey: &e.StateKey, + Depth: int64(depth), + } + err = builder.SetContent(e.Content) + if err != nil { + return httputil.LogThenError(req, err) + } + if i > 0 { + builder.PrevEvents = []gomatrixserverlib.EventReference{builtEvents[i-1].EventReference()} + } + var ev *gomatrixserverlib.Event + ev, err = buildEvent(&builder, &authEvents, cfg, evTime) + if err != nil { + return httputil.LogThenError(req, err) + } + + if err = gomatrixserverlib.Allowed(*ev, &authEvents); err != nil { + return httputil.LogThenError(req, err) + } + + // Add the event to the list of auth events + builtEvents = append(builtEvents, *ev) + err = authEvents.AddEvent(ev) + if err != nil { + return httputil.LogThenError(req, err) + } + } + + // send events to the room server + _, err = producer.SendEvents(req.Context(), builtEvents, cfg.Matrix.ServerName, nil) + if err != nil { + return httputil.LogThenError(req, err) + } + + // TODO(#269): Reserve room alias while we create the room. This stops us + // from creating the room but still failing due to the alias having already + // been taken. + var roomAlias string + if r.RoomAliasName != "" { + roomAlias = fmt.Sprintf("#%s:%s", r.RoomAliasName, cfg.Matrix.ServerName) + + aliasReq := roomserverAPI.SetRoomAliasRequest{ + Alias: roomAlias, + RoomID: roomID, + UserID: userID, + } + + var aliasResp roomserverAPI.SetRoomAliasResponse + err = aliasAPI.SetRoomAlias(req.Context(), &aliasReq, &aliasResp) + if err != nil { + return httputil.LogThenError(req, err) + } + + if aliasResp.AliasExists { + return util.MessageResponse(400, "Alias already exists") + } + } + + response := createRoomResponse{ + RoomID: roomID, + RoomAlias: roomAlias, + } + + return util.JSONResponse{ + Code: 200, + JSON: response, + } +} + +// buildEvent fills out auth_events for the builder then builds the event +func buildEvent( + builder *gomatrixserverlib.EventBuilder, + provider gomatrixserverlib.AuthEventProvider, + cfg config.Dendrite, + evTime time.Time, +) (*gomatrixserverlib.Event, error) { + eventsNeeded, err := gomatrixserverlib.StateNeededForEventBuilder(builder) + if err != nil { + return nil, err + } + refs, err := eventsNeeded.AuthEventReferences(provider) + if err != nil { + return nil, err + } + builder.AuthEvents = refs + eventID := fmt.Sprintf("$%s:%s", util.RandomString(16), cfg.Matrix.ServerName) + event, err := builder.Build(eventID, evTime, cfg.Matrix.ServerName, cfg.Matrix.KeyID, cfg.Matrix.PrivateKey) + if err != nil { + return nil, fmt.Errorf("cannot build event %s : Builder failed to build. %s", builder.Type, err) + } + return &event, nil +} diff --git a/clientapi/routing/device.go b/clientapi/routing/device.go new file mode 100644 index 00000000..cf6f24a7 --- /dev/null +++ b/clientapi/routing/device.go @@ -0,0 +1,155 @@ +// Copyright 2017 Paul Tötterman <paul.totterman@iki.fi> +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "database/sql" + "encoding/json" + "net/http" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/devices" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" +) + +type deviceJSON struct { + DeviceID string `json:"device_id"` + UserID string `json:"user_id"` +} + +type devicesJSON struct { + Devices []deviceJSON `json:"devices"` +} + +type deviceUpdateJSON struct { + DisplayName *string `json:"display_name"` +} + +// GetDeviceByID handles /devices/{deviceID} +func GetDeviceByID( + req *http.Request, deviceDB *devices.Database, device *authtypes.Device, + deviceID string, +) util.JSONResponse { + localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID) + if err != nil { + return httputil.LogThenError(req, err) + } + + ctx := req.Context() + dev, err := deviceDB.GetDeviceByID(ctx, localpart, deviceID) + if err == sql.ErrNoRows { + return util.JSONResponse{ + Code: http.StatusNotFound, + JSON: jsonerror.NotFound("Unknown device"), + } + } else if err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: deviceJSON{ + DeviceID: dev.ID, + UserID: dev.UserID, + }, + } +} + +// GetDevicesByLocalpart handles /devices +func GetDevicesByLocalpart( + req *http.Request, deviceDB *devices.Database, device *authtypes.Device, +) util.JSONResponse { + localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID) + if err != nil { + return httputil.LogThenError(req, err) + } + + ctx := req.Context() + deviceList, err := deviceDB.GetDevicesByLocalpart(ctx, localpart) + + if err != nil { + return httputil.LogThenError(req, err) + } + + res := devicesJSON{} + + for _, dev := range deviceList { + res.Devices = append(res.Devices, deviceJSON{ + DeviceID: dev.ID, + UserID: dev.UserID, + }) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: res, + } +} + +// UpdateDeviceByID handles PUT on /devices/{deviceID} +func UpdateDeviceByID( + req *http.Request, deviceDB *devices.Database, device *authtypes.Device, + deviceID string, +) util.JSONResponse { + if req.Method != http.MethodPut { + return util.JSONResponse{ + Code: http.StatusMethodNotAllowed, + JSON: jsonerror.NotFound("Bad Method"), + } + } + + localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID) + if err != nil { + return httputil.LogThenError(req, err) + } + + ctx := req.Context() + dev, err := deviceDB.GetDeviceByID(ctx, localpart, deviceID) + if err == sql.ErrNoRows { + return util.JSONResponse{ + Code: http.StatusNotFound, + JSON: jsonerror.NotFound("Unknown device"), + } + } else if err != nil { + return httputil.LogThenError(req, err) + } + + if dev.UserID != device.UserID { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("device not owned by current user"), + } + } + + defer req.Body.Close() // nolint: errcheck + + payload := deviceUpdateJSON{} + + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + return httputil.LogThenError(req, err) + } + + if err := deviceDB.UpdateDevice(ctx, localpart, deviceID, payload.DisplayName); err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} diff --git a/clientapi/routing/directory.go b/clientapi/routing/directory.go new file mode 100644 index 00000000..b23dfbfb --- /dev/null +++ b/clientapi/routing/directory.go @@ -0,0 +1,183 @@ +// Copyright 2017 Vector Creations Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "fmt" + "net/http" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/common/config" + roomserverAPI "github.com/matrix-org/dendrite/roomserver/api" + "github.com/matrix-org/gomatrix" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" +) + +// DirectoryRoom looks up a room alias +func DirectoryRoom( + req *http.Request, + roomAlias string, + federation *gomatrixserverlib.FederationClient, + cfg *config.Dendrite, + rsAPI roomserverAPI.RoomserverAliasAPI, +) util.JSONResponse { + _, domain, err := gomatrixserverlib.SplitID('#', roomAlias) + if err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("Room alias must be in the form '#localpart:domain'"), + } + } + + if domain == cfg.Matrix.ServerName { + // Query the roomserver API to check if the alias exists locally + queryReq := roomserverAPI.GetRoomIDForAliasRequest{Alias: roomAlias} + var queryRes roomserverAPI.GetRoomIDForAliasResponse + if err = rsAPI.GetRoomIDForAlias(req.Context(), &queryReq, &queryRes); err != nil { + return httputil.LogThenError(req, err) + } + + // List any roomIDs found associated with this alias + if len(queryRes.RoomID) > 0 { + return util.JSONResponse{ + Code: http.StatusOK, + JSON: queryRes, + } + } + } else { + // Query the federation for this room alias + resp, err := federation.LookupRoomAlias(req.Context(), domain, roomAlias) + if err != nil { + switch err.(type) { + case gomatrix.HTTPError: + default: + // TODO: Return 502 if the remote server errored. + // TODO: Return 504 if the remote server timed out. + return httputil.LogThenError(req, err) + } + } + if len(resp.RoomID) > 0 { + return util.JSONResponse{ + Code: http.StatusOK, + JSON: resp, + } + } + } + + return util.JSONResponse{ + Code: http.StatusNotFound, + JSON: jsonerror.NotFound( + fmt.Sprintf("Room alias %s not found", roomAlias), + ), + } +} + +// SetLocalAlias implements PUT /directory/room/{roomAlias} +// TODO: Check if the user has the power level to set an alias +func SetLocalAlias( + req *http.Request, + device *authtypes.Device, + alias string, + cfg *config.Dendrite, + aliasAPI roomserverAPI.RoomserverAliasAPI, +) util.JSONResponse { + _, domain, err := gomatrixserverlib.SplitID('#', alias) + if err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("Room alias must be in the form '#localpart:domain'"), + } + } + + if domain != cfg.Matrix.ServerName { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("Alias must be on local homeserver"), + } + } + + // Check that the alias does not fall within an exclusive namespace of an + // application service + // TODO: This code should eventually be refactored with: + // 1. The new method for checking for things matching an AS's namespace + // 2. Using an overall Regex object for all AS's just like we did for usernames + for _, appservice := range cfg.Derived.ApplicationServices { + if aliasNamespaces, ok := appservice.NamespaceMap["aliases"]; ok { + for _, namespace := range aliasNamespaces { + if namespace.Exclusive && namespace.RegexpObject.MatchString(alias) { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.ASExclusive("Alias is reserved by an application service"), + } + } + } + } + } + + var r struct { + RoomID string `json:"room_id"` + } + if resErr := httputil.UnmarshalJSONRequest(req, &r); resErr != nil { + return *resErr + } + + queryReq := roomserverAPI.SetRoomAliasRequest{ + UserID: device.UserID, + RoomID: r.RoomID, + Alias: alias, + } + var queryRes roomserverAPI.SetRoomAliasResponse + if err := aliasAPI.SetRoomAlias(req.Context(), &queryReq, &queryRes); err != nil { + return httputil.LogThenError(req, err) + } + + if queryRes.AliasExists { + return util.JSONResponse{ + Code: http.StatusConflict, + JSON: jsonerror.Unknown("The alias " + alias + " already exists."), + } + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} + +// RemoveLocalAlias implements DELETE /directory/room/{roomAlias} +// TODO: Check if the user has the power level to remove an alias +func RemoveLocalAlias( + req *http.Request, + device *authtypes.Device, + alias string, + aliasAPI roomserverAPI.RoomserverAliasAPI, +) util.JSONResponse { + queryReq := roomserverAPI.RemoveRoomAliasRequest{ + Alias: alias, + UserID: device.UserID, + } + var queryRes roomserverAPI.RemoveRoomAliasResponse + if err := aliasAPI.RemoveRoomAlias(req.Context(), &queryReq, &queryRes); err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} diff --git a/clientapi/routing/filter.go b/clientapi/routing/filter.go new file mode 100644 index 00000000..109c55da --- /dev/null +++ b/clientapi/routing/filter.go @@ -0,0 +1,123 @@ +// Copyright 2017 Jan Christian Grünhage +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "net/http" + + "encoding/json" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/gomatrix" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" +) + +// GetFilter implements GET /_matrix/client/r0/user/{userId}/filter/{filterId} +func GetFilter( + req *http.Request, device *authtypes.Device, accountDB *accounts.Database, userID string, filterID string, +) util.JSONResponse { + if req.Method != http.MethodGet { + return util.JSONResponse{ + Code: http.StatusMethodNotAllowed, + JSON: jsonerror.NotFound("Bad method"), + } + } + if userID != device.UserID { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("Cannot get filters for other users"), + } + } + localpart, _, err := gomatrixserverlib.SplitID('@', userID) + if err != nil { + return httputil.LogThenError(req, err) + } + + res, err := accountDB.GetFilter(req.Context(), localpart, filterID) + if err != nil { + //TODO better error handling. This error message is *probably* right, + // but if there are obscure db errors, this will also be returned, + // even though it is not correct. + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.NotFound("No such filter"), + } + } + filter := gomatrix.Filter{} + err = json.Unmarshal(res, &filter) + if err != nil { + httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: filter, + } +} + +type filterResponse struct { + FilterID string `json:"filter_id"` +} + +//PutFilter implements POST /_matrix/client/r0/user/{userId}/filter +func PutFilter( + req *http.Request, device *authtypes.Device, accountDB *accounts.Database, userID string, +) util.JSONResponse { + if req.Method != http.MethodPost { + return util.JSONResponse{ + Code: http.StatusMethodNotAllowed, + JSON: jsonerror.NotFound("Bad method"), + } + } + if userID != device.UserID { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("Cannot create filters for other users"), + } + } + + localpart, _, err := gomatrixserverlib.SplitID('@', userID) + if err != nil { + return httputil.LogThenError(req, err) + } + + var filter gomatrix.Filter + + if reqErr := httputil.UnmarshalJSONRequest(req, &filter); reqErr != nil { + return *reqErr + } + + filterArray, err := json.Marshal(filter) + if err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("Filter is malformed"), + } + } + + filterID, err := accountDB.PutFilter(req.Context(), localpart, filterArray) + if err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: filterResponse{FilterID: filterID}, + } +} diff --git a/clientapi/routing/joinroom.go b/clientapi/routing/joinroom.go new file mode 100644 index 00000000..c98688de --- /dev/null +++ b/clientapi/routing/joinroom.go @@ -0,0 +1,333 @@ +// Copyright 2017 Vector Creations Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/clientapi/producers" + "github.com/matrix-org/dendrite/common" + "github.com/matrix-org/dendrite/common/config" + roomserverAPI "github.com/matrix-org/dendrite/roomserver/api" + "github.com/matrix-org/gomatrix" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" +) + +// JoinRoomByIDOrAlias implements the "/join/{roomIDOrAlias}" API. +// https://matrix.org/docs/spec/client_server/r0.2.0.html#post-matrix-client-r0-join-roomidoralias +func JoinRoomByIDOrAlias( + req *http.Request, + device *authtypes.Device, + roomIDOrAlias string, + cfg config.Dendrite, + federation *gomatrixserverlib.FederationClient, + producer *producers.RoomserverProducer, + queryAPI roomserverAPI.RoomserverQueryAPI, + aliasAPI roomserverAPI.RoomserverAliasAPI, + keyRing gomatrixserverlib.KeyRing, + accountDB *accounts.Database, +) util.JSONResponse { + var content map[string]interface{} // must be a JSON object + if resErr := httputil.UnmarshalJSONRequest(req, &content); resErr != nil { + return *resErr + } + + evTime, err := httputil.ParseTSParam(req) + if err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.InvalidArgumentValue(err.Error()), + } + } + + localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID) + if err != nil { + return httputil.LogThenError(req, err) + } + + profile, err := accountDB.GetProfileByLocalpart(req.Context(), localpart) + if err != nil { + return httputil.LogThenError(req, err) + } + + content["membership"] = "join" + content["displayname"] = profile.DisplayName + content["avatar_url"] = profile.AvatarURL + + r := joinRoomReq{ + req, evTime, content, device.UserID, cfg, federation, producer, queryAPI, aliasAPI, keyRing, + } + + if strings.HasPrefix(roomIDOrAlias, "!") { + return r.joinRoomByID(roomIDOrAlias) + } + if strings.HasPrefix(roomIDOrAlias, "#") { + return r.joinRoomByAlias(roomIDOrAlias) + } + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("Invalid first character for room ID or alias"), + } +} + +type joinRoomReq struct { + req *http.Request + evTime time.Time + content map[string]interface{} + userID string + cfg config.Dendrite + federation *gomatrixserverlib.FederationClient + producer *producers.RoomserverProducer + queryAPI roomserverAPI.RoomserverQueryAPI + aliasAPI roomserverAPI.RoomserverAliasAPI + keyRing gomatrixserverlib.KeyRing +} + +// joinRoomByID joins a room by room ID +func (r joinRoomReq) joinRoomByID(roomID string) util.JSONResponse { + // A client should only join a room by room ID when it has an invite + // to the room. If the server is already in the room then we can + // lookup the invite and process the request as a normal state event. + // If the server is not in the room the we will need to look up the + // remote server the invite came from in order to request a join event + // from that server. + queryReq := roomserverAPI.QueryInvitesForUserRequest{ + RoomID: roomID, TargetUserID: r.userID, + } + var queryRes roomserverAPI.QueryInvitesForUserResponse + if err := r.queryAPI.QueryInvitesForUser(r.req.Context(), &queryReq, &queryRes); err != nil { + return httputil.LogThenError(r.req, err) + } + + servers := []gomatrixserverlib.ServerName{} + seenInInviterIDs := map[gomatrixserverlib.ServerName]bool{} + for _, userID := range queryRes.InviteSenderUserIDs { + _, domain, err := gomatrixserverlib.SplitID('@', userID) + if err != nil { + return httputil.LogThenError(r.req, err) + } + if !seenInInviterIDs[domain] { + servers = append(servers, domain) + seenInInviterIDs[domain] = true + } + } + + // Also add the domain extracted from the roomID as a last resort to join + // in case the client is erroneously trying to join by ID without an invite + // or all previous attempts at domains extracted from the inviter IDs fail + // Note: It's no guarantee we'll succeed because a room isn't bound to the domain in its ID + _, domain, err := gomatrixserverlib.SplitID('!', roomID) + if err != nil { + return httputil.LogThenError(r.req, err) + } + if domain != r.cfg.Matrix.ServerName && !seenInInviterIDs[domain] { + servers = append(servers, domain) + } + + return r.joinRoomUsingServers(roomID, servers) + +} + +// joinRoomByAlias joins a room using a room alias. +func (r joinRoomReq) joinRoomByAlias(roomAlias string) util.JSONResponse { + _, domain, err := gomatrixserverlib.SplitID('#', roomAlias) + if err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("Room alias must be in the form '#localpart:domain'"), + } + } + if domain == r.cfg.Matrix.ServerName { + queryReq := roomserverAPI.GetRoomIDForAliasRequest{Alias: roomAlias} + var queryRes roomserverAPI.GetRoomIDForAliasResponse + if err = r.aliasAPI.GetRoomIDForAlias(r.req.Context(), &queryReq, &queryRes); err != nil { + return httputil.LogThenError(r.req, err) + } + + if len(queryRes.RoomID) > 0 { + return r.joinRoomUsingServers(queryRes.RoomID, []gomatrixserverlib.ServerName{r.cfg.Matrix.ServerName}) + } + // If the response doesn't contain a non-empty string, return an error + return util.JSONResponse{ + Code: http.StatusNotFound, + JSON: jsonerror.NotFound("Room alias " + roomAlias + " not found."), + } + } + // If the room isn't local, use federation to join + return r.joinRoomByRemoteAlias(domain, roomAlias) +} + +func (r joinRoomReq) joinRoomByRemoteAlias( + domain gomatrixserverlib.ServerName, roomAlias string, +) util.JSONResponse { + resp, err := r.federation.LookupRoomAlias(r.req.Context(), domain, roomAlias) + if err != nil { + switch x := err.(type) { + case gomatrix.HTTPError: + if x.Code == http.StatusNotFound { + return util.JSONResponse{ + Code: http.StatusNotFound, + JSON: jsonerror.NotFound("Room alias not found"), + } + } + } + return httputil.LogThenError(r.req, err) + } + + return r.joinRoomUsingServers(resp.RoomID, resp.Servers) +} + +func (r joinRoomReq) writeToBuilder(eb *gomatrixserverlib.EventBuilder, roomID string) error { + eb.Type = "m.room.member" + + err := eb.SetContent(r.content) + if err != nil { + return err + } + + err = eb.SetUnsigned(struct{}{}) + if err != nil { + return err + } + + eb.Sender = r.userID + eb.StateKey = &r.userID + eb.RoomID = roomID + eb.Redacts = "" + + return nil +} + +func (r joinRoomReq) joinRoomUsingServers( + roomID string, servers []gomatrixserverlib.ServerName, +) util.JSONResponse { + var eb gomatrixserverlib.EventBuilder + err := r.writeToBuilder(&eb, roomID) + if err != nil { + return httputil.LogThenError(r.req, err) + } + + var queryRes roomserverAPI.QueryLatestEventsAndStateResponse + event, err := common.BuildEvent(r.req.Context(), &eb, r.cfg, r.evTime, r.queryAPI, &queryRes) + if err == nil { + if _, err = r.producer.SendEvents(r.req.Context(), []gomatrixserverlib.Event{*event}, r.cfg.Matrix.ServerName, nil); err != nil { + return httputil.LogThenError(r.req, err) + } + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct { + RoomID string `json:"room_id"` + }{roomID}, + } + } + if err != common.ErrRoomNoExists { + return httputil.LogThenError(r.req, err) + } + + if len(servers) == 0 { + return util.JSONResponse{ + Code: http.StatusNotFound, + JSON: jsonerror.NotFound("No candidate servers found for room"), + } + } + + var lastErr error + for _, server := range servers { + var response *util.JSONResponse + response, lastErr = r.joinRoomUsingServer(roomID, server) + if lastErr != nil { + // There was a problem talking to one of the servers. + util.GetLogger(r.req.Context()).WithError(lastErr).WithField("server", server).Warn("Failed to join room using server") + // Try the next server. + continue + } + return *response + } + + // Every server we tried to join through resulted in an error. + // We return the error from the last server. + + // TODO: Generate the correct HTTP status code for all different + // kinds of errors that could have happened. + // The possible errors include: + // 1) We can't connect to the remote servers. + // 2) None of the servers we could connect to think we are allowed + // to join the room. + // 3) The remote server returned something invalid. + // 4) We couldn't fetch the public keys needed to verify the + // signatures on the state events. + // 5) ... + return httputil.LogThenError(r.req, lastErr) +} + +// joinRoomUsingServer tries to join a remote room using a given matrix server. +// If there was a failure communicating with the server or the response from the +// server was invalid this returns an error. +// Otherwise this returns a JSONResponse. +func (r joinRoomReq) joinRoomUsingServer(roomID string, server gomatrixserverlib.ServerName) (*util.JSONResponse, error) { + respMakeJoin, err := r.federation.MakeJoin(r.req.Context(), server, roomID, r.userID) + if err != nil { + // TODO: Check if the user was not allowed to join the room. + return nil, err + } + + // Set all the fields to be what they should be, this should be a no-op + // but it's possible that the remote server returned us something "odd" + err = r.writeToBuilder(&respMakeJoin.JoinEvent, roomID) + if err != nil { + return nil, err + } + + eventID := fmt.Sprintf("$%s:%s", util.RandomString(16), r.cfg.Matrix.ServerName) + event, err := respMakeJoin.JoinEvent.Build( + eventID, r.evTime, r.cfg.Matrix.ServerName, r.cfg.Matrix.KeyID, r.cfg.Matrix.PrivateKey, + ) + if err != nil { + res := httputil.LogThenError(r.req, err) + return &res, nil + } + + respSendJoin, err := r.federation.SendJoin(r.req.Context(), server, event) + if err != nil { + return nil, err + } + + if err = respSendJoin.Check(r.req.Context(), r.keyRing, event); err != nil { + return nil, err + } + + if err = r.producer.SendEventWithState( + r.req.Context(), gomatrixserverlib.RespState(respSendJoin), event, + ); err != nil { + res := httputil.LogThenError(r.req, err) + return &res, nil + } + + return &util.JSONResponse{ + Code: http.StatusOK, + // TODO: Put the response struct somewhere common. + JSON: struct { + RoomID string `json:"room_id"` + }{roomID}, + }, nil +} diff --git a/clientapi/routing/login.go b/clientapi/routing/login.go new file mode 100644 index 00000000..cb221880 --- /dev/null +++ b/clientapi/routing/login.go @@ -0,0 +1,152 @@ +// Copyright 2017 Vector Creations Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "net/http" + + "context" + "database/sql" + "github.com/matrix-org/dendrite/clientapi/auth" + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts" + "github.com/matrix-org/dendrite/clientapi/auth/storage/devices" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/clientapi/userutil" + "github.com/matrix-org/dendrite/common/config" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" +) + +type loginFlows struct { + Flows []flow `json:"flows"` +} + +type flow struct { + Type string `json:"type"` + Stages []string `json:"stages"` +} + +type passwordRequest struct { + User string `json:"user"` + Password string `json:"password"` + InitialDisplayName *string `json:"initial_device_display_name"` + DeviceID string `json:"device_id"` +} + +type loginResponse struct { + UserID string `json:"user_id"` + AccessToken string `json:"access_token"` + HomeServer gomatrixserverlib.ServerName `json:"home_server"` + DeviceID string `json:"device_id"` +} + +func passwordLogin() loginFlows { + f := loginFlows{} + s := flow{"m.login.password", []string{"m.login.password"}} + f.Flows = append(f.Flows, s) + return f +} + +// Login implements GET and POST /login +func Login( + req *http.Request, accountDB *accounts.Database, deviceDB *devices.Database, + cfg config.Dendrite, +) util.JSONResponse { + if req.Method == http.MethodGet { // TODO: support other forms of login other than password, depending on config options + return util.JSONResponse{ + Code: http.StatusOK, + JSON: passwordLogin(), + } + } else if req.Method == http.MethodPost { + var r passwordRequest + resErr := httputil.UnmarshalJSONRequest(req, &r) + if resErr != nil { + return *resErr + } + if r.User == "" { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("'user' must be supplied."), + } + } + + util.GetLogger(req.Context()).WithField("user", r.User).Info("Processing login request") + + localpart, err := userutil.ParseUsernameParam(r.User, &cfg.Matrix.ServerName) + if err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.InvalidUsername(err.Error()), + } + } + + acc, err := accountDB.GetAccountByPassword(req.Context(), localpart, r.Password) + if err != nil { + // Technically we could tell them if the user does not exist by checking if err == sql.ErrNoRows + // but that would leak the existence of the user. + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("username or password was incorrect, or the account does not exist"), + } + } + + token, err := auth.GenerateAccessToken() + if err != nil { + httputil.LogThenError(req, err) + } + + dev, err := getDevice(req.Context(), r, deviceDB, acc, localpart, token) + if err != nil { + return util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: jsonerror.Unknown("failed to create device: " + err.Error()), + } + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: loginResponse{ + UserID: dev.UserID, + AccessToken: dev.AccessToken, + HomeServer: cfg.Matrix.ServerName, + DeviceID: dev.ID, + }, + } + } + return util.JSONResponse{ + Code: http.StatusMethodNotAllowed, + JSON: jsonerror.NotFound("Bad method"), + } +} + +// check if device exists else create one +func getDevice( + ctx context.Context, + r passwordRequest, + deviceDB *devices.Database, + acc *authtypes.Account, + localpart, token string, +) (dev *authtypes.Device, err error) { + dev, err = deviceDB.GetDeviceByID(ctx, localpart, r.DeviceID) + if err == sql.ErrNoRows { + // device doesn't exist, create one + dev, err = deviceDB.CreateDevice( + ctx, acc.Localpart, nil, token, r.InitialDisplayName, + ) + } + return +} diff --git a/clientapi/routing/logout.go b/clientapi/routing/logout.go new file mode 100644 index 00000000..d2013853 --- /dev/null +++ b/clientapi/routing/logout.go @@ -0,0 +1,71 @@ +// Copyright 2017 Vector Creations Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "net/http" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/devices" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" +) + +// Logout handles POST /logout +func Logout( + req *http.Request, deviceDB *devices.Database, device *authtypes.Device, +) util.JSONResponse { + if req.Method != http.MethodPost { + return util.JSONResponse{ + Code: http.StatusMethodNotAllowed, + JSON: jsonerror.NotFound("Bad method"), + } + } + + localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID) + if err != nil { + return httputil.LogThenError(req, err) + } + + if err := deviceDB.RemoveDevice(req.Context(), device.ID, localpart); err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} + +// LogoutAll handles POST /logout/all +func LogoutAll( + req *http.Request, deviceDB *devices.Database, device *authtypes.Device, +) util.JSONResponse { + localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID) + if err != nil { + return httputil.LogThenError(req, err) + } + + if err := deviceDB.RemoveAllDevices(req.Context(), localpart); err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} diff --git a/clientapi/routing/membership.go b/clientapi/routing/membership.go new file mode 100644 index 00000000..b308de79 --- /dev/null +++ b/clientapi/routing/membership.go @@ -0,0 +1,217 @@ +// Copyright 2017 Vector Creations Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "context" + "errors" + "net/http" + "time" + + appserviceAPI "github.com/matrix-org/dendrite/appservice/api" + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/clientapi/producers" + "github.com/matrix-org/dendrite/clientapi/threepid" + "github.com/matrix-org/dendrite/common" + "github.com/matrix-org/dendrite/common/config" + roomserverAPI "github.com/matrix-org/dendrite/roomserver/api" + "github.com/matrix-org/gomatrixserverlib" + + "github.com/matrix-org/util" +) + +var errMissingUserID = errors.New("'user_id' must be supplied") + +// SendMembership implements PUT /rooms/{roomID}/(join|kick|ban|unban|leave|invite) +// by building a m.room.member event then sending it to the room server +func SendMembership( + req *http.Request, accountDB *accounts.Database, device *authtypes.Device, + roomID string, membership string, cfg config.Dendrite, + queryAPI roomserverAPI.RoomserverQueryAPI, asAPI appserviceAPI.AppServiceQueryAPI, + producer *producers.RoomserverProducer, +) util.JSONResponse { + var body threepid.MembershipRequest + if reqErr := httputil.UnmarshalJSONRequest(req, &body); reqErr != nil { + return *reqErr + } + + evTime, err := httputil.ParseTSParam(req) + if err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.InvalidArgumentValue(err.Error()), + } + } + + inviteStored, err := threepid.CheckAndProcessInvite( + req.Context(), device, &body, cfg, queryAPI, accountDB, producer, + membership, roomID, evTime, + ) + if err == threepid.ErrMissingParameter { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON(err.Error()), + } + } else if err == threepid.ErrNotTrusted { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.NotTrusted(body.IDServer), + } + } else if err == common.ErrRoomNoExists { + return util.JSONResponse{ + Code: http.StatusNotFound, + JSON: jsonerror.NotFound(err.Error()), + } + } else if err != nil { + return httputil.LogThenError(req, err) + } + + // If an invite has been stored on an identity server, it means that a + // m.room.third_party_invite event has been emitted and that we shouldn't + // emit a m.room.member one. + if inviteStored { + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } + } + + event, err := buildMembershipEvent( + req.Context(), body, accountDB, device, membership, roomID, cfg, evTime, queryAPI, asAPI, + ) + if err == errMissingUserID { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON(err.Error()), + } + } else if err == common.ErrRoomNoExists { + return util.JSONResponse{ + Code: http.StatusNotFound, + JSON: jsonerror.NotFound(err.Error()), + } + } else if err != nil { + return httputil.LogThenError(req, err) + } + + if _, err := producer.SendEvents( + req.Context(), []gomatrixserverlib.Event{*event}, cfg.Matrix.ServerName, nil, + ); err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} + +func buildMembershipEvent( + ctx context.Context, + body threepid.MembershipRequest, accountDB *accounts.Database, + device *authtypes.Device, + membership, roomID string, + cfg config.Dendrite, evTime time.Time, + queryAPI roomserverAPI.RoomserverQueryAPI, asAPI appserviceAPI.AppServiceQueryAPI, +) (*gomatrixserverlib.Event, error) { + stateKey, reason, err := getMembershipStateKey(body, device, membership) + if err != nil { + return nil, err + } + + profile, err := loadProfile(ctx, stateKey, cfg, accountDB, asAPI) + if err != nil { + return nil, err + } + + builder := gomatrixserverlib.EventBuilder{ + Sender: device.UserID, + RoomID: roomID, + Type: "m.room.member", + StateKey: &stateKey, + } + + // "unban" or "kick" isn't a valid membership value, change it to "leave" + if membership == "unban" || membership == "kick" { + membership = "leave" + } + + content := common.MemberContent{ + Membership: membership, + DisplayName: profile.DisplayName, + AvatarURL: profile.AvatarURL, + Reason: reason, + } + + if err = builder.SetContent(content); err != nil { + return nil, err + } + + return common.BuildEvent(ctx, &builder, cfg, evTime, queryAPI, nil) +} + +// loadProfile lookups the profile of a given user from the database and returns +// it if the user is local to this server, or returns an empty profile if not. +// Returns an error if the retrieval failed or if the first parameter isn't a +// valid Matrix ID. +func loadProfile( + ctx context.Context, + userID string, + cfg config.Dendrite, + accountDB *accounts.Database, + asAPI appserviceAPI.AppServiceQueryAPI, +) (*authtypes.Profile, error) { + _, serverName, err := gomatrixserverlib.SplitID('@', userID) + if err != nil { + return nil, err + } + + var profile *authtypes.Profile + if serverName == cfg.Matrix.ServerName { + profile, err = appserviceAPI.RetreiveUserProfile(ctx, userID, asAPI, accountDB) + } else { + profile = &authtypes.Profile{} + } + + return profile, err +} + +// getMembershipStateKey extracts the target user ID of a membership change. +// For "join" and "leave" this will be the ID of the user making the change. +// For "ban", "unban", "kick" and "invite" the target user ID will be in the JSON request body. +// In the latter case, if there was an issue retrieving the user ID from the request body, +// returns a JSONResponse with a corresponding error code and message. +func getMembershipStateKey( + body threepid.MembershipRequest, device *authtypes.Device, membership string, +) (stateKey string, reason string, err error) { + if membership == "ban" || membership == "unban" || membership == "kick" || membership == "invite" { + // If we're in this case, the state key is contained in the request body, + // possibly along with a reason (for "kick" and "ban") so we need to parse + // it + if body.UserID == "" { + err = errMissingUserID + return + } + + stateKey = body.UserID + reason = body.Reason + } else { + stateKey = device.UserID + } + + return +} diff --git a/clientapi/routing/memberships.go b/clientapi/routing/memberships.go new file mode 100644 index 00000000..5b890328 --- /dev/null +++ b/clientapi/routing/memberships.go @@ -0,0 +1,60 @@ +// Copyright 2017 Vector Creations Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "net/http" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/common/config" + "github.com/matrix-org/dendrite/roomserver/api" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" +) + +type response struct { + Chunk []gomatrixserverlib.ClientEvent `json:"chunk"` +} + +// GetMemberships implements GET /rooms/{roomId}/members +func GetMemberships( + req *http.Request, device *authtypes.Device, roomID string, joinedOnly bool, + _ config.Dendrite, + queryAPI api.RoomserverQueryAPI, +) util.JSONResponse { + queryReq := api.QueryMembershipsForRoomRequest{ + JoinedOnly: joinedOnly, + RoomID: roomID, + Sender: device.UserID, + } + var queryRes api.QueryMembershipsForRoomResponse + if err := queryAPI.QueryMembershipsForRoom(req.Context(), &queryReq, &queryRes); err != nil { + return httputil.LogThenError(req, err) + } + + if !queryRes.HasBeenInRoom { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("You aren't a member of the room and weren't previously a member of the room."), + } + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: response{queryRes.JoinEvents}, + } +} diff --git a/clientapi/routing/profile.go b/clientapi/routing/profile.go new file mode 100644 index 00000000..e57d16fb --- /dev/null +++ b/clientapi/routing/profile.go @@ -0,0 +1,292 @@ +// Copyright 2017 Vector Creations Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "context" + "net/http" + "time" + + appserviceAPI "github.com/matrix-org/dendrite/appservice/api" + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/clientapi/producers" + "github.com/matrix-org/dendrite/common" + "github.com/matrix-org/dendrite/common/config" + "github.com/matrix-org/dendrite/roomserver/api" + "github.com/matrix-org/gomatrixserverlib" + + "github.com/matrix-org/util" +) + +// GetProfile implements GET /profile/{userID} +func GetProfile( + req *http.Request, accountDB *accounts.Database, userID string, asAPI appserviceAPI.AppServiceQueryAPI, +) util.JSONResponse { + if req.Method != http.MethodGet { + return util.JSONResponse{ + Code: http.StatusMethodNotAllowed, + JSON: jsonerror.NotFound("Bad method"), + } + } + profile, err := appserviceAPI.RetreiveUserProfile(req.Context(), userID, asAPI, accountDB) + if err != nil { + return httputil.LogThenError(req, err) + } + + res := common.ProfileResponse{ + AvatarURL: profile.AvatarURL, + DisplayName: profile.DisplayName, + } + return util.JSONResponse{ + Code: http.StatusOK, + JSON: res, + } +} + +// GetAvatarURL implements GET /profile/{userID}/avatar_url +func GetAvatarURL( + req *http.Request, accountDB *accounts.Database, userID string, asAPI appserviceAPI.AppServiceQueryAPI, +) util.JSONResponse { + profile, err := appserviceAPI.RetreiveUserProfile(req.Context(), userID, asAPI, accountDB) + if err != nil { + return httputil.LogThenError(req, err) + } + + res := common.AvatarURL{ + AvatarURL: profile.AvatarURL, + } + return util.JSONResponse{ + Code: http.StatusOK, + JSON: res, + } +} + +// SetAvatarURL implements PUT /profile/{userID}/avatar_url +func SetAvatarURL( + req *http.Request, accountDB *accounts.Database, device *authtypes.Device, + userID string, producer *producers.UserUpdateProducer, cfg *config.Dendrite, + rsProducer *producers.RoomserverProducer, queryAPI api.RoomserverQueryAPI, +) util.JSONResponse { + if userID != device.UserID { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("userID does not match the current user"), + } + } + + changedKey := "avatar_url" + + var r common.AvatarURL + if resErr := httputil.UnmarshalJSONRequest(req, &r); resErr != nil { + return *resErr + } + if r.AvatarURL == "" { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("'avatar_url' must be supplied."), + } + } + + localpart, _, err := gomatrixserverlib.SplitID('@', userID) + if err != nil { + return httputil.LogThenError(req, err) + } + + evTime, err := httputil.ParseTSParam(req) + if err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.InvalidArgumentValue(err.Error()), + } + } + + oldProfile, err := accountDB.GetProfileByLocalpart(req.Context(), localpart) + if err != nil { + return httputil.LogThenError(req, err) + } + + if err = accountDB.SetAvatarURL(req.Context(), localpart, r.AvatarURL); err != nil { + return httputil.LogThenError(req, err) + } + + memberships, err := accountDB.GetMembershipsByLocalpart(req.Context(), localpart) + if err != nil { + return httputil.LogThenError(req, err) + } + + newProfile := authtypes.Profile{ + Localpart: localpart, + DisplayName: oldProfile.DisplayName, + AvatarURL: r.AvatarURL, + } + + events, err := buildMembershipEvents( + req.Context(), memberships, newProfile, userID, cfg, evTime, queryAPI, + ) + if err != nil { + return httputil.LogThenError(req, err) + } + + if _, err := rsProducer.SendEvents(req.Context(), events, cfg.Matrix.ServerName, nil); err != nil { + return httputil.LogThenError(req, err) + } + + if err := producer.SendUpdate(userID, changedKey, oldProfile.AvatarURL, r.AvatarURL); err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} + +// GetDisplayName implements GET /profile/{userID}/displayname +func GetDisplayName( + req *http.Request, accountDB *accounts.Database, userID string, asAPI appserviceAPI.AppServiceQueryAPI, +) util.JSONResponse { + profile, err := appserviceAPI.RetreiveUserProfile(req.Context(), userID, asAPI, accountDB) + if err != nil { + return httputil.LogThenError(req, err) + } + res := common.DisplayName{ + DisplayName: profile.DisplayName, + } + return util.JSONResponse{ + Code: http.StatusOK, + JSON: res, + } +} + +// SetDisplayName implements PUT /profile/{userID}/displayname +func SetDisplayName( + req *http.Request, accountDB *accounts.Database, device *authtypes.Device, + userID string, producer *producers.UserUpdateProducer, cfg *config.Dendrite, + rsProducer *producers.RoomserverProducer, queryAPI api.RoomserverQueryAPI, +) util.JSONResponse { + if userID != device.UserID { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("userID does not match the current user"), + } + } + + changedKey := "displayname" + + var r common.DisplayName + if resErr := httputil.UnmarshalJSONRequest(req, &r); resErr != nil { + return *resErr + } + if r.DisplayName == "" { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("'displayname' must be supplied."), + } + } + + localpart, _, err := gomatrixserverlib.SplitID('@', userID) + if err != nil { + return httputil.LogThenError(req, err) + } + + evTime, err := httputil.ParseTSParam(req) + if err != nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.InvalidArgumentValue(err.Error()), + } + } + + oldProfile, err := accountDB.GetProfileByLocalpart(req.Context(), localpart) + if err != nil { + return httputil.LogThenError(req, err) + } + + if err = accountDB.SetDisplayName(req.Context(), localpart, r.DisplayName); err != nil { + return httputil.LogThenError(req, err) + } + + memberships, err := accountDB.GetMembershipsByLocalpart(req.Context(), localpart) + if err != nil { + return httputil.LogThenError(req, err) + } + + newProfile := authtypes.Profile{ + Localpart: localpart, + DisplayName: r.DisplayName, + AvatarURL: oldProfile.AvatarURL, + } + + events, err := buildMembershipEvents( + req.Context(), memberships, newProfile, userID, cfg, evTime, queryAPI, + ) + if err != nil { + return httputil.LogThenError(req, err) + } + + if _, err := rsProducer.SendEvents(req.Context(), events, cfg.Matrix.ServerName, nil); err != nil { + return httputil.LogThenError(req, err) + } + + if err := producer.SendUpdate(userID, changedKey, oldProfile.DisplayName, r.DisplayName); err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} + +func buildMembershipEvents( + ctx context.Context, + memberships []authtypes.Membership, + newProfile authtypes.Profile, userID string, cfg *config.Dendrite, + evTime time.Time, queryAPI api.RoomserverQueryAPI, +) ([]gomatrixserverlib.Event, error) { + evs := []gomatrixserverlib.Event{} + + for _, membership := range memberships { + builder := gomatrixserverlib.EventBuilder{ + Sender: userID, + RoomID: membership.RoomID, + Type: "m.room.member", + StateKey: &userID, + } + + content := common.MemberContent{ + Membership: "join", + } + + content.DisplayName = newProfile.DisplayName + content.AvatarURL = newProfile.AvatarURL + + if err := builder.SetContent(content); err != nil { + return nil, err + } + + event, err := common.BuildEvent(ctx, &builder, *cfg, evTime, queryAPI, nil) + if err != nil { + return nil, err + } + + evs = append(evs, *event) + } + + return evs, nil +} diff --git a/clientapi/routing/register.go b/clientapi/routing/register.go new file mode 100644 index 00000000..b1522e82 --- /dev/null +++ b/clientapi/routing/register.go @@ -0,0 +1,958 @@ +// Copyright 2017 Vector Creations Ltd +// Copyright 2017 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/matrix-org/dendrite/common/config" + + "github.com/matrix-org/dendrite/clientapi/auth" + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts" + "github.com/matrix-org/dendrite/clientapi/auth/storage/devices" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/clientapi/userutil" + "github.com/matrix-org/dendrite/common" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" + "github.com/prometheus/client_golang/prometheus" + log "github.com/sirupsen/logrus" +) + +var ( + // Prometheus metrics + amtRegUsers = prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "dendrite_clientapi_reg_users_total", + Help: "Total number of registered users", + }, + ) +) + +const ( + minPasswordLength = 8 // http://matrix.org/docs/spec/client_server/r0.2.0.html#password-based + maxPasswordLength = 512 // https://github.com/matrix-org/synapse/blob/v0.20.0/synapse/rest/client/v2_alpha/register.py#L161 + maxUsernameLength = 254 // http://matrix.org/speculator/spec/HEAD/intro.html#user-identifiers TODO account for domain + sessionIDLength = 24 +) + +func init() { + // Register prometheus metrics. They must be registered to be exposed. + prometheus.MustRegister(amtRegUsers) +} + +// sessionsDict keeps track of completed auth stages for each session. +type sessionsDict struct { + sessions map[string][]authtypes.LoginType +} + +// GetCompletedStages returns the completed stages for a session. +func (d sessionsDict) GetCompletedStages(sessionID string) []authtypes.LoginType { + if completedStages, ok := d.sessions[sessionID]; ok { + return completedStages + } + // Ensure that a empty slice is returned and not nil. See #399. + return make([]authtypes.LoginType, 0) +} + +// AddCompletedStage records that a session has completed an auth stage. +func (d *sessionsDict) AddCompletedStage(sessionID string, stage authtypes.LoginType) { + d.sessions[sessionID] = append(d.GetCompletedStages(sessionID), stage) +} + +func newSessionsDict() *sessionsDict { + return &sessionsDict{ + sessions: make(map[string][]authtypes.LoginType), + } +} + +var ( + // TODO: Remove old sessions. Need to do so on a session-specific timeout. + // sessions stores the completed flow stages for all sessions. Referenced using their sessionID. + sessions = newSessionsDict() + validUsernameRegex = regexp.MustCompile(`^[0-9a-z_\-./]+$`) +) + +// registerRequest represents the submitted registration request. +// It can be broken down into 2 sections: the auth dictionary and registration parameters. +// Registration parameters vary depending on the request, and will need to remembered across +// sessions. If no parameters are supplied, the server should use the parameters previously +// remembered. If ANY parameters are supplied, the server should REPLACE all knowledge of +// previous parameters with the ones supplied. This mean you cannot "build up" request params. +type registerRequest struct { + // registration parameters + Password string `json:"password"` + Username string `json:"username"` + Admin bool `json:"admin"` + // user-interactive auth params + Auth authDict `json:"auth"` + + InitialDisplayName *string `json:"initial_device_display_name"` + + // Prevent this user from logging in + InhibitLogin common.WeakBoolean `json:"inhibit_login"` + + // Application Services place Type in the root of their registration + // request, whereas clients place it in the authDict struct. + Type authtypes.LoginType `json:"type"` +} + +type authDict struct { + Type authtypes.LoginType `json:"type"` + Session string `json:"session"` + Mac gomatrixserverlib.HexString `json:"mac"` + + // Recaptcha + Response string `json:"response"` + // TODO: Lots of custom keys depending on the type +} + +// http://matrix.org/speculator/spec/HEAD/client_server/unstable.html#user-interactive-authentication-api +type userInteractiveResponse struct { + Flows []authtypes.Flow `json:"flows"` + Completed []authtypes.LoginType `json:"completed"` + Params map[string]interface{} `json:"params"` + Session string `json:"session"` +} + +// legacyRegisterRequest represents the submitted registration request for v1 API. +type legacyRegisterRequest struct { + Password string `json:"password"` + Username string `json:"user"` + Admin bool `json:"admin"` + Type authtypes.LoginType `json:"type"` + Mac gomatrixserverlib.HexString `json:"mac"` +} + +// newUserInteractiveResponse will return a struct to be sent back to the client +// during registration. +func newUserInteractiveResponse( + sessionID string, + fs []authtypes.Flow, + params map[string]interface{}, +) userInteractiveResponse { + return userInteractiveResponse{ + fs, sessions.GetCompletedStages(sessionID), params, sessionID, + } +} + +// http://matrix.org/speculator/spec/HEAD/client_server/unstable.html#post-matrix-client-unstable-register +type registerResponse struct { + UserID string `json:"user_id"` + AccessToken string `json:"access_token,omitempty"` + HomeServer gomatrixserverlib.ServerName `json:"home_server"` + DeviceID string `json:"device_id,omitempty"` +} + +// recaptchaResponse represents the HTTP response from a Google Recaptcha server +type recaptchaResponse struct { + Success bool `json:"success"` + ChallengeTS time.Time `json:"challenge_ts"` + Hostname string `json:"hostname"` + ErrorCodes []int `json:"error-codes"` +} + +// validateUsername returns an error response if the username is invalid +func validateUsername(username string) *util.JSONResponse { + // https://github.com/matrix-org/synapse/blob/v0.20.0/synapse/rest/client/v2_alpha/register.py#L161 + if len(username) > maxUsernameLength { + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON(fmt.Sprintf("'username' >%d characters", maxUsernameLength)), + } + } else if !validUsernameRegex.MatchString(username) { + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.InvalidUsername("Username can only contain characters a-z, 0-9, or '_-./'"), + } + } else if username[0] == '_' { // Regex checks its not a zero length string + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.InvalidUsername("Username cannot start with a '_'"), + } + } + return nil +} + +// validateApplicationServiceUsername returns an error response if the username is invalid for an application service +func validateApplicationServiceUsername(username string) *util.JSONResponse { + if len(username) > maxUsernameLength { + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON(fmt.Sprintf("'username' >%d characters", maxUsernameLength)), + } + } else if !validUsernameRegex.MatchString(username) { + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.InvalidUsername("Username can only contain characters a-z, 0-9, or '_-./'"), + } + } + return nil +} + +// validatePassword returns an error response if the password is invalid +func validatePassword(password string) *util.JSONResponse { + // https://github.com/matrix-org/synapse/blob/v0.20.0/synapse/rest/client/v2_alpha/register.py#L161 + if len(password) > maxPasswordLength { + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON(fmt.Sprintf("'password' >%d characters", maxPasswordLength)), + } + } else if len(password) > 0 && len(password) < minPasswordLength { + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.WeakPassword(fmt.Sprintf("password too weak: min %d chars", minPasswordLength)), + } + } + return nil +} + +// validateRecaptcha returns an error response if the captcha response is invalid +func validateRecaptcha( + cfg *config.Dendrite, + response string, + clientip string, +) *util.JSONResponse { + if !cfg.Matrix.RecaptchaEnabled { + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("Captcha registration is disabled"), + } + } + + if response == "" { + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("Captcha response is required"), + } + } + + // Make a POST request to Google's API to check the captcha response + resp, err := http.PostForm(cfg.Matrix.RecaptchaSiteVerifyAPI, + url.Values{ + "secret": {cfg.Matrix.RecaptchaPrivateKey}, + "response": {response}, + "remoteip": {clientip}, + }, + ) + + if err != nil { + return &util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: jsonerror.BadJSON("Error in requesting validation of captcha response"), + } + } + + // Close the request once we're finishing reading from it + defer resp.Body.Close() // nolint: errcheck + + // Grab the body of the response from the captcha server + var r recaptchaResponse + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return &util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: jsonerror.BadJSON("Error in contacting captcha server" + err.Error()), + } + } + err = json.Unmarshal(body, &r) + if err != nil { + return &util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: jsonerror.BadJSON("Error in unmarshaling captcha server's response: " + err.Error()), + } + } + + // Check that we received a "success" + if !r.Success { + return &util.JSONResponse{ + Code: http.StatusUnauthorized, + JSON: jsonerror.BadJSON("Invalid captcha response. Please try again."), + } + } + return nil +} + +// UserIDIsWithinApplicationServiceNamespace checks to see if a given userID +// falls within any of the namespaces of a given Application Service. If no +// Application Service is given, it will check to see if it matches any +// Application Service's namespace. +func UserIDIsWithinApplicationServiceNamespace( + cfg *config.Dendrite, + userID string, + appservice *config.ApplicationService, +) bool { + if appservice != nil { + // Loop through given application service's namespaces and see if any match + for _, namespace := range appservice.NamespaceMap["users"] { + // AS namespaces are checked for validity in config + if namespace.RegexpObject.MatchString(userID) { + return true + } + } + return false + } + + // Loop through all known application service's namespaces and see if any match + for _, knownAppService := range cfg.Derived.ApplicationServices { + for _, namespace := range knownAppService.NamespaceMap["users"] { + // AS namespaces are checked for validity in config + if namespace.RegexpObject.MatchString(userID) { + return true + } + } + } + return false +} + +// UsernameMatchesMultipleExclusiveNamespaces will check if a given username matches +// more than one exclusive namespace. More than one is not allowed +func UsernameMatchesMultipleExclusiveNamespaces( + cfg *config.Dendrite, + username string, +) bool { + userID := userutil.MakeUserID(username, cfg.Matrix.ServerName) + + // Check namespaces and see if more than one match + matchCount := 0 + for _, appservice := range cfg.Derived.ApplicationServices { + if appservice.IsInterestedInUserID(userID) { + if matchCount++; matchCount > 1 { + return true + } + } + } + return false +} + +// UsernameMatchesExclusiveNamespaces will check if a given username matches any +// application service's exclusive users namespace +func UsernameMatchesExclusiveNamespaces( + cfg *config.Dendrite, + username string, +) bool { + userID := userutil.MakeUserID(username, cfg.Matrix.ServerName) + return cfg.Derived.ExclusiveApplicationServicesUsernameRegexp.MatchString(userID) +} + +// validateApplicationService checks if a provided application service token +// corresponds to one that is registered. If so, then it checks if the desired +// username is within that application service's namespace. As long as these +// two requirements are met, no error will be returned. +func validateApplicationService( + cfg *config.Dendrite, + username string, + accessToken string, +) (string, *util.JSONResponse) { + // Check if the token if the application service is valid with one we have + // registered in the config. + var matchedApplicationService *config.ApplicationService + for _, appservice := range cfg.Derived.ApplicationServices { + if appservice.ASToken == accessToken { + matchedApplicationService = &appservice + break + } + } + if matchedApplicationService == nil { + return "", &util.JSONResponse{ + Code: http.StatusUnauthorized, + JSON: jsonerror.UnknownToken("Supplied access_token does not match any known application service"), + } + } + + userID := userutil.MakeUserID(username, cfg.Matrix.ServerName) + + // Ensure the desired username is within at least one of the application service's namespaces. + if !UserIDIsWithinApplicationServiceNamespace(cfg, userID, matchedApplicationService) { + // If we didn't find any matches, return M_EXCLUSIVE + return "", &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.ASExclusive(fmt.Sprintf( + "Supplied username %s did not match any namespaces for application service ID: %s", username, matchedApplicationService.ID)), + } + } + + // Check this user does not fit multiple application service namespaces + if UsernameMatchesMultipleExclusiveNamespaces(cfg, userID) { + return "", &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.ASExclusive(fmt.Sprintf( + "Supplied username %s matches multiple exclusive application service namespaces. Only 1 match allowed", username)), + } + } + + // Check username application service is trying to register is valid + if err := validateApplicationServiceUsername(username); err != nil { + return "", err + } + + // No errors, registration valid + return matchedApplicationService.ID, nil +} + +// Register processes a /register request. +// http://matrix.org/speculator/spec/HEAD/client_server/unstable.html#post-matrix-client-unstable-register +func Register( + req *http.Request, + accountDB *accounts.Database, + deviceDB *devices.Database, + cfg *config.Dendrite, +) util.JSONResponse { + + var r registerRequest + resErr := httputil.UnmarshalJSONRequest(req, &r) + if resErr != nil { + return *resErr + } + + // Retrieve or generate the sessionID + sessionID := r.Auth.Session + if sessionID == "" { + // Generate a new, random session ID + sessionID = util.RandomString(sessionIDLength) + } + + // Don't allow numeric usernames less than MAX_INT64. + if _, err := strconv.ParseInt(r.Username, 10, 64); err == nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.InvalidUsername("Numeric user IDs are reserved"), + } + } + // Auto generate a numeric username if r.Username is empty + if r.Username == "" { + id, err := accountDB.GetNewNumericLocalpart(req.Context()) + if err != nil { + return httputil.LogThenError(req, err) + } + + r.Username = strconv.FormatInt(id, 10) + } + + // Squash username to all lowercase letters + r.Username = strings.ToLower(r.Username) + + if resErr = validateUsername(r.Username); resErr != nil { + return *resErr + } + if resErr = validatePassword(r.Password); resErr != nil { + return *resErr + } + + // Make sure normal user isn't registering under an exclusive application + // service namespace. Skip this check if no app services are registered. + if r.Auth.Type != authtypes.LoginTypeApplicationService && + len(cfg.Derived.ApplicationServices) != 0 && + UsernameMatchesExclusiveNamespaces(cfg, r.Username) { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.ASExclusive("This username is reserved by an application service."), + } + } + + logger := util.GetLogger(req.Context()) + logger.WithFields(log.Fields{ + "username": r.Username, + "auth.type": r.Auth.Type, + "session_id": r.Auth.Session, + }).Info("Processing registration request") + + return handleRegistrationFlow(req, r, sessionID, cfg, accountDB, deviceDB) +} + +// handleRegistrationFlow will direct and complete registration flow stages +// that the client has requested. +// nolint: gocyclo +func handleRegistrationFlow( + req *http.Request, + r registerRequest, + sessionID string, + cfg *config.Dendrite, + accountDB *accounts.Database, + deviceDB *devices.Database, +) util.JSONResponse { + // TODO: Shared secret registration (create new user scripts) + // TODO: Enable registration config flag + // TODO: Guest account upgrading + + // TODO: Handle loading of previous session parameters from database. + // TODO: Handle mapping registrationRequest parameters into session parameters + + // TODO: email / msisdn auth types. + + if cfg.Matrix.RegistrationDisabled && r.Auth.Type != authtypes.LoginTypeSharedSecret { + return util.MessageResponse(http.StatusForbidden, "Registration has been disabled") + } + + switch r.Auth.Type { + case authtypes.LoginTypeRecaptcha: + // Check given captcha response + resErr := validateRecaptcha(cfg, r.Auth.Response, req.RemoteAddr) + if resErr != nil { + return *resErr + } + + // Add Recaptcha to the list of completed registration stages + sessions.AddCompletedStage(sessionID, authtypes.LoginTypeRecaptcha) + + case authtypes.LoginTypeSharedSecret: + // Check shared secret against config + valid, err := isValidMacLogin(cfg, r.Username, r.Password, r.Admin, r.Auth.Mac) + + if err != nil { + return httputil.LogThenError(req, err) + } else if !valid { + return util.MessageResponse(http.StatusForbidden, "HMAC incorrect") + } + + // Add SharedSecret to the list of completed registration stages + sessions.AddCompletedStage(sessionID, authtypes.LoginTypeSharedSecret) + + case "": + // Extract the access token from the request, if there's one to extract + // (which we can know by checking whether the error is nil or not). + accessToken, err := auth.ExtractAccessToken(req) + + // A missing auth type can mean either the registration is performed by + // an AS or the request is made as the first step of a registration + // using the User-Interactive Authentication API. This can be determined + // by whether the request contains an access token. + if err == nil { + return handleApplicationServiceRegistration( + accessToken, err, req, r, cfg, accountDB, deviceDB, + ) + } + + case authtypes.LoginTypeApplicationService: + // Extract the access token from the request. + accessToken, err := auth.ExtractAccessToken(req) + // Let the AS registration handler handle the process from here. We + // don't need a condition on that call since the registration is clearly + // stated as being AS-related. + return handleApplicationServiceRegistration( + accessToken, err, req, r, cfg, accountDB, deviceDB, + ) + + case authtypes.LoginTypeDummy: + // there is nothing to do + // Add Dummy to the list of completed registration stages + sessions.AddCompletedStage(sessionID, authtypes.LoginTypeDummy) + + default: + return util.JSONResponse{ + Code: http.StatusNotImplemented, + JSON: jsonerror.Unknown("unknown/unimplemented auth type"), + } + } + + // Check if the user's registration flow has been completed successfully + // A response with current registration flow and remaining available methods + // will be returned if a flow has not been successfully completed yet + return checkAndCompleteFlow(sessions.GetCompletedStages(sessionID), + req, r, sessionID, cfg, accountDB, deviceDB) +} + +// handleApplicationServiceRegistration handles the registration of an +// application service's user by validating the AS from its access token and +// registering the user. Its two first parameters must be the two return values +// of the auth.ExtractAccessToken function. +// Returns an error if the access token couldn't be extracted from the request +// at an earlier step of the registration workflow, or if the provided access +// token doesn't belong to a valid AS, or if there was an issue completing the +// registration process. +func handleApplicationServiceRegistration( + accessToken string, + tokenErr error, + req *http.Request, + r registerRequest, + cfg *config.Dendrite, + accountDB *accounts.Database, + deviceDB *devices.Database, +) util.JSONResponse { + // Check if we previously had issues extracting the access token from the + // request. + if tokenErr != nil { + return util.JSONResponse{ + Code: http.StatusUnauthorized, + JSON: jsonerror.MissingToken(tokenErr.Error()), + } + } + + // Check application service register user request is valid. + // The application service's ID is returned if so. + appserviceID, err := validateApplicationService( + cfg, r.Username, accessToken, + ) + if err != nil { + return *err + } + + // If no error, application service was successfully validated. + // Don't need to worry about appending to registration stages as + // application service registration is entirely separate. + return completeRegistration( + req.Context(), accountDB, deviceDB, r.Username, "", appserviceID, + r.InhibitLogin, r.InitialDisplayName, + ) +} + +// checkAndCompleteFlow checks if a given registration flow is completed given +// a set of allowed flows. If so, registration is completed, otherwise a +// response with +func checkAndCompleteFlow( + flow []authtypes.LoginType, + req *http.Request, + r registerRequest, + sessionID string, + cfg *config.Dendrite, + accountDB *accounts.Database, + deviceDB *devices.Database, +) util.JSONResponse { + if checkFlowCompleted(flow, cfg.Derived.Registration.Flows) { + // This flow was completed, registration can continue + return completeRegistration( + req.Context(), accountDB, deviceDB, r.Username, r.Password, "", + r.InhibitLogin, r.InitialDisplayName, + ) + } + + // There are still more stages to complete. + // Return the flows and those that have been completed. + return util.JSONResponse{ + Code: http.StatusUnauthorized, + JSON: newUserInteractiveResponse(sessionID, + cfg.Derived.Registration.Flows, cfg.Derived.Registration.Params), + } +} + +// LegacyRegister process register requests from the legacy v1 API +func LegacyRegister( + req *http.Request, + accountDB *accounts.Database, + deviceDB *devices.Database, + cfg *config.Dendrite, +) util.JSONResponse { + var r legacyRegisterRequest + resErr := parseAndValidateLegacyLogin(req, &r) + if resErr != nil { + return *resErr + } + + logger := util.GetLogger(req.Context()) + logger.WithFields(log.Fields{ + "username": r.Username, + "auth.type": r.Type, + }).Info("Processing registration request") + + if cfg.Matrix.RegistrationDisabled && r.Type != authtypes.LoginTypeSharedSecret { + return util.MessageResponse(http.StatusForbidden, "Registration has been disabled") + } + + switch r.Type { + case authtypes.LoginTypeSharedSecret: + if cfg.Matrix.RegistrationSharedSecret == "" { + return util.MessageResponse(http.StatusBadRequest, "Shared secret registration is disabled") + } + + valid, err := isValidMacLogin(cfg, r.Username, r.Password, r.Admin, r.Mac) + if err != nil { + return httputil.LogThenError(req, err) + } + + if !valid { + return util.MessageResponse(http.StatusForbidden, "HMAC incorrect") + } + + return completeRegistration(req.Context(), accountDB, deviceDB, r.Username, r.Password, "", false, nil) + case authtypes.LoginTypeDummy: + // there is nothing to do + return completeRegistration(req.Context(), accountDB, deviceDB, r.Username, r.Password, "", false, nil) + default: + return util.JSONResponse{ + Code: http.StatusNotImplemented, + JSON: jsonerror.Unknown("unknown/unimplemented auth type"), + } + } +} + +// parseAndValidateLegacyLogin parses the request into r and checks that the +// request is valid (e.g. valid user names, etc) +func parseAndValidateLegacyLogin(req *http.Request, r *legacyRegisterRequest) *util.JSONResponse { + resErr := httputil.UnmarshalJSONRequest(req, &r) + if resErr != nil { + return resErr + } + + // Squash username to all lowercase letters + r.Username = strings.ToLower(r.Username) + + if resErr = validateUsername(r.Username); resErr != nil { + return resErr + } + if resErr = validatePassword(r.Password); resErr != nil { + return resErr + } + + // All registration requests must specify what auth they are using to perform this request + if r.Type == "" { + return &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("invalid type"), + } + } + + return nil +} + +func completeRegistration( + ctx context.Context, + accountDB *accounts.Database, + deviceDB *devices.Database, + username, password, appserviceID string, + inhibitLogin common.WeakBoolean, + displayName *string, +) util.JSONResponse { + if username == "" { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("missing username"), + } + } + // Blank passwords are only allowed by registered application services + if password == "" && appserviceID == "" { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.BadJSON("missing password"), + } + } + + acc, err := accountDB.CreateAccount(ctx, username, password, appserviceID) + if err != nil { + return util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: jsonerror.Unknown("failed to create account: " + err.Error()), + } + } else if acc == nil { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.UserInUse("Desired user ID is already taken."), + } + } + + // Check whether inhibit_login option is set. If so, don't create an access + // token or a device for this user + if inhibitLogin { + return util.JSONResponse{ + Code: http.StatusOK, + JSON: registerResponse{ + UserID: userutil.MakeUserID(username, acc.ServerName), + HomeServer: acc.ServerName, + }, + } + } + + token, err := auth.GenerateAccessToken() + if err != nil { + return util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: jsonerror.Unknown("Failed to generate access token"), + } + } + + // TODO: Use the device ID in the request. + dev, err := deviceDB.CreateDevice(ctx, username, nil, token, displayName) + if err != nil { + return util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: jsonerror.Unknown("failed to create device: " + err.Error()), + } + } + + // Increment prometheus counter for created users + amtRegUsers.Inc() + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: registerResponse{ + UserID: dev.UserID, + AccessToken: dev.AccessToken, + HomeServer: acc.ServerName, + DeviceID: dev.ID, + }, + } +} + +// Used for shared secret registration. +// Checks if the username, password and isAdmin flag matches the given mac. +func isValidMacLogin( + cfg *config.Dendrite, + username, password string, + isAdmin bool, + givenMac []byte, +) (bool, error) { + sharedSecret := cfg.Matrix.RegistrationSharedSecret + + // Check that shared secret registration isn't disabled. + if cfg.Matrix.RegistrationSharedSecret == "" { + return false, errors.New("Shared secret registration is disabled") + } + + // Double check that username/password don't contain the HMAC delimiters. We should have + // already checked this. + if strings.Contains(username, "\x00") { + return false, errors.New("Username contains invalid character") + } + if strings.Contains(password, "\x00") { + return false, errors.New("Password contains invalid character") + } + if sharedSecret == "" { + return false, errors.New("Shared secret registration is disabled") + } + + adminString := "notadmin" + if isAdmin { + adminString = "admin" + } + joined := strings.Join([]string{username, password, adminString}, "\x00") + + mac := hmac.New(sha1.New, []byte(sharedSecret)) + _, err := mac.Write([]byte(joined)) + if err != nil { + return false, err + } + expectedMAC := mac.Sum(nil) + + return hmac.Equal(givenMac, expectedMAC), nil +} + +// checkFlows checks a single completed flow against another required one. If +// one contains at least all of the stages that the other does, checkFlows +// returns true. +func checkFlows( + completedStages []authtypes.LoginType, + requiredStages []authtypes.LoginType, +) bool { + // Create temporary slices so they originals will not be modified on sorting + completed := make([]authtypes.LoginType, len(completedStages)) + required := make([]authtypes.LoginType, len(requiredStages)) + copy(completed, completedStages) + copy(required, requiredStages) + + // Sort the slices for simple comparison + sort.Slice(completed, func(i, j int) bool { return completed[i] < completed[j] }) + sort.Slice(required, func(i, j int) bool { return required[i] < required[j] }) + + // Iterate through each slice, going to the next required slice only once + // we've found a match. + i, j := 0, 0 + for j < len(required) { + // Exit if we've reached the end of our input without being able to + // match all of the required stages. + if i >= len(completed) { + return false + } + + // If we've found a stage we want, move on to the next required stage. + if completed[i] == required[j] { + j++ + } + i++ + } + return true +} + +// checkFlowCompleted checks if a registration flow complies with any allowed flow +// dictated by the server. Order of stages does not matter. A user may complete +// extra stages as long as the required stages of at least one flow is met. +func checkFlowCompleted( + flow []authtypes.LoginType, + allowedFlows []authtypes.Flow, +) bool { + // Iterate through possible flows to check whether any have been fully completed. + for _, allowedFlow := range allowedFlows { + if checkFlows(flow, allowedFlow.Stages) { + return true + } + } + return false +} + +type availableResponse struct { + Available bool `json:"available"` +} + +// RegisterAvailable checks if the username is already taken or invalid. +func RegisterAvailable( + req *http.Request, + cfg config.Dendrite, + accountDB *accounts.Database, +) util.JSONResponse { + username := req.URL.Query().Get("username") + + // Squash username to all lowercase letters + username = strings.ToLower(username) + + if err := validateUsername(username); err != nil { + return *err + } + + // Check if this username is reserved by an application service + userID := userutil.MakeUserID(username, cfg.Matrix.ServerName) + for _, appservice := range cfg.Derived.ApplicationServices { + if appservice.IsInterestedInUserID(userID) { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.UserInUse("Desired user ID is reserved by an application service."), + } + } + } + + availability, availabilityErr := accountDB.CheckAccountAvailability(req.Context(), username) + if availabilityErr != nil { + return util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: jsonerror.Unknown("failed to check availability: " + availabilityErr.Error()), + } + } + if !availability { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.UserInUse("Desired User ID is already taken."), + } + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: availableResponse{ + Available: true, + }, + } +} diff --git a/clientapi/routing/register_test.go b/clientapi/routing/register_test.go new file mode 100644 index 00000000..6fcf0bc3 --- /dev/null +++ b/clientapi/routing/register_test.go @@ -0,0 +1,209 @@ +// Copyright 2017 Andrew Morgan <andrew@amorgan.xyz> +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "regexp" + "testing" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/common/config" +) + +var ( + // Registration Flows that the server allows. + allowedFlows = []authtypes.Flow{ + { + Stages: []authtypes.LoginType{ + authtypes.LoginType("stage1"), + authtypes.LoginType("stage2"), + }, + }, + { + Stages: []authtypes.LoginType{ + authtypes.LoginType("stage1"), + authtypes.LoginType("stage3"), + }, + }, + } +) + +// Should return true as we're completing all the stages of a single flow in +// order. +func TestFlowCheckingCompleteFlowOrdered(t *testing.T) { + testFlow := []authtypes.LoginType{ + authtypes.LoginType("stage1"), + authtypes.LoginType("stage3"), + } + + if !checkFlowCompleted(testFlow, allowedFlows) { + t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be true.") + } +} + +// Should return false as all stages in a single flow need to be completed. +func TestFlowCheckingStagesFromDifferentFlows(t *testing.T) { + testFlow := []authtypes.LoginType{ + authtypes.LoginType("stage2"), + authtypes.LoginType("stage3"), + } + + if checkFlowCompleted(testFlow, allowedFlows) { + t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be false.") + } +} + +// Should return true as we're completing all the stages from a single flow, as +// well as some extraneous stages. +func TestFlowCheckingCompleteOrderedExtraneous(t *testing.T) { + testFlow := []authtypes.LoginType{ + authtypes.LoginType("stage1"), + authtypes.LoginType("stage3"), + authtypes.LoginType("stage4"), + authtypes.LoginType("stage5"), + } + if !checkFlowCompleted(testFlow, allowedFlows) { + t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be true.") + } +} + +// Should return false as we're submitting an empty flow. +func TestFlowCheckingEmptyFlow(t *testing.T) { + testFlow := []authtypes.LoginType{} + if checkFlowCompleted(testFlow, allowedFlows) { + t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be false.") + } +} + +// Should return false as we've completed a stage that isn't in any allowed flow. +func TestFlowCheckingInvalidStage(t *testing.T) { + testFlow := []authtypes.LoginType{ + authtypes.LoginType("stage8"), + } + if checkFlowCompleted(testFlow, allowedFlows) { + t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be false.") + } +} + +// Should return true as we complete all stages of an allowed flow, though out +// of order, as well as extraneous stages. +func TestFlowCheckingExtraneousUnordered(t *testing.T) { + testFlow := []authtypes.LoginType{ + authtypes.LoginType("stage5"), + authtypes.LoginType("stage4"), + authtypes.LoginType("stage3"), + authtypes.LoginType("stage2"), + authtypes.LoginType("stage1"), + } + if !checkFlowCompleted(testFlow, allowedFlows) { + t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be true.") + } +} + +// Should return false as we're providing fewer stages than are required. +func TestFlowCheckingShortIncorrectInput(t *testing.T) { + testFlow := []authtypes.LoginType{ + authtypes.LoginType("stage8"), + } + if checkFlowCompleted(testFlow, allowedFlows) { + t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be false.") + } +} + +// Should return false as we're providing different stages than are required. +func TestFlowCheckingExtraneousIncorrectInput(t *testing.T) { + testFlow := []authtypes.LoginType{ + authtypes.LoginType("stage8"), + authtypes.LoginType("stage9"), + authtypes.LoginType("stage10"), + authtypes.LoginType("stage11"), + } + if checkFlowCompleted(testFlow, allowedFlows) { + t.Error("Incorrect registration flow verification: ", testFlow, ", from allowed flows: ", allowedFlows, ". Should be false.") + } +} + +// Completed flows stages should always be a valid slice header. +// TestEmptyCompletedFlows checks that sessionsDict returns a slice & not nil. +func TestEmptyCompletedFlows(t *testing.T) { + fakeEmptySessions := newSessionsDict() + fakeSessionID := "aRandomSessionIDWhichDoesNotExist" + ret := fakeEmptySessions.GetCompletedStages(fakeSessionID) + + // check for [] + if ret == nil || len(ret) != 0 { + t.Error("Empty Completed Flow Stages should be a empty slice: returned ", ret, ". Should be []") + } +} + +// This method tests validation of the provided Application Service token and +// username that they're registering +func TestValidationOfApplicationServices(t *testing.T) { + // Set up application service namespaces + regex := "@_appservice_.*" + regexp, err := regexp.Compile(regex) + if err != nil { + t.Errorf("Error compiling regex: %s", regex) + } + + fakeNamespace := config.ApplicationServiceNamespace{ + Exclusive: true, + Regex: regex, + RegexpObject: regexp, + } + + // Create a fake application service + fakeID := "FakeAS" + fakeSenderLocalpart := "_appservice_bot" + fakeApplicationService := config.ApplicationService{ + ID: fakeID, + URL: "null", + ASToken: "1234", + HSToken: "4321", + SenderLocalpart: fakeSenderLocalpart, + NamespaceMap: map[string][]config.ApplicationServiceNamespace{ + "users": {fakeNamespace}, + }, + } + + // Set up a config + fakeConfig := config.Dendrite{} + fakeConfig.Matrix.ServerName = "localhost" + fakeConfig.Derived.ApplicationServices = []config.ApplicationService{fakeApplicationService} + + // Access token is correct, user_id omitted so we are acting as SenderLocalpart + asID, resp := validateApplicationService(&fakeConfig, fakeSenderLocalpart, "1234") + if resp != nil || asID != fakeID { + t.Errorf("appservice should have validated and returned correct ID: %s", resp.JSON) + } + + // Access token is incorrect, user_id omitted so we are acting as SenderLocalpart + asID, resp = validateApplicationService(&fakeConfig, fakeSenderLocalpart, "xxxx") + if resp == nil || asID == fakeID { + t.Errorf("access_token should have been marked as invalid") + } + + // Access token is correct, acting as valid user_id + asID, resp = validateApplicationService(&fakeConfig, "_appservice_bob", "1234") + if resp != nil || asID != fakeID { + t.Errorf("access_token and user_id should've been valid: %s", resp.JSON) + } + + // Access token is correct, acting as invalid user_id + asID, resp = validateApplicationService(&fakeConfig, "_something_else", "1234") + if resp == nil || asID == fakeID { + t.Errorf("user_id should not have been valid: @_something_else:localhost") + } +} diff --git a/clientapi/routing/routing.go b/clientapi/routing/routing.go new file mode 100644 index 00000000..b0ced79e --- /dev/null +++ b/clientapi/routing/routing.go @@ -0,0 +1,413 @@ +// Copyright 2017 Vector Creations Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/gorilla/mux" + appserviceAPI "github.com/matrix-org/dendrite/appservice/api" + "github.com/matrix-org/dendrite/clientapi/auth" + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts" + "github.com/matrix-org/dendrite/clientapi/auth/storage/devices" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/clientapi/producers" + "github.com/matrix-org/dendrite/common" + "github.com/matrix-org/dendrite/common/config" + "github.com/matrix-org/dendrite/common/transactions" + roomserverAPI "github.com/matrix-org/dendrite/roomserver/api" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" +) + +const pathPrefixV1 = "/_matrix/client/api/v1" +const pathPrefixR0 = "/_matrix/client/r0" +const pathPrefixUnstable = "/_matrix/client/unstable" + +// Setup registers HTTP handlers with the given ServeMux. It also supplies the given http.Client +// to clients which need to make outbound HTTP requests. +func Setup( + apiMux *mux.Router, cfg config.Dendrite, + producer *producers.RoomserverProducer, + queryAPI roomserverAPI.RoomserverQueryAPI, + aliasAPI roomserverAPI.RoomserverAliasAPI, + asAPI appserviceAPI.AppServiceQueryAPI, + accountDB *accounts.Database, + deviceDB *devices.Database, + federation *gomatrixserverlib.FederationClient, + keyRing gomatrixserverlib.KeyRing, + userUpdateProducer *producers.UserUpdateProducer, + syncProducer *producers.SyncAPIProducer, + typingProducer *producers.TypingServerProducer, + transactionsCache *transactions.Cache, +) { + + apiMux.Handle("/_matrix/client/versions", + common.MakeExternalAPI("versions", func(req *http.Request) util.JSONResponse { + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct { + Versions []string `json:"versions"` + }{[]string{ + "r0.0.1", + "r0.1.0", + "r0.2.0", + "r0.3.0", + }}, + } + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux := apiMux.PathPrefix(pathPrefixR0).Subrouter() + v1mux := apiMux.PathPrefix(pathPrefixV1).Subrouter() + unstableMux := apiMux.PathPrefix(pathPrefixUnstable).Subrouter() + + authData := auth.Data{ + AccountDB: accountDB, + DeviceDB: deviceDB, + AppServices: cfg.Derived.ApplicationServices, + } + + r0mux.Handle("/createRoom", + common.MakeAuthAPI("createRoom", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + return CreateRoom(req, device, cfg, producer, accountDB, aliasAPI, asAPI) + }), + ).Methods(http.MethodPost, http.MethodOptions) + r0mux.Handle("/join/{roomIDOrAlias}", + common.MakeAuthAPI("join", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return JoinRoomByIDOrAlias( + req, device, vars["roomIDOrAlias"], cfg, federation, producer, queryAPI, aliasAPI, keyRing, accountDB, + ) + }), + ).Methods(http.MethodPost, http.MethodOptions) + r0mux.Handle("/rooms/{roomID}/{membership:(?:join|kick|ban|unban|leave|invite)}", + common.MakeAuthAPI("membership", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return SendMembership(req, accountDB, device, vars["roomID"], vars["membership"], cfg, queryAPI, asAPI, producer) + }), + ).Methods(http.MethodPost, http.MethodOptions) + r0mux.Handle("/rooms/{roomID}/send/{eventType}", + common.MakeAuthAPI("send_message", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return SendEvent(req, device, vars["roomID"], vars["eventType"], nil, nil, cfg, queryAPI, producer, nil) + }), + ).Methods(http.MethodPost, http.MethodOptions) + r0mux.Handle("/rooms/{roomID}/send/{eventType}/{txnID}", + common.MakeAuthAPI("send_message", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + txnID := vars["txnID"] + return SendEvent(req, device, vars["roomID"], vars["eventType"], &txnID, + nil, cfg, queryAPI, producer, transactionsCache) + }), + ).Methods(http.MethodPut, http.MethodOptions) + r0mux.Handle("/rooms/{roomID}/state/{eventType:[^/]+/?}", + common.MakeAuthAPI("send_message", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + emptyString := "" + eventType := vars["eventType"] + // If there's a trailing slash, remove it + if strings.HasSuffix(eventType, "/") { + eventType = eventType[:len(eventType)-1] + } + return SendEvent(req, device, vars["roomID"], eventType, nil, &emptyString, cfg, queryAPI, producer, nil) + }), + ).Methods(http.MethodPut, http.MethodOptions) + r0mux.Handle("/rooms/{roomID}/state/{eventType}/{stateKey}", + common.MakeAuthAPI("send_message", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + stateKey := vars["stateKey"] + return SendEvent(req, device, vars["roomID"], vars["eventType"], nil, &stateKey, cfg, queryAPI, producer, nil) + }), + ).Methods(http.MethodPut, http.MethodOptions) + + r0mux.Handle("/register", common.MakeExternalAPI("register", func(req *http.Request) util.JSONResponse { + return Register(req, accountDB, deviceDB, &cfg) + })).Methods(http.MethodPost, http.MethodOptions) + + v1mux.Handle("/register", common.MakeExternalAPI("register", func(req *http.Request) util.JSONResponse { + return LegacyRegister(req, accountDB, deviceDB, &cfg) + })).Methods(http.MethodPost, http.MethodOptions) + + r0mux.Handle("/register/available", common.MakeExternalAPI("registerAvailable", func(req *http.Request) util.JSONResponse { + return RegisterAvailable(req, cfg, accountDB) + })).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/directory/room/{roomAlias}", + common.MakeExternalAPI("directory_room", func(req *http.Request) util.JSONResponse { + vars := mux.Vars(req) + return DirectoryRoom(req, vars["roomAlias"], federation, &cfg, aliasAPI) + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/directory/room/{roomAlias}", + common.MakeAuthAPI("directory_room", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return SetLocalAlias(req, device, vars["roomAlias"], &cfg, aliasAPI) + }), + ).Methods(http.MethodPut, http.MethodOptions) + + r0mux.Handle("/directory/room/{roomAlias}", + common.MakeAuthAPI("directory_room", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return RemoveLocalAlias(req, device, vars["roomAlias"], aliasAPI) + }), + ).Methods(http.MethodDelete, http.MethodOptions) + + r0mux.Handle("/logout", + common.MakeAuthAPI("logout", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + return Logout(req, deviceDB, device) + }), + ).Methods(http.MethodPost, http.MethodOptions) + + r0mux.Handle("/logout/all", + common.MakeAuthAPI("logout", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + return LogoutAll(req, deviceDB, device) + }), + ).Methods(http.MethodPost, http.MethodOptions) + + r0mux.Handle("/rooms/{roomID}/typing/{userID}", + common.MakeAuthAPI("rooms_typing", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return SendTyping(req, device, vars["roomID"], vars["userID"], accountDB, typingProducer) + }), + ).Methods(http.MethodPut, http.MethodOptions) + + r0mux.Handle("/account/whoami", + common.MakeAuthAPI("whoami", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + return Whoami(req, device) + }), + ).Methods(http.MethodGet, http.MethodOptions) + + // Stub endpoints required by Riot + + r0mux.Handle("/login", + common.MakeExternalAPI("login", func(req *http.Request) util.JSONResponse { + return Login(req, accountDB, deviceDB, cfg) + }), + ).Methods(http.MethodGet, http.MethodPost, http.MethodOptions) + + r0mux.Handle("/pushrules/", + common.MakeExternalAPI("push_rules", func(req *http.Request) util.JSONResponse { + // TODO: Implement push rules API + res := json.RawMessage(`{ + "global": { + "content": [], + "override": [], + "room": [], + "sender": [], + "underride": [] + } + }`) + return util.JSONResponse{ + Code: http.StatusOK, + JSON: &res, + } + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/user/{userId}/filter", + common.MakeAuthAPI("put_filter", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return PutFilter(req, device, accountDB, vars["userId"]) + }), + ).Methods(http.MethodPost, http.MethodOptions) + + r0mux.Handle("/user/{userId}/filter/{filterId}", + common.MakeAuthAPI("get_filter", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return GetFilter(req, device, accountDB, vars["userId"], vars["filterId"]) + }), + ).Methods(http.MethodGet, http.MethodOptions) + + // Riot user settings + + r0mux.Handle("/profile/{userID}", + common.MakeExternalAPI("profile", func(req *http.Request) util.JSONResponse { + vars := mux.Vars(req) + return GetProfile(req, accountDB, vars["userID"], asAPI) + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/profile/{userID}/avatar_url", + common.MakeExternalAPI("profile_avatar_url", func(req *http.Request) util.JSONResponse { + vars := mux.Vars(req) + return GetAvatarURL(req, accountDB, vars["userID"], asAPI) + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/profile/{userID}/avatar_url", + common.MakeAuthAPI("profile_avatar_url", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return SetAvatarURL(req, accountDB, device, vars["userID"], userUpdateProducer, &cfg, producer, queryAPI) + }), + ).Methods(http.MethodPut, http.MethodOptions) + // Browsers use the OPTIONS HTTP method to check if the CORS policy allows + // PUT requests, so we need to allow this method + + r0mux.Handle("/profile/{userID}/displayname", + common.MakeExternalAPI("profile_displayname", func(req *http.Request) util.JSONResponse { + vars := mux.Vars(req) + return GetDisplayName(req, accountDB, vars["userID"], asAPI) + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/profile/{userID}/displayname", + common.MakeAuthAPI("profile_displayname", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return SetDisplayName(req, accountDB, device, vars["userID"], userUpdateProducer, &cfg, producer, queryAPI) + }), + ).Methods(http.MethodPut, http.MethodOptions) + // Browsers use the OPTIONS HTTP method to check if the CORS policy allows + // PUT requests, so we need to allow this method + + r0mux.Handle("/account/3pid", + common.MakeAuthAPI("account_3pid", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + return GetAssociated3PIDs(req, accountDB, device) + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/account/3pid", + common.MakeAuthAPI("account_3pid", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + return CheckAndSave3PIDAssociation(req, accountDB, device, cfg) + }), + ).Methods(http.MethodPost, http.MethodOptions) + + unstableMux.Handle("/account/3pid/delete", + common.MakeAuthAPI("account_3pid", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + return Forget3PID(req, accountDB) + }), + ).Methods(http.MethodPost, http.MethodOptions) + + r0mux.Handle("/{path:(?:account/3pid|register)}/email/requestToken", + common.MakeExternalAPI("account_3pid_request_token", func(req *http.Request) util.JSONResponse { + return RequestEmailToken(req, accountDB, cfg) + }), + ).Methods(http.MethodPost, http.MethodOptions) + + // Riot logs get flooded unless this is handled + r0mux.Handle("/presence/{userID}/status", + common.MakeExternalAPI("presence", func(req *http.Request) util.JSONResponse { + // TODO: Set presence (probably the responsibility of a presence server not clientapi) + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } + }), + ).Methods(http.MethodPut, http.MethodOptions) + + r0mux.Handle("/voip/turnServer", + common.MakeAuthAPI("turn_server", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + return RequestTurnServer(req, device, cfg) + }), + ).Methods(http.MethodGet, http.MethodOptions) + + unstableMux.Handle("/thirdparty/protocols", + common.MakeExternalAPI("thirdparty_protocols", func(req *http.Request) util.JSONResponse { + // TODO: Return the third party protcols + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/rooms/{roomID}/initialSync", + common.MakeExternalAPI("rooms_initial_sync", func(req *http.Request) util.JSONResponse { + // TODO: Allow people to peek into rooms. + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.GuestAccessForbidden("Guest access not implemented"), + } + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/user/{userID}/account_data/{type}", + common.MakeAuthAPI("user_account_data", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return SaveAccountData(req, accountDB, device, vars["userID"], "", vars["type"], syncProducer) + }), + ).Methods(http.MethodPut, http.MethodOptions) + + r0mux.Handle("/user/{userID}/rooms/{roomID}/account_data/{type}", + common.MakeAuthAPI("user_account_data", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return SaveAccountData(req, accountDB, device, vars["userID"], vars["roomID"], vars["type"], syncProducer) + }), + ).Methods(http.MethodPut, http.MethodOptions) + + r0mux.Handle("/rooms/{roomID}/members", + common.MakeAuthAPI("rooms_members", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return GetMemberships(req, device, vars["roomID"], false, cfg, queryAPI) + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/rooms/{roomID}/joined_members", + common.MakeAuthAPI("rooms_members", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return GetMemberships(req, device, vars["roomID"], true, cfg, queryAPI) + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/rooms/{roomID}/read_markers", + common.MakeExternalAPI("rooms_read_markers", func(req *http.Request) util.JSONResponse { + // TODO: return the read_markers. + return util.JSONResponse{Code: http.StatusOK, JSON: struct{}{}} + }), + ).Methods(http.MethodPost, http.MethodOptions) + + r0mux.Handle("/devices", + common.MakeAuthAPI("get_devices", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + return GetDevicesByLocalpart(req, deviceDB, device) + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/devices/{deviceID}", + common.MakeAuthAPI("get_device", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return GetDeviceByID(req, deviceDB, device, vars["deviceID"]) + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/devices/{deviceID}", + common.MakeAuthAPI("device_data", authData, func(req *http.Request, device *authtypes.Device) util.JSONResponse { + vars := mux.Vars(req) + return UpdateDeviceByID(req, deviceDB, device, vars["deviceID"]) + }), + ).Methods(http.MethodPut, http.MethodOptions) + + // Stub implementations for sytest + r0mux.Handle("/events", + common.MakeExternalAPI("events", func(req *http.Request) util.JSONResponse { + return util.JSONResponse{Code: http.StatusOK, JSON: map[string]interface{}{ + "chunk": []interface{}{}, + "start": "", + "end": "", + }} + }), + ).Methods(http.MethodGet, http.MethodOptions) + + r0mux.Handle("/initialSync", + common.MakeExternalAPI("initial_sync", func(req *http.Request) util.JSONResponse { + return util.JSONResponse{Code: http.StatusOK, JSON: map[string]interface{}{ + "end": "", + }} + }), + ).Methods(http.MethodGet, http.MethodOptions) +} diff --git a/clientapi/routing/sendevent.go b/clientapi/routing/sendevent.go new file mode 100644 index 00000000..e916e451 --- /dev/null +++ b/clientapi/routing/sendevent.go @@ -0,0 +1,153 @@ +// Copyright 2017 Vector Creations Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "net/http" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/clientapi/producers" + "github.com/matrix-org/dendrite/common" + "github.com/matrix-org/dendrite/common/config" + "github.com/matrix-org/dendrite/common/transactions" + "github.com/matrix-org/dendrite/roomserver/api" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" +) + +// http://matrix.org/docs/spec/client_server/r0.2.0.html#put-matrix-client-r0-rooms-roomid-send-eventtype-txnid +// http://matrix.org/docs/spec/client_server/r0.2.0.html#put-matrix-client-r0-rooms-roomid-state-eventtype-statekey +type sendEventResponse struct { + EventID string `json:"event_id"` +} + +// SendEvent implements: +// /rooms/{roomID}/send/{eventType} +// /rooms/{roomID}/send/{eventType}/{txnID} +// /rooms/{roomID}/state/{eventType}/{stateKey} +func SendEvent( + req *http.Request, + device *authtypes.Device, + roomID, eventType string, txnID, stateKey *string, + cfg config.Dendrite, + queryAPI api.RoomserverQueryAPI, + producer *producers.RoomserverProducer, + txnCache *transactions.Cache, +) util.JSONResponse { + if txnID != nil { + // Try to fetch response from transactionsCache + if res, ok := txnCache.FetchTransaction(*txnID); ok { + return *res + } + } + + e, resErr := generateSendEvent(req, device, roomID, eventType, stateKey, cfg, queryAPI) + if resErr != nil { + return *resErr + } + + var txnAndDeviceID *api.TransactionID + if txnID != nil { + txnAndDeviceID = &api.TransactionID{ + TransactionID: *txnID, + DeviceID: device.ID, + } + } + + // pass the new event to the roomserver and receive the correct event ID + // event ID in case of duplicate transaction is discarded + eventID, err := producer.SendEvents( + req.Context(), []gomatrixserverlib.Event{*e}, cfg.Matrix.ServerName, txnAndDeviceID, + ) + if err != nil { + return httputil.LogThenError(req, err) + } + + res := util.JSONResponse{ + Code: http.StatusOK, + JSON: sendEventResponse{eventID}, + } + // Add response to transactionsCache + if txnID != nil { + txnCache.AddTransaction(*txnID, &res) + } + + return res +} + +func generateSendEvent( + req *http.Request, + device *authtypes.Device, + roomID, eventType string, stateKey *string, + cfg config.Dendrite, + queryAPI api.RoomserverQueryAPI, +) (*gomatrixserverlib.Event, *util.JSONResponse) { + // parse the incoming http request + userID := device.UserID + var r map[string]interface{} // must be a JSON object + resErr := httputil.UnmarshalJSONRequest(req, &r) + if resErr != nil { + return nil, resErr + } + + evTime, err := httputil.ParseTSParam(req) + if err != nil { + return nil, &util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.InvalidArgumentValue(err.Error()), + } + } + + // create the new event and set all the fields we can + builder := gomatrixserverlib.EventBuilder{ + Sender: userID, + RoomID: roomID, + Type: eventType, + StateKey: stateKey, + } + err = builder.SetContent(r) + if err != nil { + resErr := httputil.LogThenError(req, err) + return nil, &resErr + } + + var queryRes api.QueryLatestEventsAndStateResponse + e, err := common.BuildEvent(req.Context(), &builder, cfg, evTime, queryAPI, &queryRes) + if err == common.ErrRoomNoExists { + return nil, &util.JSONResponse{ + Code: http.StatusNotFound, + JSON: jsonerror.NotFound("Room does not exist"), + } + } else if err != nil { + resErr := httputil.LogThenError(req, err) + return nil, &resErr + } + + // check to see if this user can perform this operation + stateEvents := make([]*gomatrixserverlib.Event, len(queryRes.StateEvents)) + for i := range queryRes.StateEvents { + stateEvents[i] = &queryRes.StateEvents[i] + } + provider := gomatrixserverlib.NewAuthEvents(stateEvents) + if err = gomatrixserverlib.Allowed(*e, &provider); err != nil { + return nil, &util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden(err.Error()), // TODO: Is this error string comprehensible to the client? + } + } + return e, nil +} diff --git a/clientapi/routing/sendtyping.go b/clientapi/routing/sendtyping.go new file mode 100644 index 00000000..561a2d89 --- /dev/null +++ b/clientapi/routing/sendtyping.go @@ -0,0 +1,80 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "database/sql" + "net/http" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/clientapi/producers" + "github.com/matrix-org/dendrite/clientapi/userutil" + "github.com/matrix-org/util" +) + +type typingContentJSON struct { + Typing bool `json:"typing"` + Timeout int64 `json:"timeout"` +} + +// SendTyping handles PUT /rooms/{roomID}/typing/{userID} +// sends the typing events to client API typingProducer +func SendTyping( + req *http.Request, device *authtypes.Device, roomID string, + userID string, accountDB *accounts.Database, + typingProducer *producers.TypingServerProducer, +) util.JSONResponse { + if device.UserID != userID { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("Cannot set another user's typing state"), + } + } + + localpart, err := userutil.ParseUsernameParam(userID, nil) + if err != nil { + return httputil.LogThenError(req, err) + } + + // Verify that the user is a member of this room + _, err = accountDB.GetMembershipInRoomByLocalpart(req.Context(), localpart, roomID) + if err == sql.ErrNoRows { + return util.JSONResponse{ + Code: http.StatusForbidden, + JSON: jsonerror.Forbidden("User not in this room"), + } + } else if err != nil { + return httputil.LogThenError(req, err) + } + + // parse the incoming http request + var r typingContentJSON + resErr := httputil.UnmarshalJSONRequest(req, &r) + if resErr != nil { + return *resErr + } + + if err = typingProducer.Send( + req.Context(), userID, roomID, r.Typing, r.Timeout, + ); err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} diff --git a/clientapi/routing/threepid.go b/clientapi/routing/threepid.go new file mode 100644 index 00000000..897d13b6 --- /dev/null +++ b/clientapi/routing/threepid.go @@ -0,0 +1,178 @@ +// Copyright 2017 Vector Creations Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "net/http" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/auth/storage/accounts" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/clientapi/threepid" + "github.com/matrix-org/dendrite/common/config" + + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" +) + +type reqTokenResponse struct { + SID string `json:"sid"` +} + +type threePIDsResponse struct { + ThreePIDs []authtypes.ThreePID `json:"threepids"` +} + +// RequestEmailToken implements: +// POST /account/3pid/email/requestToken +// POST /register/email/requestToken +func RequestEmailToken(req *http.Request, accountDB *accounts.Database, cfg config.Dendrite) util.JSONResponse { + var body threepid.EmailAssociationRequest + if reqErr := httputil.UnmarshalJSONRequest(req, &body); reqErr != nil { + return *reqErr + } + + var resp reqTokenResponse + var err error + + // Check if the 3PID is already in use locally + localpart, err := accountDB.GetLocalpartForThreePID(req.Context(), body.Email, "email") + if err != nil { + return httputil.LogThenError(req, err) + } + + if len(localpart) > 0 { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.MatrixError{ + ErrCode: "M_THREEPID_IN_USE", + Err: accounts.Err3PIDInUse.Error(), + }, + } + } + + resp.SID, err = threepid.CreateSession(req.Context(), body, cfg) + if err == threepid.ErrNotTrusted { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.NotTrusted(body.IDServer), + } + } else if err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: resp, + } +} + +// CheckAndSave3PIDAssociation implements POST /account/3pid +func CheckAndSave3PIDAssociation( + req *http.Request, accountDB *accounts.Database, device *authtypes.Device, + cfg config.Dendrite, +) util.JSONResponse { + var body threepid.EmailAssociationCheckRequest + if reqErr := httputil.UnmarshalJSONRequest(req, &body); reqErr != nil { + return *reqErr + } + + // Check if the association has been validated + verified, address, medium, err := threepid.CheckAssociation(req.Context(), body.Creds, cfg) + if err == threepid.ErrNotTrusted { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.NotTrusted(body.Creds.IDServer), + } + } else if err != nil { + return httputil.LogThenError(req, err) + } + + if !verified { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.MatrixError{ + ErrCode: "M_THREEPID_AUTH_FAILED", + Err: "Failed to auth 3pid", + }, + } + } + + if body.Bind { + // Publish the association on the identity server if requested + err = threepid.PublishAssociation(body.Creds, device.UserID, cfg) + if err == threepid.ErrNotTrusted { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.NotTrusted(body.Creds.IDServer), + } + } else if err != nil { + return httputil.LogThenError(req, err) + } + } + + // Save the association in the database + localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID) + if err != nil { + return httputil.LogThenError(req, err) + } + + if err = accountDB.SaveThreePIDAssociation(req.Context(), address, localpart, medium); err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} + +// GetAssociated3PIDs implements GET /account/3pid +func GetAssociated3PIDs( + req *http.Request, accountDB *accounts.Database, device *authtypes.Device, +) util.JSONResponse { + localpart, _, err := gomatrixserverlib.SplitID('@', device.UserID) + if err != nil { + return httputil.LogThenError(req, err) + } + + threepids, err := accountDB.GetThreePIDsForLocalpart(req.Context(), localpart) + if err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: threePIDsResponse{threepids}, + } +} + +// Forget3PID implements POST /account/3pid/delete +func Forget3PID(req *http.Request, accountDB *accounts.Database) util.JSONResponse { + var body authtypes.ThreePID + if reqErr := httputil.UnmarshalJSONRequest(req, &body); reqErr != nil { + return *reqErr + } + + if err := accountDB.RemoveThreePIDAssociation(req.Context(), body.Address, body.Medium); err != nil { + return httputil.LogThenError(req, err) + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } +} diff --git a/clientapi/routing/voip.go b/clientapi/routing/voip.go new file mode 100644 index 00000000..b9121633 --- /dev/null +++ b/clientapi/routing/voip.go @@ -0,0 +1,78 @@ +// Copyright 2017 Michael Telatysnki <7t3chguy@gmail.com> +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "fmt" + "net/http" + "time" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/clientapi/httputil" + "github.com/matrix-org/dendrite/common/config" + "github.com/matrix-org/gomatrix" + "github.com/matrix-org/util" +) + +// RequestTurnServer implements: +// GET /voip/turnServer +func RequestTurnServer(req *http.Request, device *authtypes.Device, cfg config.Dendrite) util.JSONResponse { + turnConfig := cfg.TURN + + // TODO Guest Support + if len(turnConfig.URIs) == 0 || turnConfig.UserLifetime == "" { + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } + } + + // Duration checked at startup, err not possible + duration, _ := time.ParseDuration(turnConfig.UserLifetime) + + resp := gomatrix.RespTurnServer{ + URIs: turnConfig.URIs, + TTL: int(duration.Seconds()), + } + + if turnConfig.SharedSecret != "" { + expiry := time.Now().Add(duration).Unix() + mac := hmac.New(sha1.New, []byte(turnConfig.SharedSecret)) + _, err := mac.Write([]byte(resp.Username)) + + if err != nil { + return httputil.LogThenError(req, err) + } + + resp.Username = fmt.Sprintf("%d:%s", expiry, device.UserID) + resp.Password = base64.StdEncoding.EncodeToString(mac.Sum(nil)) + } else if turnConfig.Username != "" && turnConfig.Password != "" { + resp.Username = turnConfig.Username + resp.Password = turnConfig.Password + } else { + return util.JSONResponse{ + Code: http.StatusOK, + JSON: struct{}{}, + } + } + + return util.JSONResponse{ + Code: http.StatusOK, + JSON: resp, + } +} diff --git a/clientapi/routing/whoami.go b/clientapi/routing/whoami.go new file mode 100644 index 00000000..840bcb5f --- /dev/null +++ b/clientapi/routing/whoami.go @@ -0,0 +1,34 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "net/http" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/util" +) + +// whoamiResponse represents an response for a `whoami` request +type whoamiResponse struct { + UserID string `json:"user_id"` +} + +// Whoami implements `/account/whoami` which enables client to query their account user id. +// https://matrix.org/docs/spec/client_server/r0.3.0.html#get-matrix-client-r0-account-whoami +func Whoami(req *http.Request, device *authtypes.Device) util.JSONResponse { + return util.JSONResponse{ + Code: http.StatusOK, + JSON: whoamiResponse{UserID: device.UserID}, + } +} |