Implement client API routes for 3PID handling (#205)

* Create package for handling 3pid processes and move invite processing there

* Add database table and functions for tracking 3PIDs

* Add structures and functions to interact with an ID server

* Add handlers for 3PIDs management

* Fix 3PIDs retrieval sending null if no 3PID known for a user

* Include medium in database requests and function calls

* Publish an association if it has been validated and requested

* Add TODO markers for tursted ID server check

* Use a structure instead of a map to represent a 3PID
This commit is contained in:
Brendan Abolivier 2017-09-01 10:13:10 +01:00 committed by Mark Haines
parent 8c2e6273e3
commit 960af3d628
8 changed files with 540 additions and 16 deletions

View File

@ -0,0 +1,21 @@
// 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 authtypes
// ThreePID represents a third-party identifier
type ThreePID struct {
Address string `json:"address"`
Medium string `json:"medium"`
}

View File

@ -16,6 +16,7 @@ package accounts
import ( import (
"database/sql" "database/sql"
"errors"
"github.com/matrix-org/dendrite/clientapi/auth/authtypes" "github.com/matrix-org/dendrite/clientapi/auth/authtypes"
"github.com/matrix-org/dendrite/common" "github.com/matrix-org/dendrite/common"
@ -33,6 +34,7 @@ type Database struct {
profiles profilesStatements profiles profilesStatements
memberships membershipStatements memberships membershipStatements
accountDatas accountDataStatements accountDatas accountDataStatements
threepids threepidStatements
serverName gomatrixserverlib.ServerName serverName gomatrixserverlib.ServerName
} }
@ -63,7 +65,11 @@ func NewDatabase(dataSourceName string, serverName gomatrixserverlib.ServerName)
if err = ac.prepare(db); err != nil { if err = ac.prepare(db); err != nil {
return nil, err return nil, err
} }
return &Database{db, partitions, a, p, m, ac, serverName}, nil t := threepidStatements{}
if err = t.prepare(db); err != nil {
return nil, err
}
return &Database{db, partitions, a, p, m, ac, t, serverName}, nil
} }
// GetAccountByPassword returns the account associated with the given localpart and password. // GetAccountByPassword returns the account associated with the given localpart and password.
@ -233,3 +239,51 @@ func hashPassword(plaintext string) (hash string, err error) {
hashBytes, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.DefaultCost) hashBytes, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.DefaultCost)
return string(hashBytes), err return string(hashBytes), err
} }
// Err3PIDInUse is the error returned when trying to save an association involving
// a third-party identifier which is already associated to a local user.
var Err3PIDInUse = errors.New("This third-party identifier is already in use")
// SaveThreePIDAssociation saves the association between a third party identifier
// and a local Matrix user (identified by the user's ID's local part).
// If the third-party identifier is already part of an association, returns Err3PIDInUse.
// Returns an error if there was a problem talking to the database.
func (d *Database) SaveThreePIDAssociation(threepid string, localpart string, medium string) (err error) {
return common.WithTransaction(d.db, func(txn *sql.Tx) error {
user, err := d.threepids.selectLocalpartForThreePID(txn, threepid, medium)
if err != nil {
return err
}
if len(user) > 0 {
return Err3PIDInUse
}
return d.threepids.insertThreePID(txn, threepid, medium, localpart)
})
}
// RemoveThreePIDAssociation removes the association involving a given third-party
// identifier.
// If no association exists involving this third-party identifier, returns nothing.
// If there was a problem talking to the database, returns an error.
func (d *Database) RemoveThreePIDAssociation(threepid string, medium string) (err error) {
return d.threepids.deleteThreePID(threepid, medium)
}
// GetLocalpartForThreePID looks up the localpart associated with a given third-party
// identifier.
// If no association involves the given third-party idenfitier, returns an empty
// string.
// Returns an error if there was a problem talking to the database.
func (d *Database) GetLocalpartForThreePID(threepid string, medium string) (localpart string, err error) {
return d.threepids.selectLocalpartForThreePID(nil, threepid, medium)
}
// GetThreePIDsForLocalpart looks up the third-party identifiers associated with
// a given local user.
// If no association is known for this user, returns an empty slice.
// Returns an error if there was an issue talking to the database.
func (d *Database) GetThreePIDsForLocalpart(localpart string) (threepids []authtypes.ThreePID, err error) {
return d.threepids.selectThreePIDsForLocalpart(localpart)
}

