package clientapi import ( "errors" "net/http" "github.com/dariuszjaniak/warp/server/internal/httputil" "github.com/dariuszjaniak/warp/server/internal/storage" "github.com/dariuszjaniak/warp/server/internal/userapi" ) // deviceResponse is one entry of the device list. type deviceResponse struct { DeviceID string `json:"device_id"` DisplayName string `json:"display_name,omitempty"` // LastSeenIP and LastSeenTS are omitted until the device has been seen, // which for a freshly created one is the same request that created it. LastSeenIP string `json:"last_seen_ip,omitempty"` LastSeenTS int64 `json:"last_seen_ts,omitempty"` } type devicesResponse struct { Devices []deviceResponse `json:"devices"` } func newDeviceResponse(d storage.Device) deviceResponse { return deviceResponse{ DeviceID: d.ID, DisplayName: d.DisplayName, LastSeenIP: d.LastSeenIP, LastSeenTS: d.LastSeenMS, } } // handleGetDevices implements GET /_matrix/client/v3/devices. func (c *ClientAPI) handleGetDevices(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { devices, err := c.users.Devices(r.Context(), auth.Localpart) if err != nil { return err } out := make([]deviceResponse, 0, len(devices)) for _, d := range devices { out = append(out, newDeviceResponse(d)) } httputil.WriteJSON(w, http.StatusOK, devicesResponse{Devices: out}) return nil } // handleGetDevice implements GET /_matrix/client/v3/devices/{deviceId}. func (c *ClientAPI) handleGetDevice(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { device, err := c.users.Device(r.Context(), auth.Localpart, r.PathValue("deviceID")) switch { case errors.Is(err, userapi.ErrNoSuchDevice): return httputil.NotFound("Unknown device") case err != nil: return err } httputil.WriteJSON(w, http.StatusOK, newDeviceResponse(device)) return nil } // putDeviceRequest is the body of PUT /devices/{deviceId}. type putDeviceRequest struct { DisplayName string `json:"display_name"` } // handlePutDevice implements PUT /_matrix/client/v3/devices/{deviceId}. func (c *ClientAPI) handlePutDevice(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { var req putDeviceRequest if err := httputil.DecodeJSON(w, r, &req); err != nil { return err } err := c.users.SetDeviceDisplayName(r.Context(), auth.Localpart, r.PathValue("deviceID"), req.DisplayName) switch { case errors.Is(err, userapi.ErrNoSuchDevice): return httputil.NotFound("Unknown device") case err != nil: return err } httputil.WriteJSON(w, http.StatusOK, struct{}{}) return nil } // deleteDeviceRequest is the body of DELETE /devices/{deviceId}: nothing but // the authentication. type deleteDeviceRequest struct { Auth *uiaRequest `json:"auth"` } // deleteDevicesRequest is the body of POST /delete_devices. type deleteDevicesRequest struct { Devices []string `json:"devices"` Auth *uiaRequest `json:"auth"` } // handleDeleteDevice implements DELETE /_matrix/client/v3/devices/{deviceId}. func (c *ClientAPI) handleDeleteDevice(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { var req deleteDeviceRequest // The body may be absent: a client's first attempt carries no // authentication, because the 401 is how it learns the endpoint needs // any. if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil { return err } return c.deleteDevicesWithUIA(w, r, auth, req.Auth, []string{r.PathValue("deviceID")}) } // handleDeleteDevices implements POST /_matrix/client/v3/delete_devices. func (c *ClientAPI) handleDeleteDevices(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { var req deleteDevicesRequest if err := httputil.DecodeJSON(w, r, &req); err != nil { return err } return c.deleteDevicesWithUIA(w, r, auth, req.Auth, req.Devices) } // deleteDevicesWithUIA is the shared half of the two device-deletion // endpoints: both ask the user to re-enter their password first. // // Revoking a device logs out whoever holds its token, so a stolen access // token must not be enough to do it — that is exactly the position a victim // is in when they go to remove the attacker's session. func (c *ClientAPI) deleteDevicesWithUIA( w http.ResponseWriter, r *http.Request, auth userapi.Authenticated, authReq *uiaRequest, deviceIDs []string, ) error { flows := []uiaFlow{{Stages: []string{stagePassword}}} sessionID, done, err := c.beginOrAdvanceUIA(w, flows, authReq, func(_ string, stage *uiaRequest) (*httputil.MatrixError, error) { return c.passwordStage(r, auth, stage) }) if err != nil || !done { return err } if err := c.users.DeleteDevices(r.Context(), auth.Localpart, deviceIDs); err != nil { return err } c.uia.remove(sessionID) httputil.WriteJSON(w, http.StatusOK, struct{}{}) return nil } // passwordStage checks an m.login.password stage of an interactive // authentication. // // The identity in the stage must be the user making the request. Without // that check, knowing *any* account's password would be enough to pass the // re-authentication on somebody else's session — which is the flaw the // stage exists to prevent, and which Complement tests for directly. func (c *ClientAPI) passwordStage(r *http.Request, auth userapi.Authenticated, stage *uiaRequest) (*httputil.MatrixError, error) { identifier := stage.Identifier.User switch { case identifier != "" && stage.Identifier.Type != identifierUser: return httputil.InvalidParam("Login identifier type %q is not supported", stage.Identifier.Type), nil case identifier == "": identifier = stage.User } if identifier == "" { return httputil.MissingParam("A user identifier is required"), nil } localpart, err := c.users.ParseLocalUserID(identifier) if err != nil { return httputil.Forbidden("Invalid username or password"), nil } if localpart != auth.Localpart { // Not a failed stage but a refusal: no password will make this // request acceptable, so it ends here with a 403 instead of // inviting another attempt. return nil, httputil.Forbidden( "The user in the authentication must be the one making the request") } switch err := c.users.CheckPassword(r.Context(), localpart, stage.Password); { case err == nil: return nil, nil case errors.Is(err, userapi.ErrWrongPassword), errors.Is(err, userapi.ErrNoPassword): return httputil.Forbidden("Invalid username or password"), nil default: return nil, err } }