From 9b579351f33848484c0bf68829a8ce10b822cb78 Mon Sep 17 00:00:00 2001 From: Anand Vasudevan Date: Tue, 1 Sep 2020 15:46:15 +0530 Subject: [PATCH] Implement SSO login initial changes Co-authored-by: Rohit Mohan --- clientapi/routing/login.go | 16 ++- clientapi/routing/routing.go | 6 + clientapi/routing/sso.go | 198 ++++++++++++++++++++++++++++ dendrite-config.yaml | 5 + internal/config/config_clientapi.go | 18 +++ 5 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 clientapi/routing/sso.go diff --git a/clientapi/routing/login.go b/clientapi/routing/login.go index d2bc9337db..84bab6f726 100644 --- a/clientapi/routing/login.go +++ b/clientapi/routing/login.go @@ -55,6 +55,16 @@ func passwordLogin() flows { return f } +func ssoLogin() flows { + f := flows{} + s := flow{ + Type: "m.login.sso", + Stages: []string{"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, @@ -62,9 +72,13 @@ func Login( ) 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{ diff --git a/clientapi/routing/routing.go b/clientapi/routing/routing.go index 24343ee199..0878b5e61a 100644 --- a/clientapi/routing/routing.go +++ b/clientapi/routing/routing.go @@ -397,6 +397,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) diff --git a/clientapi/routing/sso.go b/clientapi/routing/sso.go new file mode 100644 index 0000000000..0c451e3504 --- /dev/null +++ b/clientapi/routing/sso.go @@ -0,0 +1,198 @@ +// 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 routing + +import ( + "encoding/xml" + "io/ioutil" + "net/http" + "net/url" + + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/internal/config" + "github.com/matrix-org/dendrite/internal/sqlutil" + "github.com/matrix-org/dendrite/userapi/storage/accounts" + "github.com/matrix-org/util" +) + +// the XML response structure of CAS ticket validation +type casValidateResponse struct { + XMLName xml.Name `xml:"serviceResponse"` + Cas string `xml:"cas,attr"` + AuthenticationSuccess struct { + User string `xml:"user"` + } `xml:"authenticationSuccess"` +} + +// SSORedirect implements GET /login/sso/redirect +func SSORedirect( + req *http.Request, + accountDB accounts.Database, + cfg *config.ClientAPI, +) util.JSONResponse { + // if dendrite is not configured to use SSO by the admin return bad method + if !cfg.CAS.Enabled || cfg.CAS.Server == "" { + return util.JSONResponse{ + Code: http.StatusMethodNotAllowed, + JSON: jsonerror.NotFound("Bad method"), + } + } + + // try to parse the SSO URL configured to a url.URL type + ssoURL, err := url.Parse(cfg.CAS.Server) + if err != nil { + return util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: jsonerror.Unknown("Failed to parse SSO URL configured: " + err.Error()), + } + } + + // a redirect URL is required for this endpoint + redirectURL := req.FormValue("redirectUrl") + if redirectURL == "" { + return util.JSONResponse{ + Code: http.StatusBadRequest, + JSON: jsonerror.MissingArgument("redirectUrl parameter missing"), + } + } + + // if the request has a ticket param, validate the ticket instead of redirecting + // this is because the after validation, the CAS server redirects the browser to the + // "service" url that we send with an added param "ticket" + if ticket := req.FormValue("ticket"); ticket != "" { + return SSOTicket(req, accountDB, cfg) + } + + // add the params to the sso url + ssoQueries := make(url.Values) + // the service url that we send to CAS is homeserver.com/_matrix/client/r0/login/sso/redirect?redirectUrl=xyz + ssoQueries.Set("service", req.RequestURI) + + ssoURL.RawQuery = ssoQueries.Encode() + + return util.RedirectResponse(ssoURL.String()) +} + +// gets the ticket from the SSO server (this is different from the homeserver login/access token) +// generates an login token for the client to login to the homeserver with +func SSOTicket( + req *http.Request, + accountDB accounts.Database, + cfg *config.ClientAPI, +) util.JSONResponse { + // form the ticket validation URL from the config + ssoURL, err := url.Parse(cfg.CAS.Server + cfg.CAS.ValidateEndpoint) + if err != nil { + return util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: jsonerror.Unknown("Failed to parse SSO URL configured: " + err.Error()), + } + } + + ticket := req.FormValue("ticket") + + // append required params to the CAS validate endpoint + ssoQueries := make(url.Values) + ssoQueries.Set("ticket", ticket) + ssoURL.RawQuery = ssoQueries.Encode() + + // validate the ticket + casUsername, err := validateTicket(ticket, cfg) + if err != nil { + // TODO: should I be logging these? What else should I log? + util.GetLogger(req.Context()).WithError(err).Error("CAS SSO ticket validation failed") + return util.JSONResponse{ + Code: http.StatusUnauthorized, + JSON: jsonerror.Unknown("Could not validate SSO token: " + err.Error()), + } + } + if casUsername == "" { + util.GetLogger(req.Context()).WithError(err).Error("CAS SSO returned no user") + return util.JSONResponse{ + Code: http.StatusUnauthorized, + JSON: jsonerror.Unknown("CAS SSO returned no user"), + } + } + + // ticket validated. Login the user + return completeSSOAuth(req, casUsername, accountDB) +} + +// pass the ticket to the sso server to get it validated +// the CAS server responds with an xml which contains the username +func validateTicket( + ssoURL string, + cfg *config.ClientAPI, +) (string, error) { + // make the call to the sso server to validate + response, err := http.Get(ssoURL) + if err != nil { + return "", err + } + + // extract the response from the sso server + data, err := ioutil.ReadAll(response.Body) + if err != nil { + return "", err + } + + // parse the response to the CAS XML format + var res casValidateResponse + if err := xml.Unmarshal([]byte(data), &res); err != nil { + return "", err + } + + return res.AuthenticationSuccess.User, nil +} + +// returns with a redirect to the redirectUrl +func completeSSOAuth( + req *http.Request, + username string, + accountDB accounts.Database, +) util.JSONResponse { + // try to create an account with that username + // if the user exists, then we pick that user, else we create a new user + account, err := accountDB.CreateAccount(req.Context(), username, "", "") + if err != nil { + // some error + if err != sqlutil.ErrUserExists { + return util.JSONResponse{ + Code: http.StatusUnauthorized, + JSON: jsonerror.Unknown("Could not create new user"), + } + } else { + // user already exists, so just pick up their details + account, err = accountDB.GetAccountByLocalpart(req.Context(), username) + if err != nil { + return util.JSONResponse{ + Code: http.StatusUnauthorized, + JSON: jsonerror.Unknown("Could not query user"), + } + } + } + } + + // give the user a login token to use for login + // The generated token should be a macaroon, suitable for use with the m.login.token type of the /login API, and token-based interactive login. The lifetime of this token SHOULD be limited to around five seconds. This token is given to the client via the loginToken query parameter previously mentioned. + // Note that a login token is separate from an access token, the latter providing general authentication to various API endpoints. + + return util.JSONResponse{ + // TODO: change this to a redirect after implementing the token part + Code: http.StatusOK, + JSON: account, + } + +} diff --git a/dendrite-config.yaml b/dendrite-config.yaml index 23f142a83c..3a4211dbff 100644 --- a/dendrite-config.yaml +++ b/dendrite-config.yaml @@ -125,6 +125,11 @@ client_api: recaptcha_bypass_secret: "" recaptcha_siteverify_api: "" + cas: + cas_enabled: true + cas_server: "http://example.com" + cas_validate_endpoint: "/proxyValidate" + # TURN server information that this homeserver should send to clients. turn: turn_user_lifetime: "" diff --git a/internal/config/config_clientapi.go b/internal/config/config_clientapi.go index f7878276ab..30e4ccc745 100644 --- a/internal/config/config_clientapi.go +++ b/internal/config/config_clientapi.go @@ -32,6 +32,9 @@ type ClientAPI struct { // was successful RecaptchaSiteVerifyAPI string `yaml:"recaptcha_siteverify_api"` + // CAS server settings + CAS CAS `yaml:"cas"` + // TURN options TURN TURN `yaml:"turn"` } @@ -47,6 +50,7 @@ func (c *ClientAPI) Defaults() { c.RecaptchaBypassSecret = "" c.RecaptchaSiteVerifyAPI = "" c.RegistrationDisabled = false + c.CAS.Enabled = false } func (c *ClientAPI) Verify(configErrs *ConfigErrors, isMonolith bool) { @@ -61,6 +65,20 @@ func (c *ClientAPI) Verify(configErrs *ConfigErrors, isMonolith bool) { checkNotEmpty(configErrs, "client_api.recaptcha_siteverify_api", string(c.RecaptchaSiteVerifyAPI)) } c.TURN.Verify(configErrs) + c.CAS.Verify(configErrs) +} + +type CAS struct { + Enabled bool `yaml:"cas_enabled"` + Server string `yaml:"cas_server"` + ValidateEndpoint string `yaml:"cas_validate_endpoint"` +} + +func (cas *CAS) Verify(ConfigErrors *ConfigErrors) { + if cas.Enabled { + checkURL(ConfigErrors, "client_api.cas.cas_server", cas.Server) + checkNotEmpty(ConfigErrors, "client_api.cas.cas_validate_endpoint", cas.ValidateEndpoint) + } } type TURN struct {