Skip to content
This repository has been archived by the owner on Nov 25, 2024. It is now read-only.

[WIP] Implement m.login.token and SSO login initial changes #1374

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions clientapi/auth/login_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// 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"
"crypto/rand"
"encoding/base64"
"errors"
"net/http"
"strconv"
"strings"
"time"

"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/gomatrixserverlib"
"github.com/matrix-org/util"
)

// This file handles all the m.login.token logic

// GetAccountByLocalpart function implemented by the appropriate database type
type GetAccountByLocalpart func(ctx context.Context, localpart string) (*api.Account, error)

// LoginTokenRequest struct to hold the possible parameters from an m.login.token http request
type LoginTokenRequest struct {
Login
Token string `json:"token"`
TxnID string `json:"txn_id"`
}

// LoginTypeToken holds the configs and the appropriate GetAccountByLocalpart function for the database
type LoginTypeToken struct {
GetAccountByLocalpart GetAccountByLocalpart
Config *config.ClientAPI
}

// Name returns the expected type of "m.login.token"
func (t *LoginTypeToken) Name() string {
return "m.login.token"
}

// Request returns a struct of type LoginTokenRequest
func (t *LoginTypeToken) Request() interface{} {
return &LoginTokenRequest{}
}

// Type of the LoginToken
type loginToken struct {
UserID string
CreationTime int64
RandomPart string
}

// Login completes the whole token validation, user verification for m.login.token
// returns a struct of type *auth.Login which has the users details
func (t *LoginTypeToken) Login(ctx context.Context, req interface{}) (*Login, *util.JSONResponse) {
r := req.(*LoginTokenRequest)
userID, err := validateLoginToken(r.Token, r.TxnID, &t.Config.Matrix.ServerName)
if err != nil {
return nil, &util.JSONResponse{
Code: http.StatusUnauthorized,
JSON: jsonerror.InvalidArgumentValue(err.Error()),
}
}
r.Login.Identifier.User = userID
r.Login.Identifier.Type = "m.id.user"

return &r.Login, nil
}

// Decodes and validates a LoginToken
// Accepts the base64 encoded token string as param
// Checks the time expiry, userID (only the format, doesn't check to see if the user exists)
// Also checks the DB to see if the token exists
// Returns the localpart if successful
func validateLoginToken(tokenStr string, txnID string, serverName *gomatrixserverlib.ServerName) (string, error) {
token, err := decodeLoginToken(tokenStr)
if err != nil {
return "", err
}

// check whether the token has a valid time.
// TODO: should this 5 second window be configurable?
if time.Now().Unix()-token.CreationTime > 5 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 seconds seems very short, perhaps 1 minute?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wow I see. In which case please just link to this on this line to justify why 5s.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 seconds sounds pretty short to me as well, if you have a connection or DNS failure that could easily time out. It seems that Synapse uses 2 minutes, although I was unable to find any reference to why that was chosen: https://github.com/matrix-org/synapse/blob/78d5f91de1a9baf4dbb0a794cb49a799f29f7a38/synapse/handlers/auth.py#L1323-L1325

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, should I make this time configurable by the admin (with a default time of 5 seconds)?

return "", errors.New("Token has expired")
}

// check whether the UserID is malformed
if !strings.Contains(token.UserID, "@") {
// TODO: should we reveal details about the error with the token or give vague responses instead?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revealing details is fine for now.

return "", errors.New("Invalid UserID")
}
if _, err := userutil.ParseUsernameParam(token.UserID, serverName); err != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add some prose here to explain why this check alone is insufficient (it doesn't check there is an @).

return "", err
}

// check in the database
if err := checkDBToken(tokenStr, txnID); err != nil {
return "", err
}

return token.UserID, nil
}

// GenerateLoginToken creates a login token which is a base64 encoded string of (userID+time+random)
// returns an error if it cannot create a random string
func GenerateLoginToken(userID string) (string, error) {
// the time of token creation
timePart := []byte(strconv.FormatInt(time.Now().Unix(), 10))

// the random part of the token
randPart := make([]byte, 10)
if _, err := rand.Read(randPart); err != nil {
return "", err
}

// url-safe no padding
return base64.RawURLEncoding.EncodeToString([]byte(userID)) + "." + base64.RawURLEncoding.EncodeToString(timePart) + "." + base64.RawURLEncoding.EncodeToString(randPart), nil
}

