diff options
author | Kegsay <kegan@matrix.org> | 2020-07-10 00:39:44 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-07-10 00:39:44 +0100 |
commit | abf26c12f1a97fd2894a0509de9cf4a91c79d3ab (patch) | |
tree | 13b693141a36bfc4ae2ef2759e67dca92b2fc857 /clientapi/auth | |
parent | 9cc52f47f3ea2e8a009731cc46117cb996aed722 (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.go | 75 | ||||
-rw-r--r-- | clientapi/auth/user_interactive.go | 248 | ||||
-rw-r--r-- | clientapi/auth/user_interactive_test.go | 174 |
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)) + } + } +} |