View File

@ -0,0 +1,120 @@
// 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 accounts
import (
"database/sql"
"github.com/matrix-org/dendrite/clientapi/auth/authtypes"
)
const threepidSchema = `
-- Stores data about third party identifiers
CREATE TABLE IF NOT EXISTS account_threepid (
-- The third party identifier
threepid TEXT NOT NULL,
-- The 3PID medium
medium TEXT NOT NULL DEFAULT 'email',
-- The localpart of the Matrix user ID associated to this 3PID
localpart TEXT NOT NULL,
PRIMARY KEY(threepid, medium)
);
CREATE INDEX IF NOT EXISTS account_threepid_localpart ON account_threepid(localpart);
`
const selectLocalpartForThreePIDSQL = "" +
"SELECT localpart FROM account_threepid WHERE threepid = $1 AND medium = $2"
const selectThreePIDsForLocalpartSQL = "" +
"SELECT threepid, medium FROM account_threepid WHERE localpart = $1"
const insertThreePIDSQL = "" +
"INSERT INTO account_threepid (threepid, medium, localpart) VALUES ($1, $2, $3)"
const deleteThreePIDSQL = "" +
"DELETE FROM account_threepid WHERE threepid = $1 AND medium = $2"
type threepidStatements struct {
selectLocalpartForThreePIDStmt *sql.Stmt
selectThreePIDsForLocalpartStmt *sql.Stmt
insertThreePIDStmt *sql.Stmt
deleteThreePIDStmt *sql.Stmt
}
func (s *threepidStatements) prepare(db *sql.DB) (err error) {
_, err = db.Exec(threepidSchema)
if err != nil {
return
}
if s.selectLocalpartForThreePIDStmt, err = db.Prepare(selectLocalpartForThreePIDSQL); err != nil {
return
}
if s.selectThreePIDsForLocalpartStmt, err = db.Prepare(selectThreePIDsForLocalpartSQL); err != nil {
return
}
if s.insertThreePIDStmt, err = db.Prepare(insertThreePIDSQL); err != nil {
return
}
if s.deleteThreePIDStmt, err = db.Prepare(deleteThreePIDSQL); err != nil {
return
}
return
}
func (s *threepidStatements) selectLocalpartForThreePID(txn *sql.Tx, threepid string, medium string) (localpart string, err error) {
var stmt *sql.Stmt
if txn != nil {
stmt = txn.Stmt(s.selectLocalpartForThreePIDStmt)
} else {
stmt = s.selectLocalpartForThreePIDStmt
}
err = stmt.QueryRow(threepid, medium).Scan(&localpart)
if err == sql.ErrNoRows {
return "", nil
}
return
}
func (s *threepidStatements) selectThreePIDsForLocalpart(localpart string) (threepids []authtypes.ThreePID, err error) {
rows, err := s.selectThreePIDsForLocalpartStmt.Query(localpart)
if err != nil {
return
}
threepids = []authtypes.ThreePID{}
for rows.Next() {
var threepid string
var medium string
if err = rows.Scan(&threepid, &medium); err != nil {
return
}
threepids = append(threepids, authtypes.ThreePID{threepid, medium})
}
return
}
func (s *threepidStatements) insertThreePID(txn *sql.Tx, threepid string, medium string, localpart string) (err error) {
_, err = txn.Stmt(s.insertThreePIDStmt).Exec(threepid, medium, localpart)
return
}
func (s *threepidStatements) deleteThreePID(threepid string, medium string) (err error) {
_, err = s.deleteThreePIDStmt.Exec(threepid, medium)
return
}

