From a3b194581f13d18df55dd0f592c56f52c19b4a6f Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Wed, 30 Aug 2023 11:21:43 -0400 Subject: [PATCH] [MM-37984] Allow Desktop App to authenticate via external providers outside of the app on supported servers (#24140) * [MM-37984] Allow Desktop App to authenticate via external providers outside of the app on supported servers * PR feedback * Add support for mattermost-dev protocol for development use * Update server/channels/db/migrations/postgres/000110_create_desktop_tokens.up.sql * Fix silly typo * Update server/channels/db/migrations/postgres/000110_create_desktop_tokens.up.sql * Remove storage of client token, only validate it on the client * Update migrations * Add concurrently create index * Remove CONCURRENTLY for now * Fix issue with changing history * Remove old migration * Use idempotent statement to drop old index * Remove reference to old table --- server/channels/api4/handlers.go | 13 + server/channels/api4/user.go | 25 ++ server/channels/app/app_iface.go | 6 +- server/channels/app/desktop_login.go | 48 ++++ server/channels/app/desktop_login_test.go | 75 ++++++ server/channels/app/oauth.go | 12 +- .../app/opentracing/opentracing_layer.go | 52 +++- server/channels/app/server.go | 7 + server/channels/db/migrations/migrations.list | 4 + .../000112_rework_desktop_tokens.down.sql | 16 ++ .../mysql/000112_rework_desktop_tokens.up.sql | 38 +++ .../000112_rework_desktop_tokens.down.sql | 2 + .../000112_rework_desktop_tokens.up.sql | 11 + .../jobs/cleanup_desktop_tokens/scheduler.go | 20 ++ .../jobs/cleanup_desktop_tokens/worker.go | 36 +++ .../opentracinglayer/opentracinglayer.go | 101 ++++++++ .../channels/store/retrylayer/retrylayer.go | 116 +++++++++ .../store/retrylayer/retrylayer_test.go | 1 + .../store/sqlstore/desktop_tokens_store.go | 123 ++++++++++ .../sqlstore/desktop_tokens_store_test.go | 14 ++ server/channels/store/sqlstore/store.go | 6 + server/channels/store/store.go | 9 + .../store/storetest/desktop_tokens_store.go | 110 +++++++++ .../storetest/mocks/DesktopTokensStore.go | 109 +++++++++ .../channels/store/storetest/mocks/Store.go | 16 ++ server/channels/store/storetest/store.go | 3 + .../channels/store/timerlayer/timerlayer.go | 91 +++++++ server/channels/web/oauth.go | 38 ++- server/channels/web/saml.go | 30 +++ server/i18n/en.json | 12 + server/public/model/job.go | 2 + server/public/model/user.go | 2 + webapp/channels/src/actions/views/login.ts | 34 +++ .../src/components/desktop_auth_token.scss | 38 +++ .../src/components/desktop_auth_token.tsx | 224 ++++++++++++++++++ .../external_login_button.tsx | 3 + .../channels/src/components/login/login.tsx | 62 ++++- .../signup/__snapshots__/signup.test.tsx.snap | 7 + .../channels/src/components/signup/signup.tsx | 58 ++++- webapp/channels/src/i18n/en.json | 7 + webapp/platform/client/src/client4.ts | 12 + 41 files changed, 1569 insertions(+), 24 deletions(-) create mode 100644 server/channels/app/desktop_login.go create mode 100644 server/channels/app/desktop_login_test.go create mode 100644 server/channels/db/migrations/mysql/000112_rework_desktop_tokens.down.sql create mode 100644 server/channels/db/migrations/mysql/000112_rework_desktop_tokens.up.sql create mode 100644 server/channels/db/migrations/postgres/000112_rework_desktop_tokens.down.sql create mode 100644 server/channels/db/migrations/postgres/000112_rework_desktop_tokens.up.sql create mode 100644 server/channels/jobs/cleanup_desktop_tokens/scheduler.go create mode 100644 server/channels/jobs/cleanup_desktop_tokens/worker.go create mode 100644 server/channels/store/sqlstore/desktop_tokens_store.go create mode 100644 server/channels/store/sqlstore/desktop_tokens_store_test.go create mode 100644 server/channels/store/storetest/desktop_tokens_store.go create mode 100644 server/channels/store/storetest/mocks/DesktopTokensStore.go create mode 100644 webapp/channels/src/components/desktop_auth_token.scss create mode 100644 webapp/channels/src/components/desktop_auth_token.tsx diff --git a/server/channels/api4/handlers.go b/server/channels/api4/handlers.go index e5bbd3115b..2c54e730a2 100644 --- a/server/channels/api4/handlers.go +++ b/server/channels/api4/handlers.go @@ -9,6 +9,8 @@ import ( "github.com/mattermost/gziphandler" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/channels/app" "github.com/mattermost/mattermost/server/v8/channels/web" ) @@ -200,6 +202,17 @@ func (api *API) APILocal(h handlerFunc) http.Handler { return handler } +func (api *API) RateLimitedHandler(apiHandler http.Handler, settings model.RateLimitSettings) http.Handler { + settings.SetDefaults() + + rateLimiter, err := app.NewRateLimiter(&settings, []string{}) + if err != nil { + mlog.Error("getRateLimitedHandler", mlog.Err(err)) + return nil + } + return rateLimiter.RateLimitHandler(apiHandler) +} + func requireLicense(c *Context) *model.AppError { if c.App.Channels().License() == nil { err := model.NewAppError("", "api.license_error", nil, "", http.StatusNotImplemented) diff --git a/server/channels/api4/user.go b/server/channels/api4/user.go index f8f9787a20..bfa768d04c 100644 --- a/server/channels/api4/user.go +++ b/server/channels/api4/user.go @@ -61,6 +61,7 @@ func (api *API) InitUser() { api.BaseRoutes.User.Handle("/mfa/generate", api.APISessionRequiredMfa(generateMfaSecret)).Methods("POST") api.BaseRoutes.Users.Handle("/login", api.APIHandler(login)).Methods("POST") + api.BaseRoutes.Users.Handle("/login/desktop_token", api.RateLimitedHandler(api.APIHandler(loginWithDesktopToken), model.RateLimitSettings{PerSec: model.NewInt(2), MaxBurst: model.NewInt(1)})).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") @@ -1978,6 +1979,30 @@ func login(c *Context, w http.ResponseWriter, r *http.Request) { } } +func loginWithDesktopToken(c *Context, w http.ResponseWriter, r *http.Request) { + props := model.MapFromJSON(r.Body) + token := props["token"] + deviceId := props["device_id"] + + user, err := c.App.ValidateDesktopToken(token, time.Now().Add(-model.DesktopTokenTTL).Unix()) + if err != nil { + c.Err = err + return + } + + err = c.App.DoLogin(c.AppContext, w, r, user, deviceId, false, false, false) + if err != nil { + c.Err = err + return + } + + c.App.AttachSessionCookies(c.AppContext, w, r) + + if err := json.NewEncoder(w).Encode(user); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + func loginCWS(c *Context, w http.ResponseWriter, r *http.Request) { campaignToURL := map[string]string{ "focalboard": "/boards", diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 4e47444fb7..3198690d4d 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -577,6 +577,7 @@ type AppIface interface { FilterUsersByVisible(viewer *model.User, otherUsers []*model.User) ([]*model.User, *model.AppError) FindTeamByName(name string) bool FinishSendAdminNotifyPost(trial bool, now int64, pluginBasedData map[string][]*model.NotifyAdminData) + GenerateAndSaveDesktopToken(expiryTime int64, user *model.User) (*string, *model.AppError) GenerateMfaSecret(userID string) (*model.MfaSecret, *model.AppError) GeneratePresignURLForExport(name string) (*model.PresignURLResponse, *model.AppError) GeneratePublicLink(siteURL string, info *model.FileInfo) string @@ -702,8 +703,8 @@ type AppIface interface { GetOAuthAppsByCreator(userID string, page, perPage int) ([]*model.OAuthApp, *model.AppError) GetOAuthCodeRedirect(userID string, authRequest *model.AuthorizeRequest) (string, *model.AppError) GetOAuthImplicitRedirect(userID string, authRequest *model.AuthorizeRequest) (string, *model.AppError) - GetOAuthLoginEndpoint(w http.ResponseWriter, r *http.Request, service, teamID, action, redirectTo, loginHint string, isMobile bool) (string, *model.AppError) - GetOAuthSignupEndpoint(w http.ResponseWriter, r *http.Request, service, teamID string) (string, *model.AppError) + GetOAuthLoginEndpoint(w http.ResponseWriter, r *http.Request, service, teamID, action, redirectTo, loginHint string, isMobile bool, desktopToken string) (string, *model.AppError) + GetOAuthSignupEndpoint(w http.ResponseWriter, r *http.Request, service, teamID string, desktopToken string) (string, *model.AppError) GetOAuthStateToken(token string) (*model.Token, *model.AppError) GetOnboarding() (*model.System, *model.AppError) GetOpenGraphMetadata(requestURL string) ([]byte, error) @@ -1169,6 +1170,7 @@ type AppIface interface { UserAlreadyNotifiedOnRequiredFeature(user string, feature model.MattermostFeature) bool UserCanSeeOtherUser(userID string, otherUserId string) (bool, *model.AppError) UserIsFirstAdmin(user *model.User) bool + ValidateDesktopToken(token string, expiryTime int64) (*model.User, *model.AppError) VerifyEmailFromToken(c request.CTX, userSuppliedTokenString string) *model.AppError VerifyUserEmail(userID, email string) *model.AppError ViewChannel(c request.CTX, view *model.ChannelView, userID string, currentSessionId string, collapsedThreadsSupported bool) (map[string]int64, *model.AppError) diff --git a/server/channels/app/desktop_login.go b/server/channels/app/desktop_login.go new file mode 100644 index 0000000000..529e8a65fd --- /dev/null +++ b/server/channels/app/desktop_login.go @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + + "github.com/mattermost/mattermost/server/public/model" +) + +func (a *App) GenerateAndSaveDesktopToken(expiryTime int64, user *model.User) (*string, *model.AppError) { + token := model.NewRandomString(64) + err := a.Srv().Store().DesktopTokens().Insert(token, expiryTime, user.Id) + if err != nil { + // Delete any other related tokens if there's an error + a.Srv().Store().DesktopTokens().DeleteByUserId(user.Id) + + return nil, model.NewAppError("GenerateAndSaveDesktopToken", "app.desktop_token.generateServerToken.invalid_or_expired", nil, err.Error(), http.StatusBadRequest) + } + + return &token, nil +} + +func (a *App) ValidateDesktopToken(token string, expiryTime int64) (*model.User, *model.AppError) { + // Check if token is valid + userId, err := a.Srv().Store().DesktopTokens().GetUserId(token, expiryTime) + if err != nil { + // Delete the token if it is expired or invalid + a.Srv().Store().DesktopTokens().Delete(token) + + return nil, model.NewAppError("ValidateDesktopToken", "app.desktop_token.validate.invalid", nil, err.Error(), http.StatusUnauthorized) + } + + // Get the user profile + user, userErr := a.GetUser(*userId) + if userErr != nil { + // Delete the token if the user is invalid somehow + a.Srv().Store().DesktopTokens().Delete(token) + + return nil, model.NewAppError("ValidateDesktopToken", "app.desktop_token.validate.no_user", nil, userErr.Error(), http.StatusInternalServerError) + } + + // Clean up other tokens if they exist + a.Srv().Store().DesktopTokens().DeleteByUserId(*userId) + + return user, nil +} diff --git a/server/channels/app/desktop_login_test.go b/server/channels/app/desktop_login_test.go new file mode 100644 index 0000000000..56feaa1a93 --- /dev/null +++ b/server/channels/app/desktop_login_test.go @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + TTL = time.Minute * 3 + ExpiredLength = time.Minute * 10 +) + +func TestGenerateAndSaveDesktopToken(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Run("generate token", func(t *testing.T) { + token, err := th.App.GenerateAndSaveDesktopToken(time.Now().Add(-TTL).Unix(), th.BasicUser) + assert.Nil(t, err) + assert.NotNil(t, token) + }) +} + +func TestValidateDesktopToken(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + authenticatedServerToken, err := th.App.GenerateAndSaveDesktopToken(time.Now().Add(-TTL).Unix(), th.BasicUser) + require.Nil(t, err) + require.NotNil(t, authenticatedServerToken) + + expiredServerToken, err := th.App.GenerateAndSaveDesktopToken(time.Now().Add(-ExpiredLength).Unix(), th.BasicUser2) + require.Nil(t, err) + require.NotNil(t, expiredServerToken) + + badUser := model.User{Id: "some_garbage_user_id"} + badUserServerToken, err := th.App.GenerateAndSaveDesktopToken(time.Now().Add(-TTL).Unix(), &badUser) + require.Nil(t, err) + require.NotNil(t, badUserServerToken) + + t.Run("validate token", func(t *testing.T) { + user, err := th.App.ValidateDesktopToken(*authenticatedServerToken, time.Now().Add(-TTL).Unix()) + assert.Nil(t, err) + assert.NotNil(t, user) + assert.Equal(t, th.BasicUser.Id, user.Id) + }) + + t.Run("validate token - expired", func(t *testing.T) { + user, err := th.App.ValidateDesktopToken(*expiredServerToken, time.Now().Add(-TTL).Unix()) + assert.NotNil(t, err) + assert.Nil(t, user) + assert.Equal(t, "app.desktop_token.validate.invalid", err.Id) + }) + + t.Run("validate token - not authenticated", func(t *testing.T) { + user, err := th.App.ValidateDesktopToken("not_real_token", time.Now().Add(-TTL).Unix()) + assert.NotNil(t, err) + assert.Nil(t, user) + assert.Equal(t, "app.desktop_token.validate.invalid", err.Id) + }) + + t.Run("validate token - bad user id", func(t *testing.T) { + user, err := th.App.ValidateDesktopToken(*badUserServerToken, time.Now().Add(-TTL).Unix()) + assert.NotNil(t, err) + assert.Nil(t, user) + assert.Equal(t, "app.desktop_token.validate.no_user", err.Id) + }) +} diff --git a/server/channels/app/oauth.go b/server/channels/app/oauth.go index 1c82da9560..5a0d4793c9 100644 --- a/server/channels/app/oauth.go +++ b/server/channels/app/oauth.go @@ -429,7 +429,7 @@ func (a *App) newSessionUpdateToken(app *model.OAuthApp, accessData *model.Acces return accessRsp, nil } -func (a *App) GetOAuthLoginEndpoint(w http.ResponseWriter, r *http.Request, service, teamID, action, redirectTo, loginHint string, isMobile bool) (string, *model.AppError) { +func (a *App) GetOAuthLoginEndpoint(w http.ResponseWriter, r *http.Request, service, teamID, action, redirectTo, loginHint string, isMobile bool, desktopToken string) (string, *model.AppError) { stateProps := map[string]string{} stateProps["action"] = action if teamID != "" { @@ -440,6 +440,10 @@ func (a *App) GetOAuthLoginEndpoint(w http.ResponseWriter, r *http.Request, serv stateProps["redirect_to"] = redirectTo } + if desktopToken != "" { + stateProps["desktop_token"] = desktopToken + } + stateProps[model.UserAuthServiceIsMobile] = strconv.FormatBool(isMobile) authURL, err := a.GetAuthorizationCode(w, r, service, stateProps, loginHint) @@ -450,13 +454,17 @@ func (a *App) GetOAuthLoginEndpoint(w http.ResponseWriter, r *http.Request, serv return authURL, nil } -func (a *App) GetOAuthSignupEndpoint(w http.ResponseWriter, r *http.Request, service, teamID string) (string, *model.AppError) { +func (a *App) GetOAuthSignupEndpoint(w http.ResponseWriter, r *http.Request, service, teamID string, desktopToken string) (string, *model.AppError) { stateProps := map[string]string{} stateProps["action"] = model.OAuthActionSignup if teamID != "" { stateProps["team_id"] = teamID } + if desktopToken != "" { + stateProps["desktop_token"] = desktopToken + } + authURL, err := a.GetAuthorizationCode(w, r, service, stateProps, "") if err != nil { return "", err diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 08f6d12278..66590e9144 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -4539,6 +4539,28 @@ func (a *OpenTracingAppLayer) FinishSendAdminNotifyPost(trial bool, now int64, p a.app.FinishSendAdminNotifyPost(trial, now, pluginBasedData) } +func (a *OpenTracingAppLayer) GenerateAndSaveDesktopToken(expiryTime int64, user *model.User) (*string, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GenerateAndSaveDesktopToken") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GenerateAndSaveDesktopToken(expiryTime, user) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GenerateMfaSecret(userID string) (*model.MfaSecret, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GenerateMfaSecret") @@ -7613,7 +7635,7 @@ func (a *OpenTracingAppLayer) GetOAuthImplicitRedirect(userID string, authReques return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) GetOAuthLoginEndpoint(w http.ResponseWriter, r *http.Request, service string, teamID string, action string, redirectTo string, loginHint string, isMobile bool) (string, *model.AppError) { +func (a *OpenTracingAppLayer) GetOAuthLoginEndpoint(w http.ResponseWriter, r *http.Request, service string, teamID string, action string, redirectTo string, loginHint string, isMobile bool, desktopToken string) (string, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetOAuthLoginEndpoint") @@ -7625,7 +7647,7 @@ func (a *OpenTracingAppLayer) GetOAuthLoginEndpoint(w http.ResponseWriter, r *ht }() defer span.Finish() - resultVar0, resultVar1 := a.app.GetOAuthLoginEndpoint(w, r, service, teamID, action, redirectTo, loginHint, isMobile) + resultVar0, resultVar1 := a.app.GetOAuthLoginEndpoint(w, r, service, teamID, action, redirectTo, loginHint, isMobile, desktopToken) if resultVar1 != nil { span.LogFields(spanlog.Error(resultVar1)) @@ -7635,7 +7657,7 @@ func (a *OpenTracingAppLayer) GetOAuthLoginEndpoint(w http.ResponseWriter, r *ht return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) GetOAuthSignupEndpoint(w http.ResponseWriter, r *http.Request, service string, teamID string) (string, *model.AppError) { +func (a *OpenTracingAppLayer) GetOAuthSignupEndpoint(w http.ResponseWriter, r *http.Request, service string, teamID string, desktopToken string) (string, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetOAuthSignupEndpoint") @@ -7647,7 +7669,7 @@ func (a *OpenTracingAppLayer) GetOAuthSignupEndpoint(w http.ResponseWriter, r *h }() defer span.Finish() - resultVar0, resultVar1 := a.app.GetOAuthSignupEndpoint(w, r, service, teamID) + resultVar0, resultVar1 := a.app.GetOAuthSignupEndpoint(w, r, service, teamID, desktopToken) if resultVar1 != nil { span.LogFields(spanlog.Error(resultVar1)) @@ -18583,6 +18605,28 @@ func (a *OpenTracingAppLayer) UserIsInAdminRoleGroup(userID string, syncableID s return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) ValidateDesktopToken(token string, expiryTime int64) (*model.User, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ValidateDesktopToken") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.ValidateDesktopToken(token, expiryTime) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) ValidateUserPermissionsOnChannels(c request.CTX, userId string, channelIds []string) []string { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ValidateUserPermissionsOnChannels") diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 10e040d147..b568cb5c88 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -39,6 +39,7 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/audit" "github.com/mattermost/mattermost/server/v8/channels/jobs" "github.com/mattermost/mattermost/server/v8/channels/jobs/active_users" + "github.com/mattermost/mattermost/server/v8/channels/jobs/cleanup_desktop_tokens" "github.com/mattermost/mattermost/server/v8/channels/jobs/expirynotify" "github.com/mattermost/mattermost/server/v8/channels/jobs/export_delete" "github.com/mattermost/mattermost/server/v8/channels/jobs/export_process" @@ -1637,6 +1638,12 @@ func (s *Server) initJobs() { hosted_purchase_screening.MakeScheduler(s.Jobs, s.License()), ) + s.Jobs.RegisterJobType( + model.JobTypeCleanupDesktopTokens, + cleanup_desktop_tokens.MakeWorker(s.Jobs, s.Store()), + cleanup_desktop_tokens.MakeScheduler(s.Jobs), + ) + s.platform.Jobs = s.Jobs } diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list index 2800e4d656..8da0bf6fa9 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -220,6 +220,8 @@ channels/db/migrations/mysql/000109_create_persistent_notifications.down.sql channels/db/migrations/mysql/000109_create_persistent_notifications.up.sql channels/db/migrations/mysql/000111_update_vacuuming.down.sql channels/db/migrations/mysql/000111_update_vacuuming.up.sql +channels/db/migrations/mysql/000112_rework_desktop_tokens.down.sql +channels/db/migrations/mysql/000112_rework_desktop_tokens.up.sql channels/db/migrations/postgres/000001_create_teams.down.sql channels/db/migrations/postgres/000001_create_teams.up.sql channels/db/migrations/postgres/000002_create_team_members.down.sql @@ -440,3 +442,5 @@ channels/db/migrations/postgres/000109_create_persistent_notifications.down.sql channels/db/migrations/postgres/000109_create_persistent_notifications.up.sql channels/db/migrations/postgres/000111_update_vacuuming.down.sql channels/db/migrations/postgres/000111_update_vacuuming.up.sql +channels/db/migrations/postgres/000112_rework_desktop_tokens.down.sql +channels/db/migrations/postgres/000112_rework_desktop_tokens.up.sql diff --git a/server/channels/db/migrations/mysql/000112_rework_desktop_tokens.down.sql b/server/channels/db/migrations/mysql/000112_rework_desktop_tokens.down.sql new file mode 100644 index 0000000000..a006781160 --- /dev/null +++ b/server/channels/db/migrations/mysql/000112_rework_desktop_tokens.down.sql @@ -0,0 +1,16 @@ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'DesktopTokens' + AND table_schema = DATABASE() + AND index_name = 'idx_desktoptokens_createat' + ) > 0, + 'DROP INDEX idx_desktoptokens_createat ON DesktopTokens;', + 'SELECT 1' +)); + +PREPARE removeIndexIfExists FROM @preparedStatement; +EXECUTE removeIndexIfExists; +DEALLOCATE PREPARE removeIndexIfExists; + +DROP TABLE IF EXISTS DesktopTokens; \ No newline at end of file diff --git a/server/channels/db/migrations/mysql/000112_rework_desktop_tokens.up.sql b/server/channels/db/migrations/mysql/000112_rework_desktop_tokens.up.sql new file mode 100644 index 0000000000..64c1ec7ef8 --- /dev/null +++ b/server/channels/db/migrations/mysql/000112_rework_desktop_tokens.up.sql @@ -0,0 +1,38 @@ +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'DesktopTokens' + AND table_schema = DATABASE() + AND index_name = 'idx_desktoptokens_createat' + ) > 0, + 'DROP INDEX idx_desktoptokens_createat ON DesktopTokens;', + 'SELECT 1' +)); + +PREPARE removeIndexIfExists FROM @preparedStatement; +EXECUTE removeIndexIfExists; +DEALLOCATE PREPARE removeIndexIfExists; + +DROP TABLE IF EXISTS DesktopTokens; + +CREATE TABLE IF NOT EXISTS DesktopTokens ( + Token varchar(64) NOT NULL, + CreateAt bigint NOT NULL, + UserId varchar(26) NOT NULL, + PRIMARY KEY (Token) +); + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'DesktopTokens' + AND table_schema = DATABASE() + AND index_name = 'idx_desktoptokens_token_createat' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_desktoptokens_token_createat ON DesktopTokens(Token, CreateAt);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; \ No newline at end of file diff --git a/server/channels/db/migrations/postgres/000112_rework_desktop_tokens.down.sql b/server/channels/db/migrations/postgres/000112_rework_desktop_tokens.down.sql new file mode 100644 index 0000000000..9564f85d55 --- /dev/null +++ b/server/channels/db/migrations/postgres/000112_rework_desktop_tokens.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_desktoptokens_token_createat; +DROP TABLE IF EXISTS desktoptokens; diff --git a/server/channels/db/migrations/postgres/000112_rework_desktop_tokens.up.sql b/server/channels/db/migrations/postgres/000112_rework_desktop_tokens.up.sql new file mode 100644 index 0000000000..4442555cbd --- /dev/null +++ b/server/channels/db/migrations/postgres/000112_rework_desktop_tokens.up.sql @@ -0,0 +1,11 @@ +DROP INDEX IF EXISTS idx_desktoptokens_createat; +DROP TABLE IF EXISTS desktoptokens; + +CREATE TABLE IF NOT EXISTS desktoptokens ( + token VARCHAR(64) NOT NULL, + createat BIGINT NOT NULL, + userid VARCHAR(26) NOT NULL, + PRIMARY KEY (token) +); + +CREATE INDEX IF NOT EXISTS idx_desktoptokens_token_createat ON desktoptokens(token, createat) diff --git a/server/channels/jobs/cleanup_desktop_tokens/scheduler.go b/server/channels/jobs/cleanup_desktop_tokens/scheduler.go new file mode 100644 index 0000000000..879f11b575 --- /dev/null +++ b/server/channels/jobs/cleanup_desktop_tokens/scheduler.go @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package cleanup_desktop_tokens + +import ( + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/jobs" +) + +const schedFreq = 1 * time.Hour + +func MakeScheduler(jobServer *jobs.JobServer) model.Scheduler { + isEnabled := func(cfg *model.Config) bool { + return true + } + return jobs.NewPeriodicScheduler(jobServer, model.JobTypeCleanupDesktopTokens, schedFreq, isEnabled) +} diff --git a/server/channels/jobs/cleanup_desktop_tokens/worker.go b/server/channels/jobs/cleanup_desktop_tokens/worker.go new file mode 100644 index 0000000000..43f5b43819 --- /dev/null +++ b/server/channels/jobs/cleanup_desktop_tokens/worker.go @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package cleanup_desktop_tokens + +import ( + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/jobs" + "github.com/mattermost/mattermost/server/v8/channels/store" + "github.com/mattermost/mattermost/server/v8/platform/services/configservice" +) + +const jobName = "CleanupDesktopTokens" +const maxAge = 5 * time.Minute + +type AppIface interface { + configservice.ConfigService + ListDirectory(path string) ([]string, *model.AppError) + FileModTime(path string) (time.Time, *model.AppError) + RemoveFile(path string) *model.AppError +} + +func MakeWorker(jobServer *jobs.JobServer, store store.Store) model.Worker { + isEnabled := func(cfg *model.Config) bool { + return true + } + execute := func(job *model.Job) error { + defer jobServer.HandleJobPanic(job) + + return store.DesktopTokens().DeleteOlderThan(time.Now().Add(-maxAge).Unix()) + } + worker := jobs.NewSimpleWorker(jobName, jobServer, execute, isEnabled) + return worker +} diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 295bcf655c..4c2bc695af 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -26,6 +26,7 @@ type OpenTracingLayer struct { CommandStore store.CommandStore CommandWebhookStore store.CommandWebhookStore ComplianceStore store.ComplianceStore + DesktopTokensStore store.DesktopTokensStore DraftStore store.DraftStore EmojiStore store.EmojiStore FileInfoStore store.FileInfoStore @@ -95,6 +96,10 @@ func (s *OpenTracingLayer) Compliance() store.ComplianceStore { return s.ComplianceStore } +func (s *OpenTracingLayer) DesktopTokens() store.DesktopTokensStore { + return s.DesktopTokensStore +} + func (s *OpenTracingLayer) Draft() store.DraftStore { return s.DraftStore } @@ -275,6 +280,11 @@ type OpenTracingLayerComplianceStore struct { Root *OpenTracingLayer } +type OpenTracingLayerDesktopTokensStore struct { + store.DesktopTokensStore + Root *OpenTracingLayer +} + type OpenTracingLayerDraftStore struct { store.DraftStore Root *OpenTracingLayer @@ -3234,6 +3244,96 @@ func (s *OpenTracingLayerComplianceStore) Update(compliance *model.Compliance) ( return result, err } +func (s *OpenTracingLayerDesktopTokensStore) Delete(token string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DesktopTokensStore.Delete") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.DesktopTokensStore.Delete(token) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerDesktopTokensStore) DeleteByUserId(userId string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DesktopTokensStore.DeleteByUserId") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.DesktopTokensStore.DeleteByUserId(userId) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerDesktopTokensStore) DeleteOlderThan(minCreatedAt int64) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DesktopTokensStore.DeleteOlderThan") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.DesktopTokensStore.DeleteOlderThan(minCreatedAt) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerDesktopTokensStore) GetUserId(token string, minCreatedAt int64) (*string, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DesktopTokensStore.GetUserId") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.DesktopTokensStore.GetUserId(token, minCreatedAt) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerDesktopTokensStore) Insert(token string, createdAt int64, userId string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DesktopTokensStore.Insert") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.DesktopTokensStore.Insert(token, createdAt, userId) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + func (s *OpenTracingLayerDraftStore) Delete(userID string, channelID string, rootID string) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Delete") @@ -12885,6 +12985,7 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.CommandStore = &OpenTracingLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} newStore.CommandWebhookStore = &OpenTracingLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.ComplianceStore = &OpenTracingLayerComplianceStore{ComplianceStore: childStore.Compliance(), Root: &newStore} + newStore.DesktopTokensStore = &OpenTracingLayerDesktopTokensStore{DesktopTokensStore: childStore.DesktopTokens(), Root: &newStore} newStore.DraftStore = &OpenTracingLayerDraftStore{DraftStore: childStore.Draft(), Root: &newStore} newStore.EmojiStore = &OpenTracingLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore} newStore.FileInfoStore = &OpenTracingLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 50ad71a2bb..a6b6d04625 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -29,6 +29,7 @@ type RetryLayer struct { CommandStore store.CommandStore CommandWebhookStore store.CommandWebhookStore ComplianceStore store.ComplianceStore + DesktopTokensStore store.DesktopTokensStore DraftStore store.DraftStore EmojiStore store.EmojiStore FileInfoStore store.FileInfoStore @@ -98,6 +99,10 @@ func (s *RetryLayer) Compliance() store.ComplianceStore { return s.ComplianceStore } +func (s *RetryLayer) DesktopTokens() store.DesktopTokensStore { + return s.DesktopTokensStore +} + func (s *RetryLayer) Draft() store.DraftStore { return s.DraftStore } @@ -278,6 +283,11 @@ type RetryLayerComplianceStore struct { Root *RetryLayer } +type RetryLayerDesktopTokensStore struct { + store.DesktopTokensStore + Root *RetryLayer +} + type RetryLayerDraftStore struct { store.DraftStore Root *RetryLayer @@ -3607,6 +3617,111 @@ func (s *RetryLayerComplianceStore) Update(compliance *model.Compliance) (*model } +func (s *RetryLayerDesktopTokensStore) Delete(token string) error { + + tries := 0 + for { + err := s.DesktopTokensStore.Delete(token) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDesktopTokensStore) DeleteByUserId(userId string) error { + + tries := 0 + for { + err := s.DesktopTokensStore.DeleteByUserId(userId) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDesktopTokensStore) DeleteOlderThan(minCreatedAt int64) error { + + tries := 0 + for { + err := s.DesktopTokensStore.DeleteOlderThan(minCreatedAt) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDesktopTokensStore) GetUserId(token string, minCreatedAt int64) (*string, error) { + + tries := 0 + for { + result, err := s.DesktopTokensStore.GetUserId(token, minCreatedAt) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDesktopTokensStore) Insert(token string, createdAt int64, userId string) error { + + tries := 0 + for { + err := s.DesktopTokensStore.Insert(token, createdAt, userId) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerDraftStore) Delete(userID string, channelID string, rootID string) error { tries := 0 @@ -14684,6 +14799,7 @@ func New(childStore store.Store) *RetryLayer { newStore.CommandStore = &RetryLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} newStore.CommandWebhookStore = &RetryLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.ComplianceStore = &RetryLayerComplianceStore{ComplianceStore: childStore.Compliance(), Root: &newStore} + newStore.DesktopTokensStore = &RetryLayerDesktopTokensStore{DesktopTokensStore: childStore.DesktopTokens(), Root: &newStore} newStore.DraftStore = &RetryLayerDraftStore{DraftStore: childStore.Draft(), Root: &newStore} newStore.EmojiStore = &RetryLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore} newStore.FileInfoStore = &RetryLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} diff --git a/server/channels/store/retrylayer/retrylayer_test.go b/server/channels/store/retrylayer/retrylayer_test.go index 5489f2fd03..64919f439e 100644 --- a/server/channels/store/retrylayer/retrylayer_test.go +++ b/server/channels/store/retrylayer/retrylayer_test.go @@ -59,6 +59,7 @@ func genStore() *mocks.Store { mock.On("PostAcknowledgement").Return(&mocks.PostAcknowledgementStore{}) mock.On("PostPersistentNotification").Return(&mocks.PostPersistentNotificationStore{}) mock.On("TrueUpReview").Return(&mocks.TrueUpReviewStore{}) + mock.On("DesktopTokens").Return(&mocks.DesktopTokensStore{}) return mock } diff --git a/server/channels/store/sqlstore/desktop_tokens_store.go b/server/channels/store/sqlstore/desktop_tokens_store.go new file mode 100644 index 0000000000..eefce1d024 --- /dev/null +++ b/server/channels/store/sqlstore/desktop_tokens_store.go @@ -0,0 +1,123 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + + "github.com/mattermost/mattermost/server/v8/channels/store" + "github.com/mattermost/mattermost/server/v8/einterfaces" + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" +) + +type SqlDesktopTokensStore struct { + *SqlStore + metrics einterfaces.MetricsInterface +} + +func newSqlDesktopTokensStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.DesktopTokensStore { + return &SqlDesktopTokensStore{ + SqlStore: sqlStore, + metrics: metrics, + } +} + +func (s *SqlDesktopTokensStore) GetUserId(token string, minCreateAt int64) (*string, error) { + query := s.getQueryBuilder(). + Select("UserId"). + From("DesktopTokens"). + Where(sq.And{ + sq.Eq{"Token": token}, + sq.GtOrEq{"CreateAt": minCreateAt}, + }) + + dt := struct{ UserId string }{} + err := s.GetReplicaX().GetBuilder(&dt, query) + + if err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("DesktopTokens", token) + } + return nil, errors.Wrapf(err, "No token for %s", token) + } + + return &dt.UserId, nil +} + +func (s *SqlDesktopTokensStore) Insert(token string, createAt int64, userId string) error { + builder := s.getQueryBuilder(). + Insert("DesktopTokens"). + Columns("Token", "CreateAt", "UserId"). + Values(token, createAt, userId) + + query, args, err := builder.ToSql() + + if err != nil { + return errors.Wrap(err, "insert_desktoptokens_tosql") + } + + if _, err = s.GetMasterX().Exec(query, args...); err != nil { + return errors.Wrap(err, "failed to insert token row") + } + + return nil +} + +func (s *SqlDesktopTokensStore) Delete(token string) error { + builder := s.getQueryBuilder(). + Delete("DesktopTokens"). + Where(sq.Eq{ + "Token": token, + }) + + query, args, err := builder.ToSql() + + if err != nil { + return errors.Wrap(err, "delete_desktoptokens_tosql") + } + + if _, err = s.GetMasterX().Exec(query, args...); err != nil { + return errors.Wrap(err, "failed to delete token row") + } + return nil +} + +func (s *SqlDesktopTokensStore) DeleteByUserId(userId string) error { + builder := s.getQueryBuilder(). + Delete("DesktopTokens"). + Where(sq.Eq{ + "UserId": userId, + }) + + query, args, err := builder.ToSql() + + if err != nil { + return errors.Wrap(err, "delete_by_userid_desktoptokens_tosql") + } + + if _, err = s.GetMasterX().Exec(query, args...); err != nil { + return errors.Wrap(err, "failed to delete token row") + } + return nil +} + +func (s *SqlDesktopTokensStore) DeleteOlderThan(minCreateAt int64) error { + builder := s.getQueryBuilder(). + Delete("DesktopTokens"). + Where(sq.Lt{ + "CreateAt": minCreateAt, + }) + + query, args, err := builder.ToSql() + + if err != nil { + return errors.Wrap(err, "delete_old_desktoptokens_tosql") + } + + if _, err = s.GetMasterX().Exec(query, args...); err != nil { + return errors.Wrap(err, "failed to delete token row") + } + return nil +} diff --git a/server/channels/store/sqlstore/desktop_tokens_store_test.go b/server/channels/store/sqlstore/desktop_tokens_store_test.go new file mode 100644 index 0000000000..7f4bc4583e --- /dev/null +++ b/server/channels/store/sqlstore/desktop_tokens_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost/server/v8/channels/store/storetest" +) + +func TestDesktopTokensStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestDesktopTokensStore) +} diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go index 200c34fa2c..03a008bf14 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -108,6 +108,7 @@ type SqlStoreStores struct { postAcknowledgement store.PostAcknowledgementStore postPersistentNotification store.PostPersistentNotificationStore trueUpReview store.TrueUpReviewStore + desktopTokens store.DesktopTokensStore } type SqlStore struct { @@ -229,6 +230,7 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) (*Sql store.stores.postAcknowledgement = newSqlPostAcknowledgementStore(store) store.stores.postPersistentNotification = newSqlPostPersistentNotificationStore(store) store.stores.trueUpReview = newSqlTrueUpReviewStore(store) + store.stores.desktopTokens = newSqlDesktopTokensStore(store, metrics) store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures() @@ -1080,6 +1082,10 @@ func (ss *SqlStore) TrueUpReview() store.TrueUpReviewStore { return ss.stores.trueUpReview } +func (ss *SqlStore) DesktopTokens() store.DesktopTokensStore { + return ss.stores.desktopTokens +} + func (ss *SqlStore) DropAllTables() { if ss.DriverName() == model.DatabaseDriverPostgres { ss.masterX.Exec(`DO diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 782c2b4869..2e94c4c979 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -87,6 +87,7 @@ type Store interface { PostAcknowledgement() PostAcknowledgementStore PostPersistentNotification() PostPersistentNotificationStore TrueUpReview() TrueUpReviewStore + DesktopTokens() DesktopTokensStore } type RetentionPolicyStore interface { @@ -650,6 +651,14 @@ type TokenStore interface { RemoveAllTokensByType(tokenType string) error } +type DesktopTokensStore interface { + GetUserId(token string, minCreatedAt int64) (*string, error) + Insert(token string, createdAt int64, userId string) error + Delete(token string) error + DeleteByUserId(userId string) error + DeleteOlderThan(minCreatedAt int64) error +} + type EmojiStore interface { Save(emoji *model.Emoji) (*model.Emoji, error) Get(ctx context.Context, id string, allowFromCache bool) (*model.Emoji, error) diff --git a/server/channels/store/storetest/desktop_tokens_store.go b/server/channels/store/storetest/desktop_tokens_store.go new file mode 100644 index 0000000000..30a9ab1645 --- /dev/null +++ b/server/channels/store/storetest/desktop_tokens_store.go @@ -0,0 +1,110 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "testing" + + "github.com/mattermost/mattermost/server/v8/channels/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDesktopTokensStore(t *testing.T, ss store.Store, s SqlStore) { + t.Run("GetUserId", func(t *testing.T) { testGetUserId(t, ss) }) + t.Run("Insert", func(t *testing.T) { testInsert(t, ss) }) + t.Run("Delete", func(t *testing.T) { testDeleteToken(t, ss) }) + t.Run("DeleteByUserId", func(t *testing.T) { testDeleteByUserId(t, ss) }) + t.Run("DeleteOlderThan", func(t *testing.T) { testDeleteOlderThan(t, ss) }) +} + +func testGetUserId(t *testing.T, ss store.Store) { + err := ss.DesktopTokens().Insert("token_with_id", 1000, "user_id") + require.NoError(t, err) + + t.Run("get user id", func(t *testing.T) { + userId, err := ss.DesktopTokens().GetUserId("token_with_id", 1000) + assert.NoError(t, err) + assert.Equal(t, "user_id", *userId) + }) + + t.Run("get user id - expired", func(t *testing.T) { + userId, err := ss.DesktopTokens().GetUserId("token_with_id", 10000) + assert.Error(t, err) + assert.IsType(t, &store.ErrNotFound{}, err) + assert.Nil(t, userId) + }) +} + +func testInsert(t *testing.T, ss store.Store) { + t.Run("insert", func(t *testing.T) { + err := ss.DesktopTokens().Insert("token", 1000, "user_id") + assert.NoError(t, err) + }) + + t.Run("insert - token too long", func(t *testing.T) { + err := ss.DesktopTokens().Insert( + "this token is way way way WAAAAAAAAAAAAAY WAAAAAAAAAAAAAY WAAAAAAAAAAAAAY TOO LONG", + 1000, + "user_id", + ) + assert.Error(t, err) + }) +} + +func testDeleteToken(t *testing.T, ss store.Store) { + err := ss.DesktopTokens().Insert("deleteable_token", 3000, "user_id") + require.NoError(t, err) + + t.Run("delete", func(t *testing.T) { + userId, err := ss.DesktopTokens().GetUserId("deleteable_token", 3000) + assert.NoError(t, err) + assert.Equal(t, "user_id", *userId) + + err = ss.DesktopTokens().Delete("deleteable_token") + assert.NoError(t, err) + + _, err = ss.DesktopTokens().GetUserId("deleteable_token", 3000) + assert.Error(t, err) + }) +} + +func testDeleteByUserId(t *testing.T, ss store.Store) { + err := ss.DesktopTokens().Insert("deleteable_token_2", 4000, "deleteable_user_id") + require.NoError(t, err) + + t.Run("delete by user id", func(t *testing.T) { + userId, err := ss.DesktopTokens().GetUserId("deleteable_token_2", 3000) + assert.NoError(t, err) + assert.Equal(t, "deleteable_user_id", *userId) + + err = ss.DesktopTokens().DeleteByUserId("deleteable_user_id") + assert.NoError(t, err) + + _, err = ss.DesktopTokens().GetUserId("deleteable_token_2", 3000) + assert.Error(t, err) + }) +} + +func testDeleteOlderThan(t *testing.T, ss store.Store) { + err := ss.DesktopTokens().Insert("deleteable_token_old", 1000, "deleteable_user_id") + require.NoError(t, err) + err = ss.DesktopTokens().Insert("deleteable_token_new", 5000, "deleteable_user_id") + require.NoError(t, err) + + t.Run("delete older than", func(t *testing.T) { + _, err := ss.DesktopTokens().GetUserId("deleteable_token_old", 1000) + assert.NoError(t, err) + _, err = ss.DesktopTokens().GetUserId("deleteable_token_new", 5000) + assert.NoError(t, err) + + err = ss.DesktopTokens().DeleteOlderThan(2000) + assert.NoError(t, err) + + _, err = ss.DesktopTokens().GetUserId("deleteable_token_old", 1000) + assert.Error(t, err) + _, err = ss.DesktopTokens().GetUserId("deleteable_token_new", 5000) + assert.NoError(t, err) + }) +} diff --git a/server/channels/store/storetest/mocks/DesktopTokensStore.go b/server/channels/store/storetest/mocks/DesktopTokensStore.go new file mode 100644 index 0000000000..969ae119f6 --- /dev/null +++ b/server/channels/store/storetest/mocks/DesktopTokensStore.go @@ -0,0 +1,109 @@ +// Code generated by mockery v2.23.2. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// DesktopTokensStore is an autogenerated mock type for the DesktopTokensStore type +type DesktopTokensStore struct { + mock.Mock +} + +// Delete provides a mock function with given fields: token +func (_m *DesktopTokensStore) Delete(token string) error { + ret := _m.Called(token) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(token) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteByUserId provides a mock function with given fields: userId +func (_m *DesktopTokensStore) DeleteByUserId(userId string) error { + ret := _m.Called(userId) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(userId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteOlderThan provides a mock function with given fields: minCreatedAt +func (_m *DesktopTokensStore) DeleteOlderThan(minCreatedAt int64) error { + ret := _m.Called(minCreatedAt) + + var r0 error + if rf, ok := ret.Get(0).(func(int64) error); ok { + r0 = rf(minCreatedAt) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetUserId provides a mock function with given fields: token, minCreatedAt +func (_m *DesktopTokensStore) GetUserId(token string, minCreatedAt int64) (*string, error) { + ret := _m.Called(token, minCreatedAt) + + var r0 *string + var r1 error + if rf, ok := ret.Get(0).(func(string, int64) (*string, error)); ok { + return rf(token, minCreatedAt) + } + if rf, ok := ret.Get(0).(func(string, int64) *string); ok { + r0 = rf(token, minCreatedAt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*string) + } + } + + if rf, ok := ret.Get(1).(func(string, int64) error); ok { + r1 = rf(token, minCreatedAt) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Insert provides a mock function with given fields: token, createdAt, userId +func (_m *DesktopTokensStore) Insert(token string, createdAt int64, userId string) error { + ret := _m.Called(token, createdAt, userId) + + var r0 error + if rf, ok := ret.Get(0).(func(string, int64, string) error); ok { + r0 = rf(token, createdAt, userId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type mockConstructorTestingTNewDesktopTokensStore interface { + mock.TestingT + Cleanup(func()) +} + +// NewDesktopTokensStore creates a new instance of DesktopTokensStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewDesktopTokensStore(t mockConstructorTestingTNewDesktopTokensStore) *DesktopTokensStore { + mock := &DesktopTokensStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/channels/store/storetest/mocks/Store.go b/server/channels/store/storetest/mocks/Store.go index bc8f8c3031..da01a1e258 100644 --- a/server/channels/store/storetest/mocks/Store.go +++ b/server/channels/store/storetest/mocks/Store.go @@ -187,6 +187,22 @@ func (_m *Store) Context() context.Context { return r0 } +// DesktopTokens provides a mock function with given fields: +func (_m *Store) DesktopTokens() store.DesktopTokensStore { + ret := _m.Called() + + var r0 store.DesktopTokensStore + if rf, ok := ret.Get(0).(func() store.DesktopTokensStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.DesktopTokensStore) + } + } + + return r0 +} + // Draft provides a mock function with given fields: func (_m *Store) Draft() store.DraftStore { ret := _m.Called() diff --git a/server/channels/store/storetest/store.go b/server/channels/store/storetest/store.go index 80fa81159a..c0da42ac9b 100644 --- a/server/channels/store/storetest/store.go +++ b/server/channels/store/storetest/store.go @@ -61,6 +61,7 @@ type Store struct { PostAcknowledgementStore mocks.PostAcknowledgementStore PostPersistentNotificationStore mocks.PostPersistentNotificationStore TrueUpReviewStore mocks.TrueUpReviewStore + DesktopTokensStore mocks.DesktopTokensStore } func (s *Store) SetContext(context context.Context) { s.context = context } @@ -103,6 +104,7 @@ func (s *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore { return &s.ChannelMemberHistoryStore } func (s *Store) TrueUpReview() store.TrueUpReviewStore { return &s.TrueUpReviewStore } +func (s *Store) DesktopTokens() store.DesktopTokensStore { return &s.DesktopTokensStore } func (s *Store) NotifyAdmin() store.NotifyAdminStore { return &s.NotifyAdminStore } func (s *Store) Group() store.GroupStore { return &s.GroupStore } func (s *Store) LinkMetadata() store.LinkMetadataStore { return &s.LinkMetadataStore } @@ -177,5 +179,6 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.PostPriorityStore, &s.PostAcknowledgementStore, &s.PostPersistentNotificationStore, + &s.DesktopTokensStore, ) } diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index a337d49a4a..f11dd0acde 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -26,6 +26,7 @@ type TimerLayer struct { CommandStore store.CommandStore CommandWebhookStore store.CommandWebhookStore ComplianceStore store.ComplianceStore + DesktopTokensStore store.DesktopTokensStore DraftStore store.DraftStore EmojiStore store.EmojiStore FileInfoStore store.FileInfoStore @@ -95,6 +96,10 @@ func (s *TimerLayer) Compliance() store.ComplianceStore { return s.ComplianceStore } +func (s *TimerLayer) DesktopTokens() store.DesktopTokensStore { + return s.DesktopTokensStore +} + func (s *TimerLayer) Draft() store.DraftStore { return s.DraftStore } @@ -275,6 +280,11 @@ type TimerLayerComplianceStore struct { Root *TimerLayer } +type TimerLayerDesktopTokensStore struct { + store.DesktopTokensStore + Root *TimerLayer +} + type TimerLayerDraftStore struct { store.DraftStore Root *TimerLayer @@ -2968,6 +2978,86 @@ func (s *TimerLayerComplianceStore) Update(compliance *model.Compliance) (*model return result, err } +func (s *TimerLayerDesktopTokensStore) Delete(token string) error { + start := time.Now() + + err := s.DesktopTokensStore.Delete(token) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DesktopTokensStore.Delete", success, elapsed) + } + return err +} + +func (s *TimerLayerDesktopTokensStore) DeleteByUserId(userId string) error { + start := time.Now() + + err := s.DesktopTokensStore.DeleteByUserId(userId) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DesktopTokensStore.DeleteByUserId", success, elapsed) + } + return err +} + +func (s *TimerLayerDesktopTokensStore) DeleteOlderThan(minCreatedAt int64) error { + start := time.Now() + + err := s.DesktopTokensStore.DeleteOlderThan(minCreatedAt) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DesktopTokensStore.DeleteOlderThan", success, elapsed) + } + return err +} + +func (s *TimerLayerDesktopTokensStore) GetUserId(token string, minCreatedAt int64) (*string, error) { + start := time.Now() + + result, err := s.DesktopTokensStore.GetUserId(token, minCreatedAt) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DesktopTokensStore.GetUserId", success, elapsed) + } + return result, err +} + +func (s *TimerLayerDesktopTokensStore) Insert(token string, createdAt int64, userId string) error { + start := time.Now() + + err := s.DesktopTokensStore.Insert(token, createdAt, userId) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DesktopTokensStore.Insert", success, elapsed) + } + return err +} + func (s *TimerLayerDraftStore) Delete(userID string, channelID string, rootID string) error { start := time.Now() @@ -11613,6 +11703,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.CommandStore = &TimerLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} newStore.CommandWebhookStore = &TimerLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.ComplianceStore = &TimerLayerComplianceStore{ComplianceStore: childStore.Compliance(), Root: &newStore} + newStore.DesktopTokensStore = &TimerLayerDesktopTokensStore{DesktopTokensStore: childStore.DesktopTokens(), Root: &newStore} newStore.DraftStore = &TimerLayerDraftStore{DraftStore: childStore.Draft(), Root: &newStore} newStore.EmojiStore = &TimerLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore} newStore.FileInfoStore = &TimerLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} diff --git a/server/channels/web/oauth.go b/server/channels/web/oauth.go index cc60ebfced..e80ab016c0 100644 --- a/server/channels/web/oauth.go +++ b/server/channels/web/oauth.go @@ -11,6 +11,7 @@ import ( "net/url" "path/filepath" "strings" + "time" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/i18n" @@ -344,6 +345,34 @@ func completeOAuth(c *Context, w http.ResponseWriter, r *http.Request) { } else { // For web c.App.AttachSessionCookies(c.AppContext, w, r) } + + desktopToken := "" + if val, ok := props["desktop_token"]; ok { + desktopToken = val + } + + if desktopToken != "" { + serverToken, serverTokenErr := c.App.GenerateAndSaveDesktopToken(time.Now().Unix(), user) + if serverTokenErr != nil { + serverTokenErr.Translate(c.AppContext.T) + c.LogErrorByCode(serverTokenErr) + renderError(serverTokenErr) + return + } + + queryString := map[string]string{ + "client_token": desktopToken, + "server_token": *serverToken, + } + if val, ok := props["redirect_to"]; ok { + queryString["redirect_to"] = val + } + if strings.HasPrefix(desktopToken, "dev-") { + queryString["isDesktopDev"] = "true" + } + + redirectURL = utils.AppendQueryParamsToURL(c.GetSiteURLHeader()+"/login/desktop", queryString) + } } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -358,6 +387,7 @@ func loginWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) { loginHint := r.URL.Query().Get("login_hint") redirectURL := r.URL.Query().Get("redirect_to") + desktopToken := r.URL.Query().Get("desktop_token") if redirectURL != "" && !utils.IsValidWebAuthRedirectURL(c.App.Config(), redirectURL) { c.Err = model.NewAppError("loginWithOAuth", "api.invalid_redirect_url", nil, "", http.StatusBadRequest) @@ -370,7 +400,7 @@ func loginWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) { return } - authURL, err := c.App.GetOAuthLoginEndpoint(w, r, c.Params.Service, teamId, model.OAuthActionLogin, redirectURL, loginHint, false) + authURL, err := c.App.GetOAuthLoginEndpoint(w, r, c.Params.Service, teamId, model.OAuthActionLogin, redirectURL, loginHint, false, desktopToken) if err != nil { c.Err = err return @@ -399,7 +429,7 @@ func mobileLoginWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) { return } - authURL, err := c.App.GetOAuthLoginEndpoint(w, r, c.Params.Service, teamId, model.OAuthActionMobile, redirectURL, "", true) + authURL, err := c.App.GetOAuthLoginEndpoint(w, r, c.Params.Service, teamId, model.OAuthActionMobile, redirectURL, "", true, "") if err != nil { c.Err = err return @@ -427,7 +457,9 @@ func signupWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) { return } - authURL, err := c.App.GetOAuthSignupEndpoint(w, r, c.Params.Service, teamId) + desktopToken := r.URL.Query().Get("desktop_token") + + authURL, err := c.App.GetOAuthSignupEndpoint(w, r, c.Params.Service, teamId, desktopToken) if err != nil { c.Err = err return diff --git a/server/channels/web/saml.go b/server/channels/web/saml.go index e72b0c8bf3..3d9c8b65e2 100644 --- a/server/channels/web/saml.go +++ b/server/channels/web/saml.go @@ -9,6 +9,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" @@ -59,6 +60,11 @@ func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) { relayProps["redirect_to"] = redirectURL } + desktopToken := r.URL.Query().Get("desktop_token") + if desktopToken != "" { + relayProps["desktop_token"] = desktopToken + } + relayProps[model.UserAuthServiceIsMobile] = strconv.FormatBool(isMobile) if len(relayProps) > 0 { @@ -185,6 +191,30 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { c.App.AttachSessionCookies(c.AppContext, w, r) + desktopToken := relayProps["desktop_token"] + if desktopToken != "" { + serverToken, serverTokenErr := c.App.GenerateAndSaveDesktopToken(time.Now().Unix(), user) + if serverTokenErr != nil { + handleError(serverTokenErr) + return + } + + queryString := map[string]string{ + "client_token": desktopToken, + "server_token": *serverToken, + } + if val, ok := relayProps["redirect_to"]; ok { + queryString["redirect_to"] = val + } + if strings.HasPrefix(desktopToken, "dev-") { + queryString["isDesktopDev"] = "true" + } + + redirectURL = utils.AppendQueryParamsToURL(c.GetSiteURLHeader()+"/login/desktop", queryString) + http.Redirect(w, r, redirectURL, http.StatusFound) + return + } + if hasRedirectURL { if isMobile { // Mobile clients with redirect url support diff --git a/server/i18n/en.json b/server/i18n/en.json index 7691770620..78a796ffe6 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -5079,6 +5079,18 @@ "id": "app.custom_group.unique_name", "translation": "group name is not unique" }, + { + "id": "app.desktop_token.generateServerToken.invalid_or_expired", + "translation": "Token does not exist or is expired" + }, + { + "id": "app.desktop_token.validate.invalid", + "translation": "Token is not valid or is expired" + }, + { + "id": "app.desktop_token.validate.no_user", + "translation": "Cannot find a user for this token" + }, { "id": "app.draft.delete.app_error", "translation": "Unable to delete the Draft." diff --git a/server/public/model/job.go b/server/public/model/job.go index 5b2dea6731..5c1605d7cf 100644 --- a/server/public/model/job.go +++ b/server/public/model/job.go @@ -36,6 +36,7 @@ const ( JobTypeInstallPluginNotifyAdmin = "install_plugin_notify_admin" JobTypeHostedPurchaseScreening = "hosted_purchase_screening" JobTypeS3PathMigration = "s3_path_migration" + JobTypeCleanupDesktopTokens = "cleanup_desktop_tokens" JobStatusPending = "pending" JobStatusInProgress = "in_progress" @@ -66,6 +67,7 @@ var AllJobTypes = [...]string{ JobTypeExtractContent, JobTypeLastAccessiblePost, JobTypeLastAccessibleFile, + JobTypeCleanupDesktopTokens, } type Job struct { diff --git a/server/public/model/user.go b/server/public/model/user.go index 281de1dc30..b474d515cc 100644 --- a/server/public/model/user.go +++ b/server/public/model/user.go @@ -62,6 +62,8 @@ const ( UserLocaleMaxLength = 5 UserTimezoneMaxRunes = 256 UserRolesMaxLength = 256 + + DesktopTokenTTL = time.Minute * 3 ) //msgp:tuple User diff --git a/webapp/channels/src/actions/views/login.ts b/webapp/channels/src/actions/views/login.ts index 524b8dddd8..0ff337f198 100644 --- a/webapp/channels/src/actions/views/login.ts +++ b/webapp/channels/src/actions/views/login.ts @@ -45,6 +45,40 @@ export function login(loginId: string, password: string, mfaToken = ''): ActionF }; } +export function loginWithDesktopToken(token: string): ActionFunc { + return async (dispatch: DispatchFunc) => { + dispatch({type: UserTypes.LOGIN_REQUEST, data: null}); + + try { + // This is partial user profile we recieved when we login. We still need to make getMe for complete user profile. + const loggedInUserProfile = await Client4.loginWithDesktopToken(token); + + dispatch( + batchActions([ + { + type: UserTypes.LOGIN_SUCCESS, + }, + { + type: UserTypes.RECEIVED_ME, + data: loggedInUserProfile, + }, + ]), + ); + + dispatch(loadRolesIfNeeded(loggedInUserProfile.roles.split(' '))); + } catch (error) { + dispatch({ + type: UserTypes.LOGIN_FAILURE, + error, + }); + dispatch(logError(error as ServerError)); + return {error}; + } + + return {data: true}; + }; +} + export function loginById(id: string, password: string): ActionFunc { return async (dispatch: DispatchFunc) => { dispatch({type: UserTypes.LOGIN_REQUEST, data: null}); diff --git a/webapp/channels/src/components/desktop_auth_token.scss b/webapp/channels/src/components/desktop_auth_token.scss new file mode 100644 index 0000000000..73342bca94 --- /dev/null +++ b/webapp/channels/src/components/desktop_auth_token.scss @@ -0,0 +1,38 @@ +.DesktopAuthToken { + display: flex; + width: 540px; + height: 80vh; + flex-direction: column; + align-items: center; + justify-content: center; + margin: auto; + text-align: center; +} + +.DesktopAuthToken__main { + display: flex; + margin-top: auto; + margin-bottom: 14px; + color: var(--sidebar-header-bg); + font-size: 40px; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 48px; +} + +.DesktopAuthToken__sub { + margin-bottom: 24px; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 18px; + line-height: 28px; + + &.complete { + max-width: 442px; + } +} + +.DesktopAuthToken__bottom { + margin-top: auto; + font-size: 16px; + line-height: 24px; +} diff --git a/webapp/channels/src/components/desktop_auth_token.tsx b/webapp/channels/src/components/desktop_auth_token.tsx new file mode 100644 index 0000000000..bcd378497c --- /dev/null +++ b/webapp/channels/src/components/desktop_auth_token.tsx @@ -0,0 +1,224 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useDispatch} from 'react-redux'; +import {useHistory, useLocation} from 'react-router-dom'; + +import crypto from 'crypto'; +import classNames from 'classnames'; + +import {UserProfile} from '@mattermost/types/users'; +import {DispatchFunc} from 'mattermost-redux/types/actions'; + +import {loginWithDesktopToken} from 'actions/views/login'; + +import './desktop_auth_token.scss'; + +const BOTTOM_MESSAGE_TIMEOUT = 10000; +const DESKTOP_AUTH_PREFIX = 'desktop_auth_client_token'; + +declare global { + interface Window { + desktopAPI?: { + isDev?: () => Promise; + }; + } +} + +enum DesktopAuthStatus { + None, + WaitingForBrowser, + LoggedIn, + Authenticating, + Error, +} + +type Props = { + href: string; + onLogin: (userProfile: UserProfile) => void; +} + +const DesktopAuthToken: React.FC = ({href, onLogin}: Props) => { + const dispatch = useDispatch(); + const history = useHistory(); + const {search} = useLocation(); + const query = new URLSearchParams(search); + + const serverToken = query.get('server_token'); + const receivedClientToken = query.get('client_token'); + const storedClientToken = sessionStorage.getItem(DESKTOP_AUTH_PREFIX); + const [status, setStatus] = useState(serverToken ? DesktopAuthStatus.LoggedIn : DesktopAuthStatus.None); + const [showBottomMessage, setShowBottomMessage] = useState(); + + const tryDesktopLogin = async () => { + if (!(serverToken && receivedClientToken === storedClientToken)) { + setStatus(DesktopAuthStatus.Error); + return; + } + + sessionStorage.removeItem(DESKTOP_AUTH_PREFIX); + const {data: userProfile, error: loginError} = await dispatch(loginWithDesktopToken(serverToken)); + + if (loginError && loginError.server_error_id && loginError.server_error_id.length !== 0) { + setStatus(DesktopAuthStatus.Error); + return; + } + + setStatus(DesktopAuthStatus.LoggedIn); + await onLogin(userProfile as UserProfile); + }; + + const openExternalLoginURL = async () => { + const isDev = await window.desktopAPI?.isDev?.(); + const desktopToken = `${isDev ? 'dev-' : ''}${crypto.randomBytes(32).toString('hex')}`.slice(0, 64); + sessionStorage.setItem(DESKTOP_AUTH_PREFIX, desktopToken); + const parsedURL = new URL(href); + + const params = new URLSearchParams(parsedURL.searchParams); + params.set('desktop_token', desktopToken); + + window.open(`${parsedURL.origin}${parsedURL.pathname}?${params.toString()}`); + setStatus(DesktopAuthStatus.WaitingForBrowser); + }; + + const forwardToDesktopApp = () => { + const url = new URL(window.location.href); + if (url.searchParams.get('isDesktopDev')) { + url.protocol = 'mattermost-dev'; + } else { + url.protocol = 'mattermost'; + } + + window.location.href = url.toString(); + }; + + useEffect(() => { + setShowBottomMessage(false); + + const timeout = setTimeout(() => { + setShowBottomMessage(true); + }, BOTTOM_MESSAGE_TIMEOUT) as unknown as number; + + return () => { + clearTimeout(timeout); + }; + }, [status]); + + useEffect(() => { + if (serverToken) { + if (storedClientToken) { + tryDesktopLogin(); + } else { + forwardToDesktopApp(); + } + return; + } + + openExternalLoginURL(); + }, [serverToken]); + + let mainMessage; + let subMessage; + let bottomMessage; + + if (status === DesktopAuthStatus.WaitingForBrowser) { + mainMessage = ( + + ); + subMessage = ( + + ); + + bottomMessage = null; + } + + if (status === DesktopAuthStatus.LoggedIn) { + mainMessage = ( + + ); + subMessage = ( + { + return ( + + {chunks} + + ); + }, + b: (chunks: React.ReactNode) => ({chunks}), + }} + /> + ); + + bottomMessage = ( + { + return ( + history.push('/')}> + {chunks} + + ); + }, + }} + /> + ); + } + + if (status === DesktopAuthStatus.Error) { + mainMessage = ( + + ); + subMessage = ( + here to try again.'} + values={{ + a: (chunks: React.ReactNode) => { + return ( + history.push('/')}> + {chunks} + + ); + }, + }} + /> + ); + bottomMessage = null; + } + + return ( +
+

+ {mainMessage} +

+

+ {subMessage} +

+
+ {showBottomMessage ? bottomMessage : null} +
+
+ ); +}; + +export default DesktopAuthToken; diff --git a/webapp/channels/src/components/external_login_button/external_login_button.tsx b/webapp/channels/src/components/external_login_button/external_login_button.tsx index 4e4f63dac8..75036a40af 100644 --- a/webapp/channels/src/components/external_login_button/external_login_button.tsx +++ b/webapp/channels/src/components/external_login_button/external_login_button.tsx @@ -13,6 +13,7 @@ export type ExternalLoginButtonType = { label: string; style?: React.CSSProperties; direction?: 'row' | 'column'; + onClick: (event: React.MouseEvent) => void; }; const ExternalLoginButton = ({ @@ -22,12 +23,14 @@ const ExternalLoginButton = ({ label, style, direction = 'row', + onClick, }: ExternalLoginButtonType) => ( {icon} diff --git a/webapp/channels/src/components/login/login.tsx b/webapp/channels/src/components/login/login.tsx index 810270a70c..d85aa1fb6d 100644 --- a/webapp/channels/src/components/login/login.tsx +++ b/webapp/channels/src/components/login/login.tsx @@ -3,7 +3,7 @@ import React, {useState, useEffect, useRef, useCallback, FormEvent} from 'react'; import {useIntl} from 'react-intl'; -import {Link, useLocation, useHistory} from 'react-router-dom'; +import {Link, useLocation, useHistory, Route} from 'react-router-dom'; import {useSelector, useDispatch} from 'react-redux'; import classNames from 'classnames'; import throttle from 'lodash/throttle'; @@ -32,7 +32,9 @@ import {setNeedsLoggedInLimitReachedCheck} from 'actions/views/admin'; import {trackEvent} from 'actions/telemetry_actions'; import AlertBanner, {ModeType, AlertBannerProps} from 'components/alert_banner'; +import DesktopAuthToken from 'components/desktop_auth_token'; import ExternalLoginButton, {ExternalLoginButtonType} from 'components/external_login_button/external_login_button'; +import ExternalLink from 'components/external_link'; import AlternateLinkLayout from 'components/header_footer_route/content_layouts/alternate_link'; import ColumnLayout from 'components/header_footer_route/content_layouts/column'; import {CustomizeHeaderType} from 'components/header_footer_route/header_footer_route'; @@ -54,12 +56,12 @@ import {GlobalState} from 'types/store'; import Constants from 'utils/constants'; import {showNotification} from 'utils/notifications'; import {t} from 'utils/i18n'; +import {isDesktopApp} from 'utils/user_agent'; import {setCSRFFromCookie} from 'utils/utils'; import LoginMfa from './login_mfa'; import './login.scss'; -import ExternalLink from 'components/external_link'; const MOBILE_SCREEN_WIDTH = 1200; @@ -148,6 +150,8 @@ const Login = ({onCustomizeHeader}: LoginProps) => { const query = new URLSearchParams(search); const redirectTo = query.get('redirect_to'); + const [desktopLoginLink, setDesktopLoginLink] = useState(''); + const getExternalLoginOptions = () => { const externalLoginOptions: ExternalLoginButtonType[] = []; @@ -156,55 +160,76 @@ const Login = ({onCustomizeHeader}: LoginProps) => { } if (enableSignUpWithGitLab) { + const url = `${Client4.getOAuthRoute()}/gitlab/login${search}`; externalLoginOptions.push({ id: 'gitlab', - url: `${Client4.getOAuthRoute()}/gitlab/login${search}`, + url, icon: , label: GitLabButtonText || formatMessage({id: 'login.gitlab', defaultMessage: 'GitLab'}), style: {color: GitLabButtonColor, borderColor: GitLabButtonColor}, + onClick: desktopExternalAuth(url), }); } if (enableSignUpWithGoogle) { + const url = `${Client4.getOAuthRoute()}/google/login${search}`; externalLoginOptions.push({ id: 'google', - url: `${Client4.getOAuthRoute()}/google/login${search}`, + url, icon: , label: formatMessage({id: 'login.google', defaultMessage: 'Google'}), + onClick: desktopExternalAuth(url), }); } if (enableSignUpWithOffice365) { + const url = `${Client4.getOAuthRoute()}/office365/login${search}`; externalLoginOptions.push({ id: 'office365', - url: `${Client4.getOAuthRoute()}/office365/login${search}`, + url, icon: , label: formatMessage({id: 'login.office365', defaultMessage: 'Office 365'}), + onClick: desktopExternalAuth(url), }); } if (enableSignUpWithOpenId) { + const url = `${Client4.getOAuthRoute()}/openid/login${search}`; externalLoginOptions.push({ id: 'openid', - url: `${Client4.getOAuthRoute()}/openid/login${search}`, + url, icon: , label: OpenIdButtonText || formatMessage({id: 'login.openid', defaultMessage: 'Open ID'}), style: {color: OpenIdButtonColor, borderColor: OpenIdButtonColor}, + onClick: desktopExternalAuth(url), }); } if (enableSignUpWithSaml) { + const url = `${Client4.getUrl()}/login/sso/saml${search}`; externalLoginOptions.push({ id: 'saml', - url: `${Client4.getUrl()}/login/sso/saml${search}`, + url, icon: , label: SamlLoginButtonText || formatMessage({id: 'login.saml', defaultMessage: 'SAML'}), + onClick: desktopExternalAuth(url), }); } return externalLoginOptions; }; + const desktopExternalAuth = (href: string) => { + return (event: React.MouseEvent) => { + if (isDesktopApp()) { + event.preventDefault(); + + setDesktopLoginLink(href); + history.push(`/login/desktop${search}`); + } + }; + }; + const dismissAlert = () => { setAlertBanner(null); setHasError(false); @@ -376,6 +401,11 @@ const Login = ({onCustomizeHeader}: LoginProps) => { }, [onCustomizeHeader, search, showMfa, isMobileView, getAlternateLink]); useEffect(() => { + // We don't want to redirect outside of this route if we're doing Desktop App auth + if (query.get('server_token')) { + return; + } + if (currentUser) { if (redirectTo && redirectTo.match(/^\/([^/]|$)/)) { history.push(redirectTo); @@ -594,6 +624,10 @@ const Login = ({onCustomizeHeader}: LoginProps) => { return; } + await postSubmit(userProfile); + }; + + const postSubmit = async (userProfile: UserProfile) => { if (graphQLEnabled) { await dispatch(loadMe()); } else { @@ -752,6 +786,20 @@ const Login = ({onCustomizeHeader}: LoginProps) => { ); } + if (desktopLoginLink || query.get('server_token')) { + return ( + ( + + )} + /> + ); + } + return ( <>
diff --git a/webapp/channels/src/components/signup/signup.tsx b/webapp/channels/src/components/signup/signup.tsx index 8216ae5a8d..b3ff97e772 100644 --- a/webapp/channels/src/components/signup/signup.tsx +++ b/webapp/channels/src/components/signup/signup.tsx @@ -4,7 +4,7 @@ import React, {useState, useEffect, useRef, useCallback, FocusEvent} from 'react'; import {useIntl} from 'react-intl'; -import {useLocation, useHistory} from 'react-router-dom'; +import {useLocation, useHistory, Route} from 'react-router-dom'; import {useSelector, useDispatch} from 'react-redux'; import classNames from 'classnames'; import throttle from 'lodash/throttle'; @@ -54,9 +54,11 @@ import useCWSAvailabilityCheck from 'components/common/hooks/useCWSAvailabilityC import ExternalLink from 'components/external_link'; import {Constants, HostedCustomerLinks, ItemStatus, ValidationErrors} from 'utils/constants'; +import {isDesktopApp} from 'utils/user_agent'; import {isValidUsername, isValidPassword, getPasswordConfig, getRoleFromTrackFlow, getMediumFromTrackFlow} from 'utils/utils'; import './signup.scss'; +import DesktopAuthToken from 'components/desktop_auth_token'; const MOBILE_SCREEN_WIDTH = 1200; @@ -148,6 +150,8 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { const canSubmit = Boolean(email && name && password) && !hasError && !loading; const {error: passwordInfo} = isValidPassword('', getPasswordConfig(config), intl); + const [desktopLoginLink, setDesktopLoginLink] = useState(''); + const subscribeToSecurityNewsletterFunc = () => { try { Client4.subscribeToNewsletter({email, subscribed_content: 'security_newsletter'}); @@ -165,40 +169,48 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { } if (enableSignUpWithGitLab) { + const url = `${Client4.getOAuthRoute()}/gitlab/signup${search}`; externalLoginOptions.push({ id: 'gitlab', - url: `${Client4.getOAuthRoute()}/gitlab/signup${search}`, + url, icon: , label: GitLabButtonText || formatMessage({id: 'login.gitlab', defaultMessage: 'GitLab'}), style: {color: GitLabButtonColor, borderColor: GitLabButtonColor}, + onClick: desktopExternalAuth(url), }); } if (isLicensed && enableSignUpWithGoogle) { + const url = `${Client4.getOAuthRoute()}/google/signup${search}`; externalLoginOptions.push({ id: 'google', - url: `${Client4.getOAuthRoute()}/google/signup${search}`, + url, icon: , label: formatMessage({id: 'login.google', defaultMessage: 'Google'}), + onClick: desktopExternalAuth(url), }); } if (isLicensed && enableSignUpWithOffice365) { + const url = `${Client4.getOAuthRoute()}/office365/signup${search}`; externalLoginOptions.push({ id: 'office365', - url: `${Client4.getOAuthRoute()}/office365/signup${search}`, + url, icon: , label: formatMessage({id: 'login.office365', defaultMessage: 'Office 365'}), + onClick: desktopExternalAuth(url), }); } if (isLicensed && enableSignUpWithOpenId) { + const url = `${Client4.getOAuthRoute()}/openid/signup${search}`; externalLoginOptions.push({ id: 'openid', - url: `${Client4.getOAuthRoute()}/openid/signup${search}`, + url, icon: , label: OpenIdButtonText || formatMessage({id: 'login.openid', defaultMessage: 'Open ID'}), style: {color: OpenIdButtonColor, borderColor: OpenIdButtonColor}, + onClick: desktopExternalAuth(url), }); } @@ -211,6 +223,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { url: `${Client4.getUrl()}/login?${newSearchParam.toString()}`, icon: , label: LdapLoginFieldName || formatMessage({id: 'signup.ldap', defaultMessage: 'AD/LDAP Credentials'}), + onClick: () => {}, }); } @@ -218,11 +231,13 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { const newSearchParam = new URLSearchParams(search); newSearchParam.set('action', 'signup'); + const url = `${Client4.getUrl()}/login/sso/saml?${newSearchParam.toString()}`; externalLoginOptions.push({ id: 'saml', - url: `${Client4.getUrl()}/login/sso/saml?${newSearchParam.toString()}`, + url, icon: , label: SamlLoginButtonText || formatMessage({id: 'login.saml', defaultMessage: 'SAML'}), + onClick: desktopExternalAuth(url), }); } @@ -295,6 +310,17 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { setIsMobileView(window.innerWidth < MOBILE_SCREEN_WIDTH); }, 100); + const desktopExternalAuth = (href: string) => { + return (event: React.MouseEvent) => { + if (isDesktopApp()) { + event.preventDefault(); + + setDesktopLoginLink(href); + history.push(`/signup_user_complete/desktop${search}`); + } + }; + }; + useEffect(() => { dispatch(removeGlobalItem('team')); trackEvent('signup', 'signup_user_01_welcome', {...getRoleFromTrackFlow(), ...getMediumFromTrackFlow()}); @@ -448,6 +474,12 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { return; } + await postSignupSuccess(); + }; + + const postSignupSuccess = async () => { + const redirectTo = (new URLSearchParams(search)).get('redirect_to'); + if (graphQLEnabled) { await dispatch(loadMe()); } else { @@ -696,6 +728,20 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { ); } + if (desktopLoginLink) { + return ( + ( + + )} + /> + ); + } + let emailCustomLabelForInput: CustomMessageInputType = parsedEmail ? { type: ItemStatus.INFO, value: formatMessage( diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 889d1c8c81..f04dae03d0 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3277,6 +3277,13 @@ "demote_to_user_modal.demote": "Demote", "demote_to_user_modal.desc": "This action demotes the user {username} to a guest. It will restrict the user's ability to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to demote user {username} to guest?", "demote_to_user_modal.title": "Demote User {username} to Guest", + "desktop_auth_token.complete.havingTrouble": "Having trouble logging in?
Open Mattermost in your browser", + "desktop_auth_token.complete.openMattermost": "Click on Open Mattermost in the browser prompt to launch the desktop app", + "desktop_auth_token.complete.youAreNowLoggedIn": "You are now logged in", + "desktop_auth_token.error.restartFlow": "Click here to try again.", + "desktop_auth_token.error.somethingWentWrong": "Something went wrong", + "desktop_auth_token.polling.awaitingToken": "Authenticating in the browser, awaiting valid token.", + "desktop_auth_token.polling.redirectingToBrowser": "Redirecting to browser...", "device_icons.android": "Android Icon", "device_icons.apple": "Apple Icon", "device_icons.linux": "Linux Icon", diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 96ed48db08..d38c888ee7 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -755,6 +755,18 @@ export default class Client4 { return profile; }; + loginWithDesktopToken = async (token: string) => { + const body: any = { + token, + deviceId: '', + }; + + return await this.doFetch( + `${this.getUsersRoute()}/login/desktop_token`, + {method: 'post', body: JSON.stringify(body)}, + ); + }; + loginById = (id: string, password: string, token = '') => { this.trackEvent('api', 'api_users_login'); const body: any = {