// Decodes the given tokenStr into a LoginToken struct
func decodeLoginToken(tokenStr string) (*loginToken, error) {
// split the string into it's constituent parts
strParts := strings.Split(tokenStr, ".")
if len(strParts) != 3 {
return nil, errors.New("Malformed token string")
}

var token loginToken
// decode each of the strParts
userBytes, err := base64.RawURLEncoding.DecodeString(strParts[0])
if err != nil {
return nil, errors.New("Invalid user ID")
}
token.UserID = string(userBytes)

// first decode the time to a string
timeBytes, err := base64.RawURLEncoding.DecodeString(strParts[1])
if err != nil {
return nil, errors.New("Invalid creation time")
}
// now convert the string to an integer
creationTime, err := strconv.ParseInt(string(timeBytes), 10, 64)
if err != nil {
return nil, errors.New("Invalid creation time")
}
token.CreationTime = creationTime

randomBytes, err := base64.RawURLEncoding.DecodeString(strParts[2])
if err != nil {
return nil, errors.New("Invalid random part")
}
token.UserID = string(randomBytes)

token = loginToken{
UserID: string(userBytes),
CreationTime: creationTime,
RandomPart: string(randomBytes),
}
return &token, nil
}

// Checks whether the token exists in the DB and whether the token is assigned to the current transaction ID
// Does not validate the userID or the creation time expiry
// Returns nil if successful
func checkDBToken(tokenStr string, txnID string) error {
// if the client has provided a transaction id, try to lock the token to that ID
if txnID != "" {
if err := LinkToken(tokenStr, txnID); err != nil {
// TODO: should we abort the login attempt or something else?
}
}
return nil
}

// StoreLoginToken stores the login token in the database
// Returns nil if successful
func StoreLoginToken(tokenStr string) error {
return nil
}

// DeleteLoginToken Deletes a token from the DB
// used to delete a token that has already been used
// Returns nil if successful
func DeleteLoginToken(tokenStr string) error {
return nil
}

// LinkToken Links a token to a transaction ID so no other client can try to login using that token
// as specified in https://matrix.org/docs/spec/client_server/r0.6.1#token-based
func LinkToken(tokenStr string, txnID string) error {
return nil
}
116 changes: 103 additions & 13 deletions clientapi/routing/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
package routing

