implement MSC 3782

This commit is contained in:
Tak Wai Wong 2022-04-21 15:46:20 -07:00
parent 0eb5bd1e13
commit 390d557df8
21 changed files with 1190 additions and 41 deletions

3
.gitignore vendored
View file

@ -66,3 +66,6 @@ test/wasm/node_modules
complement/ complement/
media_store/ media_store/
# Debug
**/__debug_bin

View file

@ -11,4 +11,9 @@ const (
LoginTypeRecaptcha = "m.login.recaptcha" LoginTypeRecaptcha = "m.login.recaptcha"
LoginTypeApplicationService = "m.login.application_service" LoginTypeApplicationService = "m.login.application_service"
LoginTypeToken = "m.login.token" LoginTypeToken = "m.login.token"
LoginTypePublicKey = "m.login.publickey"
)
const (
LoginTypePublicKeyEthereum = "m.login.publickey.ethereum"
) )

View file

@ -33,7 +33,16 @@ import (
// called after authorization has completed, with the result of the authorization. // called after authorization has completed, with the result of the authorization.
// If the final return value is non-nil, an error occurred and the cleanup function // If the final return value is non-nil, an error occurred and the cleanup function
// is nil. // is nil.
func LoginFromJSONReader(ctx context.Context, r io.Reader, useraccountAPI uapi.UserAccountAPI, userAPI UserInternalAPIForLogin, cfg *config.ClientAPI) (*Login, LoginCleanupFunc, *util.JSONResponse) { func LoginFromJSONReader(
ctx context.Context,
r io.Reader,
useraccountAPI uapi.UserAccountAPI,
userAPI UserInternalAPIForLogin,
userRegisterAPI uapi.UserRegisterAPI,
userInteractiveAuth *UserInteractive,
cfg *config.ClientAPI,
) (*Login, LoginCleanupFunc, *util.JSONResponse) {
reqBytes, err := ioutil.ReadAll(r) reqBytes, err := ioutil.ReadAll(r)
if err != nil { if err != nil {
err := &util.JSONResponse{ err := &util.JSONResponse{
@ -55,17 +64,23 @@ func LoginFromJSONReader(ctx context.Context, r io.Reader, useraccountAPI uapi.U
} }
var typ Type var typ Type
switch header.Type { switch {
case authtypes.LoginTypePassword: case header.Type == authtypes.LoginTypePassword && !cfg.PasswordAuthenticationDisabled:
typ = &LoginTypePassword{ typ = &LoginTypePassword{
GetAccountByPassword: useraccountAPI.QueryAccountByPassword, GetAccountByPassword: useraccountAPI.QueryAccountByPassword,
Config: cfg, Config: cfg,
} }
case authtypes.LoginTypeToken: case header.Type == authtypes.LoginTypeToken:
typ = &LoginTypeToken{ typ = &LoginTypeToken{
UserAPI: userAPI, UserAPI: userAPI,
Config: cfg, Config: cfg,
} }
case header.Type == authtypes.LoginTypePublicKey && cfg.PublicKeyAuthentication.Enabled():
typ = &LoginTypePublicKey{
UserAPI: userRegisterAPI,
UserInteractive: userInteractiveAuth,
Config: cfg,
}
default: default:
err := util.JSONResponse{ err := util.JSONResponse{
Code: http.StatusBadRequest, Code: http.StatusBadRequest,

View file

@ -0,0 +1,149 @@
// Copyright 2022 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/auth/authtypes"
"github.com/matrix-org/dendrite/clientapi/jsonerror"
"github.com/matrix-org/dendrite/internal/mapsutil"
"github.com/matrix-org/dendrite/setup/config"
userApi "github.com/matrix-org/dendrite/userapi/api"
"github.com/matrix-org/util"
"github.com/tidwall/gjson"
)
type LoginPublicKeyHandler interface {
AccountExists(ctx context.Context) (string, *jsonerror.MatrixError)
CreateLogin() *Login
GetSession() string
GetType() string
ValidateLoginResponse() (bool, *jsonerror.MatrixError)
}
// LoginTypePublicKey implements https://matrix.org/docs/spec/client_server/..... (to be spec'ed)
type LoginTypePublicKey struct {
UserAPI userApi.UserRegisterAPI
UserInteractive *UserInteractive
Config *config.ClientAPI
}
func (t *LoginTypePublicKey) Name() string {
return authtypes.LoginTypePublicKey
}
func (t *LoginTypePublicKey) AddFlows(userInteractive *UserInteractive) {
if t.Config.PublicKeyAuthentication.Ethereum.Enabled {
userInteractive.Flows = append(userInteractive.Flows, userInteractiveFlow{
Stages: []string{
authtypes.LoginTypePublicKeyEthereum,
},
})
params := t.Config.PublicKeyAuthentication.GetPublicKeyRegistrationParams()
userInteractive.Params = mapsutil.MapsUnion(userInteractive.Params, params)
}
if t.Config.PublicKeyAuthentication.Enabled() {
userInteractive.Types[t.Name()] = t
}
}
// LoginFromJSON implements Type.
func (t *LoginTypePublicKey) LoginFromJSON(ctx context.Context, reqBytes []byte) (*Login, LoginCleanupFunc, *util.JSONResponse) {
// "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
authBytes := gjson.GetBytes(reqBytes, "auth")
if !authBytes.Exists() {
return nil, nil, t.UserInteractive.NewSession()
}
var authHandler LoginPublicKeyHandler
authType := gjson.GetBytes(reqBytes, "auth.type").String()
switch authType {
case authtypes.LoginTypePublicKeyEthereum:
pkEthHandler, err := CreatePublicKeyEthereumHandler(
[]byte(authBytes.Raw),
t.UserAPI,
t.Config,
)
if err != nil {
return nil, nil, &util.JSONResponse{
Code: http.StatusUnauthorized,
JSON: err,
}
}
authHandler = *pkEthHandler
default:
return nil, nil, &util.JSONResponse{
Code: http.StatusUnauthorized,
JSON: jsonerror.InvalidParam("auth.type"),
}
}
return t.continueLoginFlow(ctx, authHandler)
}
func (t *LoginTypePublicKey) continueLoginFlow(ctx context.Context, authHandler LoginPublicKeyHandler) (*Login, LoginCleanupFunc, *util.JSONResponse) {
loginOK := false
sessionID := authHandler.GetSession()
defer func() {
if loginOK {
t.UserInteractive.AddCompletedStage(sessionID, authHandler.GetType())
} else {
t.UserInteractive.DeleteSession(sessionID)
}
}()
if _, ok := t.UserInteractive.Sessions[sessionID]; !ok {
return nil, nil, &util.JSONResponse{
Code: http.StatusUnauthorized,
JSON: jsonerror.Unknown("the session ID is missing or unknown."),
}
}
localPart, err := authHandler.AccountExists(ctx)
// user account does not exist or there is an error.
if localPart == "" || err != nil {
return nil, nil, &util.JSONResponse{
Code: http.StatusForbidden,
JSON: err,
}
}
// user account exists
isValidated, err := authHandler.ValidateLoginResponse()
if err != nil {
return nil, nil, &util.JSONResponse{
Code: http.StatusUnauthorized,
JSON: err,
}
}
if isValidated {
loginOK = true
login := authHandler.CreateLogin()
return login, func(context.Context, *util.JSONResponse) {}, nil
}
return nil, nil, &util.JSONResponse{
Code: http.StatusUnauthorized,
JSON: jsonerror.Unknown("authentication failed, or the account does not exist."),
}
}

View file

@ -0,0 +1,233 @@
// Copyright 2022 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/base64"
"encoding/json"
"errors"
"regexp"
"strings"
"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/crypto"
"github.com/matrix-org/dendrite/clientapi/jsonerror"
"github.com/matrix-org/dendrite/clientapi/userutil"
"github.com/matrix-org/dendrite/setup/config"
userApi "github.com/matrix-org/dendrite/userapi/api"
"github.com/tidwall/gjson"
)
type LoginPublicKeyEthereum struct {
// Todo: See https://...
Type string `json:"type"`
Address string `json:"address"`
Session string `json:"session"`
Message string `json:"message"`
Signature string `json:"signature"`
HashFields publicKeyEthereumHashFields `json:"hashFields"`
HashFieldsRaw string // Raw base64 encoded string of MessageFields for hash verification
userAPI userApi.UserRegisterAPI
config *config.ClientAPI
}
type publicKeyEthereumHashFields struct {
// Todo: See https://...
Domain string `json:"domain"` // home server domain
Address string `json:"address"` // Ethereum address. 0x...
Nonce string `json:"nonce"` // session ID
Version string `json:"version"` // version of the Matrix public key spec that the client is complying with
ChainId string `json:"chainId"` // blockchain network ID.
}
type publicKeyEthereumRequiredFields struct {
From string // Sender
To string // Recipient
Hash string // Hash of JSON representation of the message fields
}
func CreatePublicKeyEthereumHandler(
reqBytes []byte,
userAPI userApi.UserRegisterAPI,
config *config.ClientAPI,
) (*LoginPublicKeyEthereum, *jsonerror.MatrixError) {
var pk LoginPublicKeyEthereum
if err := json.Unmarshal(reqBytes, &pk); err != nil {
return nil, jsonerror.BadJSON("auth")
}
hashFields := gjson.GetBytes(reqBytes, "hashFields")
if !hashFields.Exists() {
return nil, jsonerror.BadJSON("auth.hashFields")
}
pk.config = config
pk.userAPI = userAPI
// Save raw bytes for hash verification later.
pk.HashFieldsRaw = hashFields.Raw
// Case-insensitive
pk.Address = strings.ToLower(pk.Address)
return &pk, nil
}
func (pk LoginPublicKeyEthereum) GetSession() string {
return pk.Session
}
func (pk LoginPublicKeyEthereum) GetType() string {
return pk.Type
}
func (pk LoginPublicKeyEthereum) AccountExists(ctx context.Context) (string, *jsonerror.MatrixError) {
localPart, err := userutil.ParseUsernameParam(pk.Address, &pk.config.Matrix.ServerName)
if err != nil {
// userId does not exist
return "", jsonerror.Forbidden("the address is incorrect, or the account does not exist.")
}
res := userApi.QueryAccountAvailabilityResponse{}
if err := pk.userAPI.QueryAccountAvailability(ctx, &userApi.QueryAccountAvailabilityRequest{
Localpart: localPart,
}, &res); err != nil {
return "", jsonerror.Unknown("failed to check availability: " + err.Error())
}
if res.Available {
return "", jsonerror.Forbidden("the address is incorrect, account does not exist")
}
return localPart, nil
}
func (pk LoginPublicKeyEthereum) ValidateLoginResponse() (bool, *jsonerror.MatrixError) {
// Check signature to verify message was not tempered
isVerified := verifySignature(pk.Address, []byte(pk.Message), pk.Signature)
if !isVerified {
return false, jsonerror.InvalidSignature("")
}
// Extract the required message fields for validation
requiredFields, err := extractRequiredMessageFields(pk.Message)
if err != nil {
return false, jsonerror.MissingParam("message does not contain domain, address, or hash")
}
// Verify that the hash is valid for the message fields.
if !verifyHash(pk.HashFieldsRaw, requiredFields.Hash) {
return false, jsonerror.InvalidParam("error verifying message hash")
}
// Unmarshal the hashFields for further validation
var authData publicKeyEthereumHashFields
if err := json.Unmarshal([]byte(pk.HashFieldsRaw), &authData); err != nil {
return false, jsonerror.BadJSON("auth.hashFields")
}
// Error if the message is not from the expected public address
if pk.Address != requiredFields.From || requiredFields.From != pk.HashFields.Address {
return false, jsonerror.InvalidParam("address")
}
// Error if the message is not for the home server
if requiredFields.To != pk.HashFields.Domain {
return false, jsonerror.InvalidParam("domain")
}
// No errors.
return true, nil
}
func (pk LoginPublicKeyEthereum) CreateLogin() *Login {
identifier := LoginIdentifier{
Type: "m.id.user",
User: pk.Address,
}
login := Login{
Identifier: identifier,
}
return &login
}
// The required fields in the signed message are:
// 1. Domain -- home server. First non-whitespace characters in the first line.
// 2. Address -- public address of the user. Starts with 0x... in the second line on its own.
// 3. Hash -- Base64-encoded hash string of the metadata that represents the message.
// The rest of the fields are informational, and will be used in the future.
var regexpAuthority = regexp.MustCompile(`^\S+`)
var regexpAddress = regexp.MustCompile(`\n(?P<address>0x\w+)\n`)
var regexpHash = regexp.MustCompile(`\nHash: (?P<hash>.*)\n`)
func extractRequiredMessageFields(message string) (*publicKeyEthereumRequiredFields, error) {
var requiredFields publicKeyEthereumRequiredFields
/*
service.org wants you to sign in with your account:
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
I accept the ServiceOrg Terms of Service: https://service.org/tos
Hash: yfSIwarByPfKFxeYSCWN3XoIgNgeEFJffbwFA+JxYbA=
*/
requiredFields.To = regexpAuthority.FindString(message)
from := regexpAddress.FindStringSubmatch(message)
if len(from) == 2 {
requiredFields.From = from[1]
}
hash := regexpHash.FindStringSubmatch(message)
if len(hash) == 2 {
requiredFields.Hash = hash[1]
}
if len(requiredFields.To) == 0 || len(requiredFields.From) == 0 || len(requiredFields.Hash) == 0 {
return nil, errors.New("required message fields are missing")
}
// Make these fields case-insensitive
requiredFields.From = strings.ToLower(requiredFields.From)
requiredFields.To = strings.ToLower(requiredFields.To)
return &requiredFields, nil
}
func verifySignature(from string, message []byte, signature string) bool {
decodedSig := hexutil.MustDecode(signature)
message = accounts.TextHash(message)
// Issue: https://stackoverflow.com/questions/49085737/geth-ecrecover-invalid-signature-recovery-id
// Fix: https://gist.github.com/dcb9/385631846097e1f59e3cba3b1d42f3ed#file-eth_sign_verify-go
decodedSig[crypto.RecoveryIDOffset] -= 27 // Transform yellow paper V from 27/28 to 0/1
recovered, err := crypto.SigToPub(message, decodedSig)
if err != nil {
return false
}
recoveredAddr := crypto.PubkeyToAddress(*recovered)
addressStr := strings.ToLower(recoveredAddr.Hex())
return from == addressStr
}
func verifyHash(rawStr string, expectedHash string) bool {
hash := crypto.Keccak256([]byte(rawStr))
hashStr := base64.StdEncoding.EncodeToString(hash)
return expectedHash == hashStr
}

View file

@ -61,6 +61,13 @@ func TestLoginFromJSONReader(t *testing.T) {
WantDeletedTokens: []string{"atoken"}, WantDeletedTokens: []string{"atoken"},
}, },
} }
userInteractive := UserInteractive{
Completed: []string{},
Flows: []userInteractiveFlow{},
Types: make(map[string]Type),
Sessions: make(map[string][]string),
}
for _, tst := range tsts { for _, tst := range tsts {
t.Run(tst.Name, func(t *testing.T) { t.Run(tst.Name, func(t *testing.T) {
var userAPI fakeUserInternalAPI var userAPI fakeUserInternalAPI
@ -69,7 +76,7 @@ func TestLoginFromJSONReader(t *testing.T) {
ServerName: serverName, ServerName: serverName,
}, },
} }
login, cleanup, err := LoginFromJSONReader(ctx, strings.NewReader(tst.Body), &userAPI, &userAPI, cfg) login, cleanup, err := LoginFromJSONReader(ctx, strings.NewReader(tst.Body), &userAPI, &userAPI, &userAPI, &userInteractive, cfg)
if err != nil { if err != nil {
t.Fatalf("LoginFromJSONReader failed: %+v", err) t.Fatalf("LoginFromJSONReader failed: %+v", err)
} }
@ -139,6 +146,13 @@ func TestBadLoginFromJSONReader(t *testing.T) {
WantErrCode: "M_INVALID_ARGUMENT_VALUE", WantErrCode: "M_INVALID_ARGUMENT_VALUE",
}, },
} }
userInteractive := UserInteractive{
Completed: []string{},
Flows: []userInteractiveFlow{},
Types: make(map[string]Type),
Sessions: make(map[string][]string),
}
for _, tst := range tsts { for _, tst := range tsts {
t.Run(tst.Name, func(t *testing.T) { t.Run(tst.Name, func(t *testing.T) {
var userAPI fakeUserInternalAPI var userAPI fakeUserInternalAPI
@ -147,7 +161,7 @@ func TestBadLoginFromJSONReader(t *testing.T) {
ServerName: serverName, ServerName: serverName,
}, },
} }
_, cleanup, errRes := LoginFromJSONReader(ctx, strings.NewReader(tst.Body), &userAPI, &userAPI, cfg) _, cleanup, errRes := LoginFromJSONReader(ctx, strings.NewReader(tst.Body), &userAPI, &userAPI, &userAPI, &userInteractive, cfg)
if errRes == nil { if errRes == nil {
cleanup(ctx, nil) cleanup(ctx, nil)
t.Fatalf("LoginFromJSONReader err: got %+v, want code %q", errRes, tst.WantErrCode) t.Fatalf("LoginFromJSONReader err: got %+v, want code %q", errRes, tst.WantErrCode)
@ -161,6 +175,7 @@ func TestBadLoginFromJSONReader(t *testing.T) {
type fakeUserInternalAPI struct { type fakeUserInternalAPI struct {
UserInternalAPIForLogin UserInternalAPIForLogin
uapi.UserAccountAPI uapi.UserAccountAPI
uapi.UserRegisterAPI
DeletedTokens []string DeletedTokens []string
} }

View file

@ -107,3 +107,12 @@ func (t *LoginTypePassword) Login(ctx context.Context, req interface{}) (*Login,
} }
return &r.Login, nil return &r.Login, nil
} }
func (t *LoginTypePassword) AddFLows(userInteractive *UserInteractive) {
flow := userInteractiveFlow{
Stages: []string{t.Name()},
}
userInteractive.Flows = append(userInteractive.Flows, flow)
userInteractive.Types[t.Name()] = t
}

View file

@ -108,25 +108,40 @@ type UserInteractive struct {
Types map[string]Type Types map[string]Type
// Map of session ID to completed login types, will need to be extended in future // Map of session ID to completed login types, will need to be extended in future
Sessions map[string][]string Sessions map[string][]string
Params map[string]interface{}
} }
func NewUserInteractive(userAccountAPI api.UserAccountAPI, cfg *config.ClientAPI) *UserInteractive { func NewUserInteractive(
typePassword := &LoginTypePassword{ userAccountAPI api.UserAccountAPI,
GetAccountByPassword: userAccountAPI.QueryAccountByPassword, userRegisterAPI api.UserRegisterAPI,
Config: cfg, cfg *config.ClientAPI,
} ) *UserInteractive {
return &UserInteractive{ userInteractive := UserInteractive{
Completed: []string{}, Completed: []string{},
Flows: []userInteractiveFlow{ Flows: []userInteractiveFlow{},
{ Types: make(map[string]Type),
Stages: []string{typePassword.Name()}, Sessions: make(map[string][]string),
}, Params: make(map[string]interface{}),
},
Types: map[string]Type{
typePassword.Name(): typePassword,
},
Sessions: make(map[string][]string),
} }
if !cfg.PasswordAuthenticationDisabled {
typePassword := &LoginTypePassword{
GetAccountByPassword: userAccountAPI.QueryAccountByPassword,
Config: cfg,
}
typePassword.AddFLows(&userInteractive)
}
if cfg.PublicKeyAuthentication.Enabled() {
typePublicKey := &LoginTypePublicKey{
userRegisterAPI,
&userInteractive,
cfg,
}
typePublicKey.AddFlows(&userInteractive)
}
return &userInteractive
} }
func (u *UserInteractive) IsSingleStageFlow(authType string) bool { func (u *UserInteractive) IsSingleStageFlow(authType string) bool {
@ -144,6 +159,10 @@ func (u *UserInteractive) AddCompletedStage(sessionID, authType string) {
delete(u.Sessions, sessionID) delete(u.Sessions, sessionID)
} }
func (u *UserInteractive) DeleteSession(sessionID string) {
delete(u.Sessions, sessionID)
}
type Challenge struct { type Challenge struct {
Completed []string `json:"completed"` Completed []string `json:"completed"`
Flows []userInteractiveFlow `json:"flows"` Flows []userInteractiveFlow `json:"flows"`
@ -160,7 +179,7 @@ func (u *UserInteractive) Challenge(sessionID string) *util.JSONResponse {
Completed: u.Completed, Completed: u.Completed,
Flows: u.Flows, Flows: u.Flows,
Session: sessionID, Session: sessionID,
Params: make(map[string]interface{}), Params: u.Params,
}, },
} }
} }
@ -204,7 +223,7 @@ func (u *UserInteractive) ResponseWithChallenge(sessionID string, response inter
// Verify returns an error/challenge response to send to the client, or nil if the user is authenticated. // 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. // `bodyBytes` is the HTTP request body which must contain an `auth` key.
// Returns the login that was verified for additional checks if required. // 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) { func (u *UserInteractive) Verify(ctx context.Context, bodyBytes []byte) (*Login, *util.JSONResponse) {
// TODO: rate limit // 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" // "A client should first make a request with no auth parameter. The homeserver returns an HTTP 401 response, with a JSON body"

View file

@ -26,6 +26,7 @@ var (
type fakeAccountDatabase struct { type fakeAccountDatabase struct {
api.UserAccountAPI api.UserAccountAPI
api.UserRegisterAPI
} }
func (d *fakeAccountDatabase) PerformPasswordUpdate(ctx context.Context, req *api.PerformPasswordUpdateRequest, res *api.PerformPasswordUpdateResponse) error { func (d *fakeAccountDatabase) PerformPasswordUpdate(ctx context.Context, req *api.PerformPasswordUpdateRequest, res *api.PerformPasswordUpdateResponse) error {
@ -52,13 +53,14 @@ func setup() *UserInteractive {
ServerName: serverName, ServerName: serverName,
}, },
} }
return NewUserInteractive(&fakeAccountDatabase{}, cfg) accountApi := fakeAccountDatabase{}
return NewUserInteractive(&accountApi, &accountApi, cfg)
} }
func TestUserInteractiveChallenge(t *testing.T) { func TestUserInteractiveChallenge(t *testing.T) {
uia := setup() uia := setup()
// no auth key results in a challenge // no auth key results in a challenge
_, errRes := uia.Verify(ctx, []byte(`{}`), device) _, errRes := uia.Verify(ctx, []byte(`{}`))
if errRes == nil { if errRes == nil {
t.Fatalf("Verify succeeded with {} but expected failure") t.Fatalf("Verify succeeded with {} but expected failure")
} }
@ -98,7 +100,7 @@ func TestUserInteractivePasswordLogin(t *testing.T) {
}`), }`),
} }
for _, tc := range testCases { for _, tc := range testCases {
_, errRes := uia.Verify(ctx, tc, device) _, errRes := uia.Verify(ctx, tc)
if errRes != nil { if errRes != nil {
t.Errorf("Verify failed but expected success for request: %s - got %+v", string(tc), errRes) t.Errorf("Verify failed but expected success for request: %s - got %+v", string(tc), errRes)
} }
@ -179,7 +181,7 @@ func TestUserInteractivePasswordBadLogin(t *testing.T) {
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
_, errRes := uia.Verify(ctx, tc.body, device) _, errRes := uia.Verify(ctx, tc.body)
if errRes == nil { if errRes == nil {
t.Errorf("Verify succeeded but expected failure for request: %s", string(tc.body)) t.Errorf("Verify succeeded but expected failure for request: %s", string(tc.body))
continue continue

View file

@ -28,7 +28,7 @@ func Deactivate(
} }
} }
login, errRes := userInteractiveAuth.Verify(ctx, bodyBytes, deviceAPI) login, errRes := userInteractiveAuth.Verify(ctx, bodyBytes)
if errRes != nil { if errRes != nil {
return *errRes return *errRes
} }

View file

@ -198,7 +198,7 @@ func DeleteDeviceById(
sessionID = s sessionID = s
} }
login, errRes := userInteractiveAuth.Verify(ctx, bodyBytes, device) login, errRes := userInteractiveAuth.Verify(ctx, bodyBytes)
if errRes != nil { if errRes != nil {
switch data := errRes.JSON.(type) { switch data := errRes.JSON.(type) {
case auth.Challenge: case auth.Challenge:

View file

@ -42,28 +42,42 @@ type flow struct {
Type string `json:"type"` Type string `json:"type"`
} }
func passwordLogin() flows { func passwordLogin(f *flows) {
f := flows{}
s := flow{ s := flow{
Type: "m.login.password", Type: "m.login.password",
} }
f.Flows = append(f.Flows, s) f.Flows = append(f.Flows, s)
return f }
func publicKeyLogin(f *flows) {
loginFlow := flow{
Type: "m.login.publickey",
}
f.Flows = append(f.Flows, loginFlow)
} }
// Login implements GET and POST /login // Login implements GET and POST /login
func Login( func Login(
req *http.Request, userAPI userapi.UserInternalAPI, req *http.Request,
userAPI userapi.UserInternalAPI,
userInteractiveAuth *auth.UserInteractive,
cfg *config.ClientAPI, cfg *config.ClientAPI,
) util.JSONResponse { ) util.JSONResponse {
if req.Method == http.MethodGet { if req.Method == http.MethodGet {
// TODO: support other forms of login other than password, depending on config options f := flows{}
if !cfg.PasswordAuthenticationDisabled {
passwordLogin(&f)
}
if cfg.PublicKeyAuthentication.Enabled() {
publicKeyLogin(&f)
}
// TODO: support other forms of login depending on config options
return util.JSONResponse{ return util.JSONResponse{
Code: http.StatusOK, Code: http.StatusOK,
JSON: passwordLogin(), JSON: f,
} }
} else if req.Method == http.MethodPost { } else if req.Method == http.MethodPost {
login, cleanup, authErr := auth.LoginFromJSONReader(req.Context(), req.Body, userAPI, userAPI, cfg) login, cleanup, authErr := auth.LoginFromJSONReader(req.Context(), req.Body, userAPI, userAPI, userAPI, userInteractiveAuth, cfg)
if authErr != nil { if authErr != nil {
return *authErr return *authErr
} }

View file

@ -589,6 +589,10 @@ func Register(
Code: http.StatusBadRequest, Code: http.StatusBadRequest,
JSON: jsonerror.MissingArgument("A known registration type (e.g. m.login.application_service) must be specified if an access_token is provided"), JSON: jsonerror.MissingArgument("A known registration type (e.g. m.login.application_service) must be specified if an access_token is provided"),
} }
case r.Auth.Type == authtypes.LoginTypePublicKey && cfg.PublicKeyAuthentication.Enabled():
// Skip checks here. Will be validated later.
default: default:
// Spec-compliant case (neither the access_token nor the login type are // Spec-compliant case (neither the access_token nor the login type are
// specified, so it's a normal user registration) // specified, so it's a normal user registration)
@ -607,7 +611,7 @@ func Register(
"session_id": r.Auth.Session, "session_id": r.Auth.Session,
}).Info("Processing registration request") }).Info("Processing registration request")
return handleRegistrationFlow(req, r, sessionID, cfg, userAPI, accessToken, accessTokenErr) return handleRegistrationFlow(req, reqBody, r, sessionID, cfg, userAPI, accessToken, accessTokenErr)
} }
func handleGuestRegistration( func handleGuestRegistration(
@ -676,6 +680,7 @@ func handleGuestRegistration(
// nolint: gocyclo // nolint: gocyclo
func handleRegistrationFlow( func handleRegistrationFlow(
req *http.Request, req *http.Request,
reqBody []byte,
r registerRequest, r registerRequest,
sessionID string, sessionID string,
cfg *config.ClientAPI, cfg *config.ClientAPI,
@ -736,6 +741,18 @@ func handleRegistrationFlow(
// Add Dummy to the list of completed registration stages // Add Dummy to the list of completed registration stages
sessions.addCompletedSessionStage(sessionID, authtypes.LoginTypeDummy) sessions.addCompletedSessionStage(sessionID, authtypes.LoginTypeDummy)
case authtypes.LoginTypePublicKey:
isCompleted, authType, err := handlePublicKeyRegistration(cfg, reqBody, userAPI)
if err != nil {
return *err
}
if isCompleted {
sessions.addCompletedSessionStage(sessionID, authType)
} else {
newPublicKeyAuthSession(&r)
}
case "": case "":
// An empty auth type means that we want to fetch the available // An empty auth type means that we want to fetch the available
// flows. It can also mean that we want to register as an appservice // flows. It can also mean that we want to register as an appservice

View file

@ -0,0 +1,80 @@
// Copyright 2022 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 routing
import (
"net/http"
"github.com/matrix-org/dendrite/clientapi/auth"
"github.com/matrix-org/dendrite/clientapi/auth/authtypes"
"github.com/matrix-org/dendrite/clientapi/jsonerror"
"github.com/matrix-org/dendrite/setup/config"
userapi "github.com/matrix-org/dendrite/userapi/api"
"github.com/matrix-org/util"
"github.com/tidwall/gjson"
)
func newPublicKeyAuthSession(request *registerRequest) {
// Public key auth does not use password. But the registration flow
// requires setting a password in order to create the account.
// Create a random password to satisfy the requirement.
request.Password = util.RandomString(sessionIDLength)
}
func handlePublicKeyRegistration(
cfg *config.ClientAPI,
reqBytes []byte,
userAPI userapi.UserRegisterAPI,
) (bool, authtypes.LoginType, *util.JSONResponse) {
if !cfg.PublicKeyAuthentication.Enabled() {
return false, "", &util.JSONResponse{
Code: http.StatusForbidden,
JSON: jsonerror.Forbidden("public key account registration is disabled"),
}
}
var authHandler auth.LoginPublicKeyHandler
authType := gjson.GetBytes(reqBytes, "auth.public_key_response.type").String()
switch authType {
case authtypes.LoginTypePublicKeyEthereum:
authBytes := gjson.GetBytes(reqBytes, "auth.public_key_response")
pkEthHandler, err := auth.CreatePublicKeyEthereumHandler(
[]byte(authBytes.Raw),
userAPI,
cfg,
)
if err != nil {
return false, "", &util.JSONResponse{
Code: http.StatusUnauthorized,
JSON: err,
}
}
authHandler = pkEthHandler
default:
// No response. Client is asking for a new registration session
return false, "", nil
}
isCompleted, jerr := authHandler.ValidateLoginResponse()
if jerr != nil {
return false, "", &util.JSONResponse{
Code: http.StatusUnauthorized,
JSON: jerr,
}
}
return isCompleted, authtypes.LoginType(authHandler.GetType()), nil
}

View file

@ -64,7 +64,7 @@ func Setup(
prometheus.MustRegister(amtRegUsers, sendEventDuration) prometheus.MustRegister(amtRegUsers, sendEventDuration)
rateLimits := httputil.NewRateLimits(&cfg.RateLimiting) rateLimits := httputil.NewRateLimits(&cfg.RateLimiting)
userInteractiveAuth := auth.NewUserInteractive(userAPI, cfg) userInteractiveAuth := auth.NewUserInteractive(userAPI, userAPI, cfg)
unstableFeatures := map[string]bool{ unstableFeatures := map[string]bool{
"org.matrix.e2e_cross_signing": true, "org.matrix.e2e_cross_signing": true,
@ -551,7 +551,7 @@ func Setup(
if r := rateLimits.Limit(req); r != nil { if r := rateLimits.Limit(req); r != nil {
return *r return *r
} }
return Login(req, userAPI, cfg) return Login(req, userAPI, userInteractiveAuth, cfg)
}), }),
).Methods(http.MethodGet, http.MethodPost, http.MethodOptions) ).Methods(http.MethodGet, http.MethodPost, http.MethodOptions)

View file

@ -169,6 +169,16 @@ client_api:
# whether registration is otherwise disabled. # whether registration is otherwise disabled.
registration_shared_secret: "" registration_shared_secret: ""
# Disable password authentication.
password_authentication_disabled: false
# public key authentication
public_key_authentication:
ethereum:
enabled: false
version: 1
chain_ids: [] // https://chainlist.org/
# Whether to require reCAPTCHA for registration. # Whether to require reCAPTCHA for registration.
enable_registration_captcha: false enable_registration_captcha: false

11
go.mod
View file

@ -11,29 +11,36 @@ require (
github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect
github.com/MFAshby/stdemuxerhook v1.0.0 github.com/MFAshby/stdemuxerhook v1.0.0
github.com/Masterminds/semver/v3 v3.1.1 github.com/Masterminds/semver/v3 v3.1.1
github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect
github.com/codeclysm/extract v2.2.0+incompatible github.com/codeclysm/extract v2.2.0+incompatible
github.com/containerd/containerd v1.6.2 // indirect github.com/containerd/containerd v1.6.2 // indirect
github.com/docker/docker v20.10.14+incompatible github.com/docker/docker v20.10.14+incompatible
github.com/docker/go-connections v0.4.0 github.com/docker/go-connections v0.4.0
github.com/ethereum/go-ethereum v1.10.17
github.com/frankban/quicktest v1.14.3 // indirect github.com/frankban/quicktest v1.14.3 // indirect
github.com/getsentry/sentry-go v0.13.0 github.com/getsentry/sentry-go v0.13.0
github.com/gologme/log v1.3.0 github.com/gologme/log v1.3.0
github.com/google/go-cmp v0.5.7 github.com/google/go-cmp v0.5.7
github.com/google/gopacket v1.1.18 // indirect
github.com/google/uuid v1.3.0 github.com/google/uuid v1.3.0
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/h2non/filetype v1.1.3 // indirect github.com/h2non/filetype v1.1.3 // indirect
github.com/hashicorp/golang-lru v0.5.4 github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d
github.com/juju/testing v0.0.0-20220203020004-a0ff61f03494 // indirect github.com/juju/testing v0.0.0-20220203020004-a0ff61f03494 // indirect
github.com/lib/pq v1.10.5 github.com/lib/pq v1.10.5
github.com/libp2p/go-libp2p v0.13.0
github.com/libp2p/go-libp2p-core v0.8.3 // indirect
github.com/libp2p/go-libp2p-gostream v0.3.1
github.com/libp2p/go-libp2p-http v0.2.0
github.com/matrix-org/dugong v0.0.0-20210921133753-66e6b1c67e2e github.com/matrix-org/dugong v0.0.0-20210921133753-66e6b1c67e2e
github.com/matrix-org/go-http-js-libp2p v0.0.0-20200518170932-783164aeeda4
github.com/matrix-org/go-sqlite3-js v0.0.0-20220419092513-28aa791a1c91 github.com/matrix-org/go-sqlite3-js v0.0.0-20220419092513-28aa791a1c91
github.com/matrix-org/gomatrix v0.0.0-20210324163249-be2af5ef2e16 github.com/matrix-org/gomatrix v0.0.0-20210324163249-be2af5ef2e16
github.com/matrix-org/gomatrixserverlib v0.0.0-20220408160933-cf558306b56f github.com/matrix-org/gomatrixserverlib v0.0.0-20220408160933-cf558306b56f
github.com/matrix-org/pinecone v0.0.0-20220408153826-2999ea29ed48 github.com/matrix-org/pinecone v0.0.0-20220408153826-2999ea29ed48
github.com/matrix-org/util v0.0.0-20200807132607-55161520e1d4 github.com/matrix-org/util v0.0.0-20200807132607-55161520e1d4
github.com/mattn/go-sqlite3 v1.14.10 github.com/mattn/go-sqlite3 v1.14.10
github.com/miekg/dns v1.1.31 // indirect
github.com/nats-io/nats-server/v2 v2.7.4-0.20220309205833-773636c1c5bb github.com/nats-io/nats-server/v2 v2.7.4-0.20220309205833-773636c1c5bb
github.com/nats-io/nats.go v1.14.0 github.com/nats-io/nats.go v1.14.0
github.com/neilalexander/utp v0.1.1-0.20210727203401-54ae7b1cd5f9 github.com/neilalexander/utp v0.1.1-0.20210727203401-54ae7b1cd5f9

482
go.sum

File diff suppressed because it is too large Load diff

29
internal/mapsutil/maps.go Normal file
View file

@ -0,0 +1,29 @@
// Copyright 2022 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 mapsutil
// Union two maps together with "b" overriding the values of "a"
// if the keys collide.
func MapsUnion(a map[string]interface{}, b map[string]interface{}) map[string]interface{} {
c := make(map[string]interface{})
for k, v := range a {
c[k] = v
}
for k, v := range b {
c[k] = v
}
return c
}

View file

@ -26,6 +26,7 @@ import (
"strings" "strings"
"github.com/matrix-org/dendrite/clientapi/auth/authtypes" "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
"github.com/matrix-org/dendrite/internal/mapsutil"
"github.com/matrix-org/gomatrixserverlib" "github.com/matrix-org/gomatrixserverlib"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"golang.org/x/crypto/ed25519" "golang.org/x/crypto/ed25519"
@ -280,6 +281,16 @@ func (config *Dendrite) Derive() error {
config.Derived.Registration.Flows = append(config.Derived.Registration.Flows, config.Derived.Registration.Flows = append(config.Derived.Registration.Flows,
authtypes.Flow{Stages: []authtypes.LoginType{authtypes.LoginTypeDummy}}) authtypes.Flow{Stages: []authtypes.LoginType{authtypes.LoginTypeDummy}})
} }
if config.ClientAPI.PublicKeyAuthentication.Enabled() {
pkFlows := config.ClientAPI.PublicKeyAuthentication.GetPublicKeyRegistrationFlows()
if pkFlows != nil {
config.Derived.Registration.Flows = append(config.Derived.Registration.Flows, pkFlows...)
}
pkParams := config.ClientAPI.PublicKeyAuthentication.GetPublicKeyRegistrationParams()
if pkParams != nil {
config.Derived.Registration.Params = mapsutil.MapsUnion(config.Derived.Registration.Params, pkParams)
}
}
// Load application service configuration files // Load application service configuration files
if err := loadAppServices(&config.AppServiceAPI, &config.Derived); err != nil { if err := loadAppServices(&config.AppServiceAPI, &config.Derived); err != nil {

View file

@ -3,6 +3,8 @@ package config
import ( import (
"fmt" "fmt"
"time" "time"
"github.com/matrix-org/dendrite/clientapi/auth/authtypes"
) )
type ClientAPI struct { type ClientAPI struct {
@ -43,6 +45,12 @@ type ClientAPI struct {
RateLimiting RateLimiting `yaml:"rate_limiting"` RateLimiting RateLimiting `yaml:"rate_limiting"`
MSCs *MSCs `yaml:"mscs"` MSCs *MSCs `yaml:"mscs"`
// Disable password authentication.
PasswordAuthenticationDisabled bool `yaml:"password_authentication_disabled"`
// Public key authentication
PublicKeyAuthentication publicKeyAuthentication `yaml:"public_key_authentication"`
} }
func (c *ClientAPI) Defaults(generate bool) { func (c *ClientAPI) Defaults(generate bool) {
@ -127,3 +135,44 @@ func (r *RateLimiting) Defaults() {
r.Threshold = 5 r.Threshold = 5
r.CooloffMS = 500 r.CooloffMS = 500
} }
type ethereumAuthParams struct {
Version uint32 `json:"version"`
ChainIDs []string `json:"chain_ids"`
}
type ethereumAuthConfig struct {
Enabled bool `yaml:"enabled"`
Version uint32 `yaml:"version"`
ChainIDs []string `yaml:"chain_ids"`
}
type publicKeyAuthentication struct {
Ethereum ethereumAuthConfig `yaml:"ethereum"`
}
func (pk *publicKeyAuthentication) Enabled() bool {
return pk.Ethereum.Enabled
}
func (pk *publicKeyAuthentication) GetPublicKeyRegistrationFlows() []authtypes.Flow {
var flows []authtypes.Flow
if pk.Ethereum.Enabled {
flows = append(flows, authtypes.Flow{Stages: []authtypes.LoginType{authtypes.LoginTypePublicKeyEthereum}})
}
return flows
}
func (pk *publicKeyAuthentication) GetPublicKeyRegistrationParams() map[string]interface{} {
params := make(map[string]interface{})
if pk.Ethereum.Enabled {
p := ethereumAuthParams{
Version: pk.Ethereum.Version,
ChainIDs: pk.Ethereum.ChainIDs,
}
params[authtypes.LoginTypePublicKeyEthereum] = p
}
return params
}