View File

@ -0,0 +1,160 @@
// 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 readers
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/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) 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(body.Email, "email")
if err != nil {
return httputil.LogThenError(req, err)
}
if len(localpart) > 0 {
return util.JSONResponse{
Code: 400,
JSON: jsonerror.MatrixError{
ErrCode: "M_THREEPID_IN_USE",
Err: accounts.Err3PIDInUse.Error(),
},
}
}
resp.SID, err = threepid.CreateSession(body)
if err != nil {
return httputil.LogThenError(req, err)
}
return util.JSONResponse{
Code: 200,
JSON: resp,
}
}
// CheckAndSave3PIDAssociation implements POST /account/3pid
func CheckAndSave3PIDAssociation(
req *http.Request, accountDB *accounts.Database, device *authtypes.Device,
) 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(body.Creds)
if err != nil {
return httputil.LogThenError(req, err)
}
if !verified {
return util.JSONResponse{
Code: 400,
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
if err = threepid.PublishAssociation(body.Creds, device.UserID); 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(address, localpart, medium); err != nil {
return httputil.LogThenError(req, err)
}
return util.JSONResponse{
Code: 200,
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(localpart)
if err != nil {
return httputil.LogThenError(req, err)
}
return util.JSONResponse{
Code: 200,
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(body.Address, body.Medium); err != nil {
return httputil.LogThenError(req, err)
}
return util.JSONResponse{
Code: 200,
JSON: struct{}{},
}
}

View File

@ -234,15 +234,28 @@ func Setup(
// PUT requests, so we need to allow this method // PUT requests, so we need to allow this method
r0mux.Handle("/account/3pid", r0mux.Handle("/account/3pid",
common.MakeAPI("account_3pid", func(req *http.Request) util.JSONResponse { common.MakeAuthAPI("account_3pid", deviceDB, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
// TODO: Get 3pid data for user ID return readers.GetAssociated3PIDs(req, accountDB, device)
res := json.RawMessage(`{"threepids":[]}`)
return util.JSONResponse{
Code: 200,
JSON: &res,
}
}), }),
) ).Methods("GET")
r0mux.Handle("/account/3pid",
common.MakeAuthAPI("account_3pid", deviceDB, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
return readers.CheckAndSave3PIDAssociation(req, accountDB, device)
}),
).Methods("POST", "OPTIONS")
unstableMux.Handle("/account/3pid/delete",
common.MakeAuthAPI("account_3pid", deviceDB, func(req *http.Request, device *authtypes.Device) util.JSONResponse {
return readers.Forget3PID(req, accountDB)
}),
).Methods("POST", "OPTIONS")
r0mux.Handle("/{path:(?:account/3pid|register)}/email/requestToken",
common.MakeAPI("account_3pid_request_token", func(req *http.Request) util.JSONResponse {
return readers.RequestEmailToken(req, accountDB)
}),
).Methods("POST", "OPTIONS")
// Riot logs get flooded unless this is handled // Riot logs get flooded unless this is handled
r0mux.Handle("/presence/{userID}/status", r0mux.Handle("/presence/{userID}/status",

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
package thirdpartyinvites package threepid
import ( import (
"encoding/json" "encoding/json"
@ -66,7 +66,7 @@ type idServerStoreInviteResponse struct {
PublicKeys []common.PublicKey `json:"public_keys"` PublicKeys []common.PublicKey `json:"public_keys"`
} }
// CheckAndProcess analyses the body of an incoming membership request. // CheckAndProcessInvite analyses the body of an incoming membership request.
// If the fields relative to a third-party-invite are all supplied, lookups the // If the fields relative to a third-party-invite are all supplied, lookups the
// matching Matrix ID from the given identity server. If no Matrix ID is // matching Matrix ID from the given identity server. If no Matrix ID is
// associated to the given 3PID, asks the identity server to store the invite // associated to the given 3PID, asks the identity server to store the invite
@ -79,7 +79,7 @@ type idServerStoreInviteResponse struct {
// must be processed as a non-3PID membership request. In the latter case, // must be processed as a non-3PID membership request. In the latter case,
// fills the Matrix ID in the request body so a normal invite membership event // fills the Matrix ID in the request body so a normal invite membership event
// can be emitted. // can be emitted.
func CheckAndProcess( func CheckAndProcessInvite(
req *http.Request, device *authtypes.Device, body *MembershipRequest, req *http.Request, device *authtypes.Device, body *MembershipRequest,
cfg config.Dendrite, queryAPI api.RoomserverQueryAPI, db *accounts.Database, cfg config.Dendrite, queryAPI api.RoomserverQueryAPI, db *accounts.Database,
producer *producers.RoomserverProducer, membership string, roomID string, producer *producers.RoomserverProducer, membership string, roomID string,

View File

@ -0,0 +1,156 @@
// 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 threepid
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
)
// EmailAssociationRequest represents the request defined at https://matrix.org/docs/spec/client_server/r0.2.0.html#post-matrix-client-r0-register-email-requesttoken
type EmailAssociationRequest struct {
IDServer string `json:"id_server"`
Secret string `json:"client_secret"`
Email string `json:"email"`
SendAttempt int `json:"send_attempt"`
}
// EmailAssociationCheckRequest represents the request defined at https://matrix.org/docs/spec/client_server/r0.2.0.html#post-matrix-client-r0-account-3pid
type EmailAssociationCheckRequest struct {
Creds Credentials `json:"threePidCreds"`
Bind bool `json:"bind"`
}
// Credentials represents the "ThreePidCredentials" structure defined at https://matrix.org/docs/spec/client_server/r0.2.0.html#post-matrix-client-r0-account-3pid
type Credentials struct {
SID string `json:"sid"`
IDServer string `json:"id_server"`
Secret string `json:"client_secret"`
}
// CreateSession creates a session on an identity server.
// Returns the session's ID.
// Returns an error if there was a problem sending the request or decoding the
// response, or if the identity server responded with a non-OK status.
func CreateSession(req EmailAssociationRequest) (string, error) {
// TODO: Check if the ID server is trusted
// Create a session on the ID server
postURL := fmt.Sprintf("https://%s/_matrix/identity/api/v1/validate/email/requestToken", req.IDServer)
data := url.Values{}
data.Add("client_secret", req.Secret)
data.Add("email", req.Email)
data.Add("send_attempt", strconv.Itoa(req.SendAttempt))
request, err := http.NewRequest("POST", postURL, strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
client := http.Client{}
resp, err := client.Do(request)
if err != nil {
return "", err
}
// Error if the status isn't OK
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Could not create a session on the server %s", req.IDServer)
}
// Extract the SID from the response and return it
var sid struct {
SID string `json:"sid"`
}
err = json.NewDecoder(resp.Body).Decode(&sid)
return sid.SID, err
}
// CheckAssociation checks the status of an ongoing association validation on an
// identity server.
// Returns a boolean set to true if the association has been validated, false if not.
// If the association has been validated, also returns the related third-party
// identifier and its medium.
// Returns an error if there was a problem sending the request or decoding the
// response, or if the identity server responded with a non-OK status.
func CheckAssociation(creds Credentials) (bool, string, string, error) {
// TODO: Check if the ID server is trusted
url := fmt.Sprintf("https://%s/_matrix/identity/api/v1/3pid/getValidated3pid?sid=%s&client_secret=%s", creds.IDServer, creds.SID, creds.Secret)
resp, err := http.Get(url)
if err != nil {
return false, "", "", err
}
var respBody struct {
Medium string `json:"medium"`
ValidatedAt int64 `json:"validated_at"`
Address string `json:"address"`
ErrCode string `json:"errcode"`
Error string `json:"error"`
}
if err = json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
return false, "", "", err
}
if respBody.ErrCode == "M_SESSION_NOT_VALIDATED" {
return false, "", "", nil
} else if len(respBody.ErrCode) > 0 {
return false, "", "", errors.New(respBody.Error)
}
return true, respBody.Address, respBody.Medium, nil
}
// PublishAssociation publishes a validated association between a third-party
// identifier and a Matrix ID.
// Returns an error if there was a problem sending the request or decoding the
// response, or if the identity server responded with a non-OK status.
func PublishAssociation(creds Credentials, userID string) error {
// TODO: Check if the ID server is trusted
postURL := fmt.Sprintf("https://%s/_matrix/identity/api/v1/3pid/bind", creds.IDServer)
data := url.Values{}
data.Add("sid", creds.SID)
data.Add("client_secret", creds.Secret)
data.Add("mxid", userID)
request, err := http.NewRequest("POST", postURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
client := http.Client{}
resp, err := client.Do(request)
if err != nil {
return err
}
// Error if the status isn't OK
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Could not publish the association on the server %s", creds.IDServer)
}
return nil
}

View File

@ -23,7 +23,7 @@ import (
"github.com/matrix-org/dendrite/clientapi/httputil" "github.com/matrix-org/dendrite/clientapi/httputil"
"github.com/matrix-org/dendrite/clientapi/jsonerror" "github.com/matrix-org/dendrite/clientapi/jsonerror"
"github.com/matrix-org/dendrite/clientapi/producers" "github.com/matrix-org/dendrite/clientapi/producers"
"github.com/matrix-org/dendrite/clientapi/thirdpartyinvites" "github.com/matrix-org/dendrite/clientapi/threepid"
"github.com/matrix-org/dendrite/common" "github.com/matrix-org/dendrite/common"
"github.com/matrix-org/dendrite/common/config" "github.com/matrix-org/dendrite/common/config"
"github.com/matrix-org/dendrite/roomserver/api" "github.com/matrix-org/dendrite/roomserver/api"
@ -39,12 +39,12 @@ func SendMembership(
roomID string, membership string, cfg config.Dendrite, roomID string, membership string, cfg config.Dendrite,
queryAPI api.RoomserverQueryAPI, producer *producers.RoomserverProducer, queryAPI api.RoomserverQueryAPI, producer *producers.RoomserverProducer,
) util.JSONResponse { ) util.JSONResponse {
var body thirdpartyinvites.MembershipRequest var body threepid.MembershipRequest
if reqErr := httputil.UnmarshalJSONRequest(req, &body); reqErr != nil { if reqErr := httputil.UnmarshalJSONRequest(req, &body); reqErr != nil {
return *reqErr return *reqErr
} }
if res := thirdpartyinvites.CheckAndProcess( if res := threepid.CheckAndProcessInvite(
req, device, &body, cfg, queryAPI, accountDB, producer, membership, roomID, req, device, &body, cfg, queryAPI, accountDB, producer, membership, roomID,
); res != nil { ); res != nil {
return *res return *res
@ -129,7 +129,7 @@ func loadProfile(userID string, cfg config.Dendrite, accountDB *accounts.Databas
// In the latter case, if there was an issue retrieving the user ID from the 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. // returns a JSONResponse with a corresponding error code and message.
func getMembershipStateKey( func getMembershipStateKey(
body thirdpartyinvites.MembershipRequest, device *authtypes.Device, membership string, body threepid.MembershipRequest, device *authtypes.Device, membership string,
) (stateKey string, reason string, response *util.JSONResponse) { ) (stateKey string, reason string, response *util.JSONResponse) {
if membership == "ban" || membership == "unban" || membership == "kick" || membership == "invite" { if membership == "ban" || membership == "unban" || membership == "kick" || membership == "invite" {
// If we're in this case, the state key is contained in the request body, // If we're in this case, the state key is contained in the request body,