aboutsummaryrefslogtreecommitdiff
path: root/clientapi/auth
diff options
context:
space:
mode:
authorKegsay <kegan@matrix.org>2020-07-10 00:39:44 +0100
committerGitHub <noreply@github.com>2020-07-10 00:39:44 +0100
commitabf26c12f1a97fd2894a0509de9cf4a91c79d3ab (patch)
tree13b693141a36bfc4ae2ef2759e67dca92b2fc857 /clientapi/auth
parent9cc52f47f3ea2e8a009731cc46117cb996aed722 (diff)
Add User-Interactive Authentication (#1193)
* Add User-Interactive Authentication And use it when deleting a device. With tests. * Make remaining sytest pass * Linting * 403 not 401 on wrong user/pass
Diffstat (limited to 'clientapi/auth')
-rw-r--r--clientapi/auth/password.go75
-rw-r--r--clientapi/auth/user_interactive.go248
-rw-r--r--clientapi/auth/user_interactive_test.go174
3 files changed, 497 insertions, 0 deletions
diff --git a/clientapi/auth/password.go b/clientapi/auth/password.go
new file mode 100644
index 00000000..f4814925
--- /dev/null
+++ b/clientapi/auth/password.go
@@ -0,0 +1,75 @@
+// Copyright 2020 The Matrix.org Foundation C.I.C.
+//
+// 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 auth
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/clientapi/userutil"
+ "github.com/matrix-org/dendrite/internal/config"
+ "github.com/matrix-org/dendrite/userapi/api"
+ "github.com/matrix-org/util"
+)
+
+type GetAccountByPassword func(ctx context.Context, localpart, password string) (*api.Account, error)
+
+type PasswordRequest struct {
+ Login
+ Password string `json:"password"`
+}
+
+// LoginTypePassword implements https://matrix.org/docs/spec/client_server/r0.6.1#password-based
+type LoginTypePassword struct {
+ GetAccountByPassword GetAccountByPassword
+ Config *config.Dendrite
+}
+
+func (t *LoginTypePassword) Name() string {
+ return "m.login.password"
+}
+
+func (t *LoginTypePassword) Request() interface{} {
+ return &PasswordRequest{}
+}
+
+func (t *LoginTypePassword) Login(ctx context.Context, req interface{}) (*Login, *util.JSONResponse) {
+ r := req.(*PasswordRequest)
+ username := r.Username()
+ if username == "" {
+ return nil, &util.JSONResponse{
+ Code: http.StatusUnauthorized,
+ JSON: jsonerror.BadJSON("'user' must be supplied."),
+ }
+ }
+ localpart, err := userutil.ParseUsernameParam(username, &t.Config.Matrix.ServerName)
+ if err != nil {
+ return nil, &util.JSONResponse{
+ Code: http.StatusUnauthorized,
+ JSON: jsonerror.InvalidUsername(err.Error()),
+ }
+ }
+ _, err = t.GetAccountByPassword(ctx, 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 nil, &util.JSONResponse{
+ Code: http.StatusForbidden,
+ JSON: jsonerror.Forbidden("username or password was incorrect, or the account does not exist"),
+ }
+ }
+ return &r.Login, nil
+}
diff --git a/clientapi/auth/user_interactive.go b/clientapi/auth/user_interactive.go
new file mode 100644
index 00000000..581a85f0
--- /dev/null
+++ b/clientapi/auth/user_interactive.go
@@ -0,0 +1,248 @@
+// Copyright 2020 The Matrix.org Foundation C.I.C.
+//
+// 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 auth
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+
+ "github.com/matrix-org/dendrite/clientapi/jsonerror"
+ "github.com/matrix-org/dendrite/internal/config"
+ "github.com/matrix-org/dendrite/userapi/api"
+ "github.com/matrix-org/util"
+ "github.com/sirupsen/logrus"
+ "github.com/tidwall/gjson"
+)
+
+// Type represents an auth type
+// https://matrix.org/docs/spec/client_server/r0.6.1#authentication-types
+type Type interface {
+ // Name returns the name of the auth type e.g `m.login.password`
+ Name() string
+ // Request returns a pointer to a new request body struct to unmarshal into.
+ Request() interface{}
+ // Login with the auth type, returning an error response on failure.
+ // Not all types support login, only m.login.password and m.login.token
+ // See https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-login
+ // `req` is guaranteed to be the type returned from Request()
+ // This function will be called when doing login and when doing 'sudo' style
+ // actions e.g deleting devices. The response must be a 401 as per:
+ // "If the homeserver decides that an attempt on a stage was unsuccessful, but the
+ // client may make a second attempt, it returns the same HTTP status 401 response as above,
+ // with the addition of the standard errcode and error fields describing the error."
+ Login(ctx context.Context, req interface{}) (login *Login, errRes *util.JSONResponse)
+ // TODO: Extend to support Register() flow
+ // Register(ctx context.Context, sessionID string, req interface{})
+}
+
+// LoginIdentifier represents identifier types
+// https://matrix.org/docs/spec/client_server/r0.6.1#identifier-types
+type LoginIdentifier struct {
+ Type string `json:"type"`
+ // when type = m.id.user
+ User string `json:"user"`
+ // when type = m.id.thirdparty
+ Medium string `json:"medium"`
+ Address string `json:"address"`
+}
+
+// Login represents the shared fields used in all forms of login/sudo endpoints.
+type Login struct {
+ Type string `json:"type"`
+ Identifier LoginIdentifier `json:"identifier"`
+ User string `json:"user"` // deprecated in favour of identifier
+ Medium string `json:"medium"` // deprecated in favour of identifier
+ Address string `json:"address"` // deprecated in favour of identifier
+
+ // Both DeviceID and InitialDisplayName can be omitted, or empty strings ("")
+ // Thus a pointer is needed to differentiate between the two
+ InitialDisplayName *string `json:"initial_device_display_name"`
+ DeviceID *string `json:"device_id"`
+}
+
+// Username returns the user localpart/user_id in this request, if it exists.
+func (r *Login) Username() string {
+ if r.Identifier.Type == "m.id.user" {
+ return r.Identifier.User
+ }
+ // deprecated but without it Riot iOS won't log in
+ return r.User
+}
+
+// ThirdPartyID returns the 3PID medium and address for this login, if it exists.
+func (r *Login) ThirdPartyID() (medium, address string) {
+ if r.Identifier.Type == "m.id.thirdparty" {
+ return r.Identifier.Medium, r.Identifier.Address
+ }
+ // deprecated
+ if r.Medium == "email" {
+ return "email", r.Address
+ }
+ return "", ""
+}
+
+type userInteractiveFlow struct {
+ Stages []string `json:"stages"`
+}
+
+// UserInteractive checks that the user is who they claim to be, via a UI auth.
+// This is used for things like device deletion and password reset where
+// the user already has a valid access token, but we want to double-check
+// that it isn't stolen by re-authenticating them.
+type UserInteractive struct {
+ Flows []userInteractiveFlow
+ // Map of login type to implementation
+ Types map[string]Type
+ // Map of session ID to completed login types, will need to be extended in future
+ Sessions map[string][]string
+}
+
+func NewUserInteractive(getAccByPass GetAccountByPassword, cfg *config.Dendrite) *UserInteractive {
+ typePassword := &LoginTypePassword{
+ GetAccountByPassword: getAccByPass,
+ Config: cfg,
+ }
+ // TODO: Add SSO login
+ return &UserInteractive{
+ Flows: []userInteractiveFlow{
+ {
+ Stages: []string{typePassword.Name()},
+ },
+ },
+ Types: map[string]Type{
+ typePassword.Name(): typePassword,
+ },
+ Sessions: make(map[string][]string),
+ }
+}
+
+func (u *UserInteractive) IsSingleStageFlow(authType string) bool {
+ for _, f := range u.Flows {
+ if len(f.Stages) == 1 && f.Stages[0] == authType {
+ return true
+ }
+ }
+ return false
+}
+
+func (u *UserInteractive) AddCompletedStage(sessionID, authType string) {
+ // TODO: Handle multi-stage flows
+ delete(u.Sessions, sessionID)
+}
+
+// Challenge returns an HTTP 401 with the supported flows for authenticating
+func (u *UserInteractive) Challenge(sessionID string) *util.JSONResponse {
+ return &util.JSONResponse{
+ Code: 401,
+ JSON: struct {
+ Flows []userInteractiveFlow `json:"flows"`
+ Session string `json:"session"`
+ // TODO: Return any additional `params`
+ Params map[string]interface{} `json:"params"`
+ }{
+ u.Flows,
+ sessionID,
+ make(map[string]interface{}),
+ },
+ }
+}
+
+// NewSession returns a challenge with a new session ID and remembers the session ID
+func (u *UserInteractive) NewSession() *util.JSONResponse {
+ sessionID, err := GenerateAccessToken()
+ if err != nil {
+ logrus.WithError(err).Error("failed to generate session ID")
+ res := jsonerror.InternalServerError()
+ return &res
+ }
+ u.Sessions[sessionID] = []string{}
+ return u.Challenge(sessionID)
+}
+
+// ResponseWithChallenge mixes together a JSON body (e.g an error with errcode/message) with the
+// standard challenge response.
+func (u *UserInteractive) ResponseWithChallenge(sessionID string, response interface{}) *util.JSONResponse {
+ mixedObjects := make(map[string]interface{})
+ b, err := json.Marshal(response)
+ if err != nil {
+ ise := jsonerror.InternalServerError()
+ return &ise
+ }
+ _ = json.Unmarshal(b, &mixedObjects)
+ challenge := u.Challenge(sessionID)
+ b, err = json.Marshal(challenge.JSON)
+ if err != nil {
+ ise := jsonerror.InternalServerError()
+ return &ise
+ }
+ _ = json.Unmarshal(b, &mixedObjects)
+
+ return &util.JSONResponse{
+ Code: 401,
+ JSON: mixedObjects,
+ }
+}
+
+// Verify returns an error/challenge response to send to the client, or nil if the user is authenticated.
+// `bodyBytes` is the HTTP request body which must contain an `auth` key.
+// Returns the login that was verified for additional checks if required.
+func (u *UserInteractive) Verify(ctx context.Context, bodyBytes []byte, device *api.Device) (*Login, *util.JSONResponse) {
+ // TODO: rate limit
+
+ // "A client should first make a request with no auth parameter. The homeserver returns an HTTP 401 response, with a JSON body"
+ // https://matrix.org/docs/spec/client_server/r0.6.1#user-interactive-api-in-the-rest-api
+ hasResponse := gjson.GetBytes(bodyBytes, "auth").Exists()
+ if !hasResponse {
+ return nil, u.NewSession()
+ }
+
+ // extract the type so we know which login type to use
+ authType := gjson.GetBytes(bodyBytes, "auth.type").Str
+ loginType, ok := u.Types[authType]
+ if !ok {
+ return nil, &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("unknown auth.type: " + authType),
+ }
+ }
+
+ // retrieve the session
+ sessionID := gjson.GetBytes(bodyBytes, "auth.session").Str
+ if _, ok = u.Sessions[sessionID]; !ok {
+ // if the login type is part of a single stage flow then allow them to omit the session ID
+ if !u.IsSingleStageFlow(authType) {
+ return nil, &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.Unknown("missing or unknown auth.session"),
+ }
+ }
+ }
+
+ r := loginType.Request()
+ if err := json.Unmarshal([]byte(gjson.GetBytes(bodyBytes, "auth").Raw), r); err != nil {
+ return nil, &util.JSONResponse{
+ Code: http.StatusBadRequest,
+ JSON: jsonerror.BadJSON("The request body could not be decoded into valid JSON. " + err.Error()),
+ }
+ }
+ login, resErr := loginType.Login(ctx, r)
+ if resErr == nil {
+ u.AddCompletedStage(sessionID, authType)
+ // TODO: Check if there's more stages to go and return an error
+ return login, nil
+ }
+ return nil, u.ResponseWithChallenge(sessionID, resErr.JSON)
+}
diff --git a/clientapi/auth/user_interactive_test.go b/clientapi/auth/user_interactive_test.go
new file mode 100644
index 00000000..d12652c0
--- /dev/null
+++ b/clientapi/auth/user_interactive_test.go
@@ -0,0 +1,174 @@
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/matrix-org/dendrite/internal/config"
+ "github.com/matrix-org/dendrite/userapi/api"
+ "github.com/matrix-org/gomatrixserverlib"
+ "github.com/matrix-org/util"
+)
+
+var (
+ ctx = context.Background()
+ serverName = gomatrixserverlib.ServerName("example.com")
+ // space separated localpart+password -> account
+ lookup = make(map[string]*api.Account)
+ device = &api.Device{
+ AccessToken: "flibble",
+ DisplayName: "My Device",
+ ID: "device_id_goes_here",
+ }
+)
+
+func getAccountByPassword(ctx context.Context, localpart, plaintextPassword string) (*api.Account, error) {
+ acc, ok := lookup[localpart+" "+plaintextPassword]
+ if !ok {
+ return nil, fmt.Errorf("unknown user/password")
+ }
+ return acc, nil
+}
+
+func setup() *UserInteractive {
+ cfg := &config.Dendrite{}
+ cfg.Matrix.ServerName = serverName
+ return NewUserInteractive(getAccountByPassword, cfg)
+}
+
+func TestUserInteractiveChallenge(t *testing.T) {
+ uia := setup()
+ // no auth key results in a challenge
+ _, errRes := uia.Verify(ctx, []byte(`{}`), device)
+ if errRes == nil {
+ t.Fatalf("Verify succeeded with {} but expected failure")
+ }
+ if errRes.Code != 401 {
+ t.Errorf("Expected HTTP 401, got %d", errRes.Code)
+ }
+}
+
+func TestUserInteractivePasswordLogin(t *testing.T) {
+ uia := setup()
+ // valid password login succeeds when an account exists
+ lookup["alice herpassword"] = &api.Account{
+ Localpart: "alice",
+ ServerName: serverName,
+ UserID: fmt.Sprintf("@alice:%s", serverName),
+ }
+ // valid password requests
+ testCases := []json.RawMessage{
+ // deprecated form
+ []byte(`{
+ "auth": {
+ "type": "m.login.password",
+ "user": "alice",
+ "password": "herpassword"
+ }
+ }`),
+ // new form
+ []byte(`{
+ "auth": {
+ "type": "m.login.password",
+ "identifier": {
+ "type": "m.id.user",
+ "user": "alice"
+ },
+ "password": "herpassword"
+ }
+ }`),
+ }
+ for _, tc := range testCases {
+ _, errRes := uia.Verify(ctx, tc, device)
+ if errRes != nil {
+ t.Errorf("Verify failed but expected success for request: %s - got %+v", string(tc), errRes)
+ }
+ }
+}
+
+func TestUserInteractivePasswordBadLogin(t *testing.T) {
+ uia := setup()
+ // password login fails when an account exists but is specced wrong
+ lookup["bob hispassword"] = &api.Account{
+ Localpart: "bob",
+ ServerName: serverName,
+ UserID: fmt.Sprintf("@bob:%s", serverName),
+ }
+ // invalid password requests
+ testCases := []struct {
+ body json.RawMessage
+ wantRes util.JSONResponse
+ }{
+ {
+ // fields not in an auth dict
+ body: []byte(`{
+ "type": "m.login.password",
+ "user": "bob",
+ "password": "hispassword"
+ }`),
+ wantRes: util.JSONResponse{
+ Code: 401,
+ },
+ },
+ {
+ // wrong type
+ body: []byte(`{
+ "auth": {
+ "type": "m.login.not_password",
+ "identifier": {
+ "type": "m.id.user",
+ "user": "bob"
+ },
+ "password": "hispassword"
+ }
+ }`),
+ wantRes: util.JSONResponse{
+ Code: 400,
+ },
+ },
+ {
+ // identifier type is wrong
+ body: []byte(`{
+ "auth": {
+ "type": "m.login.password",
+ "identifier": {
+ "type": "m.id.thirdparty",
+ "user": "bob"
+ },
+ "password": "hispassword"
+ }
+ }`),
+ wantRes: util.JSONResponse{
+ Code: 401,
+ },
+ },
+ {
+ // wrong password
+ body: []byte(`{
+ "auth": {
+ "type": "m.login.password",
+ "identifier": {
+ "type": "m.id.user",
+ "user": "bob"
+ },
+ "password": "not_his_password"
+ }
+ }`),
+ wantRes: util.JSONResponse{
+ Code: 401,
+ },
+ },
+ }
+ for _, tc := range testCases {
+ _, errRes := uia.Verify(ctx, tc.body, device)
+ if errRes == nil {
+ t.Errorf("Verify succeeded but expected failure for request: %s", string(tc.body))
+ continue
+ }
+ if errRes.Code != tc.wantRes.Code {
+ t.Errorf("got code %d want code %d for request: %s", errRes.Code, tc.wantRes.Code, string(tc.body))
+ }
+ }
+}