From 22297a9bf4d94144aaceb41e97e4a8b8919205ee Mon Sep 17 00:00:00 2001 From: Mario de Frutos Dieguez Date: Tue, 1 Sep 2020 14:50:43 +0200 Subject: [PATCH] [MM-28083] CWS one-time login logic (#15356) * Cloud token login This PR adds the capability of activate the cloud token login that will be used in our Cloud installations to let the customer login for the first time without using credentials. * Read CSRF from cookie when is not on the header and we're login with CWS * Create new CWS login endpoint - New endpoint created - We're using the cloud feature from the license instead of the configuration flag - Removed the CSRF changes * Reduce amount of work if cws token is not set * Removed unused config key * Now we store the token to detect it was used If the token is in the token store then we are assuming that the token was used * Add tests * Add i18n strings --- api4/user.go | 46 +++++++++++++++++++++++-- app/app_iface.go | 2 +- app/login.go | 50 ++++++++++++++++++++++++++-- app/login_test.go | 34 +++++++++++++++++++ app/opentracing/opentracing_layer.go | 4 +-- app/user.go | 1 + i18n/en.json | 8 +++++ 7 files changed, 138 insertions(+), 7 deletions(-) diff --git a/api4/user.go b/api4/user.go index 0aabc9ef51..45d088c1f7 100644 --- a/api4/user.go +++ b/api4/user.go @@ -63,6 +63,7 @@ func (api *API) InitUser() { api.BaseRoutes.Users.Handle("/login", api.ApiHandler(login)).Methods("POST") api.BaseRoutes.Users.Handle("/login/switch", api.ApiHandler(switchAccountType)).Methods("POST") + api.BaseRoutes.Users.Handle("/login/cws", api.ApiHandlerTrustRequester(loginCWS)).Methods("POST") api.BaseRoutes.Users.Handle("/logout", api.ApiHandler(logout)).Methods("POST") api.BaseRoutes.UserByUsername.Handle("", api.ApiSessionRequired(getUserByUsername)).Methods("GET") @@ -1652,7 +1653,6 @@ func login(c *Context, w http.ResponseWriter, r *http.Request) { }() props := model.MapFromJson(r.Body) - id := props["id"] loginId := props["login_id"] password := props["password"] @@ -1686,7 +1686,7 @@ func login(c *Context, w http.ResponseWriter, r *http.Request) { c.LogAuditWithUserId(id, "attempt - login_id="+loginId) - user, err := c.App.AuthenticateUserForLogin(id, loginId, password, mfaToken, ldapOnly) + user, err := c.App.AuthenticateUserForLogin(id, loginId, password, mfaToken, "", ldapOnly) if err != nil { c.LogAuditWithUserId(id, "failure - login_id="+loginId) c.Err = err @@ -1736,6 +1736,48 @@ func login(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(user.ToJson())) } +func loginCWS(c *Context, w http.ResponseWriter, r *http.Request) { + if c.App.Srv().License() == nil || !*c.App.Srv().License().Features.Cloud { + c.Err = model.NewAppError("loginCWS", "api.user.login_cws.license.error", nil, "", http.StatusUnauthorized) + return + } + r.ParseForm() + var loginID string + var token string + if len(r.Form) > 0 { + for key, value := range r.Form { + if key == "login_id" { + loginID = value[0] + } + if key == "cws_token" { + token = value[0] + } + } + } + + auditRec := c.MakeAuditRecord("login", audit.Fail) + defer c.LogAuditRec(auditRec) + auditRec.AddMeta("login_id", loginID) + user, err := c.App.AuthenticateUserForLogin("", loginID, "", "", token, false) + if err != nil { + c.LogAuditWithUserId("", "failure - login_id="+loginID) + mlog.Error("CWS authentication error", mlog.Err(err)) + http.Redirect(w, r, *c.App.Config().ServiceSettings.SiteURL, 302) + return + } + auditRec.AddMeta("user", user) + c.LogAuditWithUserId(user.Id, "authenticated") + err = c.App.DoLogin(w, r, user, "", false, false, false) + if err != nil { + mlog.Error("CWS login error", mlog.Err(err)) + http.Redirect(w, r, *c.App.Config().ServiceSettings.SiteURL, 302) + return + } + c.LogAuditWithUserId(user.Id, "success") + c.App.AttachSessionCookies(w, r) + http.Redirect(w, r, *c.App.Config().ServiceSettings.SiteURL, 302) +} + func logout(c *Context, w http.ResponseWriter, r *http.Request) { Logout(c, w, r) } diff --git a/app/app_iface.go b/app/app_iface.go index 2888bc1a1f..d66feaf7fe 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -361,7 +361,7 @@ type AppIface interface { AsymmetricSigningKey() *ecdsa.PrivateKey AttachDeviceId(sessionId string, deviceId string, expiresAt int64) *model.AppError AttachSessionCookies(w http.ResponseWriter, r *http.Request) - AuthenticateUserForLogin(id, loginId, password, mfaToken string, ldapOnly bool) (user *model.User, err *model.AppError) + AuthenticateUserForLogin(id, loginId, password, mfaToken, cwsToken string, ldapOnly bool) (user *model.User, err *model.AppError) AuthorizeOAuthUser(w http.ResponseWriter, r *http.Request, service, code, state, redirectUri string) (io.ReadCloser, string, map[string]string, *model.AppError) AutocompleteChannels(teamId string, term string) (*model.ChannelList, *model.AppError) AutocompleteChannelsForSearch(teamId string, userId string, term string) (*model.ChannelList, *model.AppError) diff --git a/app/login.go b/app/login.go index 4eaba3252a..33e20f8c75 100644 --- a/app/login.go +++ b/app/login.go @@ -4,19 +4,25 @@ package app import ( + "crypto/subtle" + "errors" "fmt" "net/http" + "os" "strconv" "strings" "time" "github.com/avct/uasurfer" + "github.com/mattermost/mattermost-server/v5/mlog" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/plugin" "github.com/mattermost/mattermost-server/v5/store" "github.com/mattermost/mattermost-server/v5/utils" ) +const cwsTokenEnv = "CWS_CLOUD_TOKEN" + func (a *App) CheckForClientSideCert(r *http.Request) (string, string, string) { pem := r.Header.Get("X-SSL-Client-Cert") // mapped to $ssl_client_cert from nginx subject := r.Header.Get("X-SSL-Client-Cert-Subject-DN") // mapped to $ssl_client_s_dn from nginx @@ -34,7 +40,7 @@ func (a *App) CheckForClientSideCert(r *http.Request) (string, string, string) { return pem, subject, email } -func (a *App) AuthenticateUserForLogin(id, loginId, password, mfaToken string, ldapOnly bool) (user *model.User, err *model.AppError) { +func (a *App) AuthenticateUserForLogin(id, loginId, password, mfaToken, cwsToken string, ldapOnly bool) (user *model.User, err *model.AppError) { // Do statistics defer func() { if a.Metrics() != nil { @@ -46,7 +52,7 @@ func (a *App) AuthenticateUserForLogin(id, loginId, password, mfaToken string, l } }() - if len(password) == 0 { + if len(password) == 0 && !IsCWSLogin(a, cwsToken) { return nil, model.NewAppError("AuthenticateUserForLogin", "api.user.login.blank_pwd.app_error", nil, "", http.StatusBadRequest) } @@ -55,6 +61,42 @@ func (a *App) AuthenticateUserForLogin(id, loginId, password, mfaToken string, l return nil, err } + // CWS login allow to use the one-time token to login the users when they're redirected to their + // installation for the first time + if IsCWSLogin(a, cwsToken) { + if err = checkUserNotBot(user); err != nil { + return nil, err + } + token, err := a.Srv().Store.Token().GetByToken(cwsToken) + if nfErr := new(store.ErrNotFound); err != nil && !errors.As(err, &nfErr) { + mlog.Error("error retrieving the cws token from the store", mlog.Err(err)) + return nil, model.NewAppError("AuthenticateUserForLogin", + "api.user.login_by_cws.invalid_token.app_error", nil, "", http.StatusInternalServerError) + } + // If token is stored in the database that means it was used + if token != nil { + return nil, model.NewAppError("AuthenticateUserForLogin", + "api.user.login_by_cws.invalid_token.app_error", nil, "", http.StatusBadRequest) + } + envToken, ok := os.LookupEnv(cwsTokenEnv) + if ok && subtle.ConstantTimeCompare([]byte(envToken), []byte(cwsToken)) == 1 { + token = &model.Token{ + Token: cwsToken, + CreateAt: model.GetMillis(), + Type: TOKEN_TYPE_CWS_ACCESS, + } + err := a.Srv().Store.Token().Save(token) + if err != nil { + mlog.Error("error storing the cws token in the store", mlog.Err(err)) + return nil, model.NewAppError("AuthenticateUserForLogin", + "api.user.login_by_cws.invalid_token.app_error", nil, "", http.StatusInternalServerError) + } + return user, nil + } + return nil, model.NewAppError("AuthenticateUserForLogin", + "api.user.login_by_cws.invalid_token.app_error", nil, "", http.StatusBadRequest) + } + // If client side cert is enable and it's checking as a primary source // then trust the proxy and cert that the correct user is supplied and allow // them access @@ -246,3 +288,7 @@ func GetProtocol(r *http.Request) string { } return "http" } + +func IsCWSLogin(a *App, token string) bool { + return a.Srv().License() != nil && *a.Srv().License().Features.Cloud && token != "" +} diff --git a/app/login_test.go b/app/login_test.go index 23f7a55707..68c18bc57f 100644 --- a/app/login_test.go +++ b/app/login_test.go @@ -5,8 +5,10 @@ package app import ( "net/http" + "os" "testing" + "github.com/mattermost/mattermost-server/v5/model" "github.com/stretchr/testify/require" ) @@ -35,3 +37,35 @@ func TestCheckForClientSideCert(t *testing.T) { require.Equal(t, actualEmail, tt.expectedEmail, "CheckForClientSideCert(%v): expected %v, actual %v", tt.subject, tt.expectedEmail, actualEmail) } } + +func TestCWSLogin(t *testing.T) { + + th := Setup(t).InitBasic() + defer th.TearDown() + license := model.NewTestLicense() + license.Features.Cloud = model.NewBool(true) + th.App.Srv().SetLicense(license) + + t.Run("Should authenticate user when CWS login is enabled and tokens are equal", func(t *testing.T) { + token := model.NewToken(TOKEN_TYPE_CWS_ACCESS, "") + defer th.App.DeleteToken(token) + os.Setenv("CWS_CLOUD_TOKEN", token.Token) + user, err := th.App.AuthenticateUserForLogin("", th.BasicUser.Username, "", "", token.Token, false) + require.Nil(t, err) + require.NotNil(t, user) + require.Equal(t, th.BasicUser.Username, user.Username) + _, apperr := th.App.Srv().Store.Token().GetByToken(token.Token) + require.Nil(t, apperr) + th.App.DeleteToken(token) + }) + + t.Run("Should not authenticate the user when CWS token was used", func(t *testing.T) { + token := model.NewToken(TOKEN_TYPE_CWS_ACCESS, "") + os.Setenv("CWS_CLOUD_TOKEN", token.Token) + require.Nil(t, th.App.Srv().Store.Token().Save(token)) + defer th.App.DeleteToken(token) + user, err := th.App.AuthenticateUserForLogin("", th.BasicUser.Username, "", "", token.Token, false) + require.Error(t, err) + require.Nil(t, user) + }) +} diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 5bb445bfce..62d04ab534 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -599,7 +599,7 @@ func (a *OpenTracingAppLayer) AttachSessionCookies(w http.ResponseWriter, r *htt a.app.AttachSessionCookies(w, r) } -func (a *OpenTracingAppLayer) AuthenticateUserForLogin(id string, loginId string, password string, mfaToken string, ldapOnly bool) (user *model.User, err *model.AppError) { +func (a *OpenTracingAppLayer) AuthenticateUserForLogin(id string, loginId string, password string, mfaToken string, cwsToken string, ldapOnly bool) (user *model.User, err *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.AuthenticateUserForLogin") @@ -611,7 +611,7 @@ func (a *OpenTracingAppLayer) AuthenticateUserForLogin(id string, loginId string }() defer span.Finish() - resultVar0, resultVar1 := a.app.AuthenticateUserForLogin(id, loginId, password, mfaToken, ldapOnly) + resultVar0, resultVar1 := a.app.AuthenticateUserForLogin(id, loginId, password, mfaToken, cwsToken, ldapOnly) if resultVar1 != nil { span.LogFields(spanlog.Error(resultVar1)) diff --git a/app/user.go b/app/user.go index 527088b89c..3e6cee7d7c 100644 --- a/app/user.go +++ b/app/user.go @@ -42,6 +42,7 @@ const ( TOKEN_TYPE_VERIFY_EMAIL = "verify_email" TOKEN_TYPE_TEAM_INVITATION = "team_invitation" TOKEN_TYPE_GUEST_INVITATION = "guest_invitation" + TOKEN_TYPE_CWS_ACCESS = "cws_access_token" PASSWORD_RECOVER_EXPIRY_TIME = 1000 * 60 * 60 // 1 hour INVITATION_EXPIRY_TIME = 1000 * 60 * 60 * 48 // 48 hours IMAGE_PROFILE_PIXEL_DIMENSION = 128 diff --git a/i18n/en.json b/i18n/en.json index 97cf69d52f..5746d4757a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2906,6 +2906,10 @@ "id": "api.user.login.use_auth_service.app_error", "translation": "Please sign in using {{.AuthService}}." }, + { + "id": "api.user.login_by_cws.invalid_token.app_error", + "translation": "CWS token is not valid" + }, { "id": "api.user.login_by_oauth.bot_login_forbidden.app_error", "translation": "Bot login is forbidden." @@ -2918,6 +2922,10 @@ "id": "api.user.login_by_oauth.parse.app_error", "translation": "Could not parse auth data out of {{.Service}} user object." }, + { + "id": "api.user.login_cws.license.error", + "translation": "CWS login is forbidden." + }, { "id": "api.user.login_ldap.not_available.app_error", "translation": "AD/LDAP not available on this server."