import (
"bytes"
"context"
"encoding/json"
"io/ioutil"
"net/http"

"github.com/matrix-org/dendrite/clientapi/auth"
Expand Down Expand Up @@ -53,40 +56,127 @@ func passwordLogin() flows {
return f
}

func ssoLogin() flows {
f := flows{}
s := flow{
Type: "m.login.sso",
}
f.Flows = append(f.Flows, s)
return f
}

// Login implements GET and POST /login
func Login(
req *http.Request, accountDB accounts.Database, userAPI userapi.UserInternalAPI,
cfg *config.ClientAPI,
) util.JSONResponse {
if req.Method == http.MethodGet {
// TODO: support other forms of login other than password, depending on config options
flows := passwordLogin()
if cfg.CAS.Enabled {
flows.Flows = append(flows.Flows, ssoLogin().Flows...)
}
return util.JSONResponse{
Code: http.StatusOK,
JSON: passwordLogin(),
JSON: flows,
}
} else if req.Method == http.MethodPost {
typePassword := auth.LoginTypePassword{
GetAccountByPassword: accountDB.GetAccountByPassword,
Config: cfg,
// TODO: is the the right way to read the body and re-add it?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

body, err := ioutil.ReadAll(req.Body)
if err != nil {
return util.JSONResponse{
Code: http.StatusBadRequest,
JSON: jsonerror.BadJSON("Bad JSON"),
}
}
r := typePassword.Request()
resErr := httputil.UnmarshalJSONRequest(req, r)
if resErr != nil {
return *resErr
// add the body back to the request because ioutil.ReadAll consumes the body
req.Body = ioutil.NopCloser(bytes.NewBuffer(body))

// marshall the body into an unstructured json map
var jsonBody map[string]interface{}
if err := json.Unmarshal([]byte(body), &jsonBody); err != nil {
kegsay marked this conversation as resolved.
Show resolved Hide resolved
return util.JSONResponse{
Code: http.StatusBadRequest,
JSON: jsonerror.BadJSON("Bad JSON"),
}
}

var loginType string
if val, ok := jsonBody["type"]; ok {
loginType = val.(string)
} else {
return util.JSONResponse{
Code: http.StatusBadRequest,
JSON: jsonerror.BadJSON("No 'type' parameter"),
}
}
login, authErr := typePassword.Login(req.Context(), r)
if authErr != nil {
return *authErr
if loginType == "m.login.password" {
return doPasswordLogin(req, accountDB, userAPI, cfg)
} else if loginType == "m.login.token" {
return doTokenLogin(req, accountDB, userAPI, cfg)
}
// make a device/access token
return completeAuth(req.Context(), cfg.Matrix.ServerName, userAPI, login)
}

return util.JSONResponse{
Code: http.StatusMethodNotAllowed,
JSON: jsonerror.NotFound("Bad method"),
}
}

// Handles a m.login.password login type request
func doPasswordLogin(
req *http.Request, accountDB accounts.Database, userAPI userapi.UserInternalAPI,
cfg *config.ClientAPI,
) util.JSONResponse {
typePassword := auth.LoginTypePassword{
GetAccountByPassword: accountDB.GetAccountByPassword,
Config: cfg,
}
r := typePassword.Request()
resErr := httputil.UnmarshalJSONRequest(req, r)
if resErr != nil {
return *resErr
}
login, authErr := typePassword.Login(req.Context(), r)
if authErr != nil {
return *authErr
}

// make a device/access token
return completeAuth(req.Context(), cfg.Matrix.ServerName, userAPI, login)
}

// Handles a m.login.token login type request
func doTokenLogin(req *http.Request, accountDB accounts.Database, userAPI userapi.UserInternalAPI,
cfg *config.ClientAPI,
) util.JSONResponse {
// create a struct with the appropriate DB(postgres/sqlite) function and the configs
typeToken := auth.LoginTypeToken{
GetAccountByLocalpart: accountDB.GetAccountByLocalpart,
Config: cfg,
}
r := typeToken.Request()
resErr := httputil.UnmarshalJSONRequest(req, r)
if resErr != nil {
return *resErr
}
login, authErr := typeToken.Login(req.Context(), r)
if authErr != nil {
return *authErr
}

// make a device/access token
authResult := completeAuth(req.Context(), cfg.Matrix.ServerName, userAPI, login)

// the login is successful, delete the login token before returning the access token to the client
if authResult.Code == http.StatusOK {
if err := auth.DeleteLoginToken(r.(*auth.LoginTokenRequest).Token); err != nil {
util.GetLogger(req.Context()).WithError(err).Error("Could not delete login ticket from DB")
}
}
return authResult
}

func completeAuth(
ctx context.Context, serverName gomatrixserverlib.ServerName, userAPI userapi.UserInternalAPI, login *auth.Login,
) util.JSONResponse {
Expand Down
6 changes: 6 additions & 0 deletions clientapi/routing/routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,12 @@ func Setup(
}),
).Methods(http.MethodGet, http.MethodPost, http.MethodOptions)

r0mux.Handle("/login/sso/redirect",
httputil.MakeExternalAPI("login", func(req *http.Request) util.JSONResponse {
return SSORedirect(req, accountDB, cfg)
}),
).Methods(http.MethodGet, http.MethodOptions)

r0mux.Handle("/auth/{authType}/fallback/web",
httputil.MakeHTMLAPI("auth_fallback", func(w http.ResponseWriter, req *http.Request) *util.JSONResponse {
vars := mux.Vars(req)
Expand Down
Loading