diff --git a/server/channels/api4/handlers.go b/server/channels/api4/handlers.go index 9005395877..45571429e9 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 cdaa4b9d5a..569f69fe84 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") @@ -1966,6 +1967,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 ea5f74426f..bc9b7ee7e6 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -433,6 +433,7 @@ type AppIface interface { AttachCloudSessionCookie(c *request.Context, w http.ResponseWriter, r *http.Request) AttachDeviceId(sessionID string, deviceID string, expiresAt int64) *model.AppError AttachSessionCookies(c *request.Context, w http.ResponseWriter, r *http.Request) + AuthenticateDesktopToken(token string, expiryTime int64, user *model.User) *model.AppError AuthenticateUserForLogin(c *request.Context, 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.User, *model.AppError) AutocompleteChannels(c request.CTX, userID, term string) (model.ChannelListWithTeamData, *model.AppError) @@ -485,6 +486,7 @@ type AppIface interface { CreateChannelWithUser(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError) CreateCommand(cmd *model.Command) (*model.Command, *model.AppError) CreateCommandWebhook(commandID string, args *model.CommandArgs) (*model.CommandWebhook, *model.AppError) + CreateDesktopToken(token string, createdAt int64) *model.AppError CreateEmoji(c request.CTX, sessionUserId string, emoji *model.Emoji, multiPartImageData *multipart.Form) (*model.Emoji, *model.AppError) CreateGroup(group *model.Group) (*model.Group, *model.AppError) CreateGroupChannel(c request.CTX, userIDs []string, creatorId string) (*model.Channel, *model.AppError) @@ -701,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) @@ -1176,6 +1178,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..b26789b64b --- /dev/null +++ b/server/channels/app/desktop_login.go @@ -0,0 +1,78 @@ +// 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) CreateDesktopToken(token string, createdAt int64) *model.AppError { + // Check if the token already exists in the database somehow + // If so return an error + _, getErr := a.Srv().Store().DesktopTokens().GetUserId(token, 0) + if getErr == nil { + return model.NewAppError("CreateDesktopToken", "app.desktop_token.create.collision", nil, "", http.StatusBadRequest) + } + + // Create token in the database + err := a.Srv().Store().DesktopTokens().Insert(token, createdAt, nil) + if err != nil { + return model.NewAppError("CreateDesktopToken", "app.desktop_token.create.error", nil, err.Error(), http.StatusInternalServerError) + } + + return nil +} + +func (a *App) AuthenticateDesktopToken(token string, expiryTime int64, user *model.User) *model.AppError { + // Throw an error if the token is expired + err := a.Srv().Store().DesktopTokens().SetUserId(token, expiryTime, user.Id) + if err != nil { + // Delete the token if it is expired + a.Srv().Go(func() { + a.Srv().Store().DesktopTokens().Delete(token) + }) + + return model.NewAppError("AuthenticateDesktopToken", "app.desktop_token.authenticate.invalid_or_expired", nil, err.Error(), http.StatusBadRequest) + } + + return nil +} + +func (a *App) ValidateDesktopToken(token string, expiryTime int64) (*model.User, *model.AppError) { + // Check if token is expired + userId, err := a.Srv().Store().DesktopTokens().GetUserId(token, expiryTime) + if err != nil { + // Delete the token if it is expired + a.Srv().Go(func() { + a.Srv().Store().DesktopTokens().Delete(token) + }) + + return nil, model.NewAppError("ValidateDesktopToken", "app.desktop_token.validate.expired", nil, err.Error(), http.StatusUnauthorized) + } + + // If there's no user id, it's not authenticated yet + if userId == "" { + return nil, model.NewAppError("ValidateDesktopToken", "app.desktop_token.validate.invalid", nil, "", 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().Go(func() { + 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().Go(func() { + 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..eecb02692d --- /dev/null +++ b/server/channels/app/desktop_login_test.go @@ -0,0 +1,124 @@ +// 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 TestCreateDesktopToken(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + existingErr := th.App.CreateDesktopToken("existing_token", time.Now().Unix()) + require.Nil(t, existingErr) + + t.Run("create token", func(t *testing.T) { + err := th.App.CreateDesktopToken("new_token", time.Now().Unix()) + assert.Nil(t, err) + + user, err := th.App.ValidateDesktopToken("new_token", time.Now().Add(-TTL).Unix()) + assert.Nil(t, user) + assert.NotNil(t, err) + assert.Equal(t, "app.desktop_token.validate.invalid", err.Id) + }) + + t.Run("create token - already exists", func(t *testing.T) { + err := th.App.CreateDesktopToken("existing_token", time.Now().Unix()) + assert.NotNil(t, err) + assert.Equal(t, "app.desktop_token.create.collision", err.Id) + }) +} + +func TestAuthenticateDesktopToken(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + err := th.App.CreateDesktopToken("unauthenticated_token", time.Now().Unix()) + require.Nil(t, err) + + err = th.App.CreateDesktopToken("expired_token", time.Now().Add(-ExpiredLength).Unix()) + require.Nil(t, err) + + t.Run("authenticate token", func(t *testing.T) { + err := th.App.AuthenticateDesktopToken("unauthenticated_token", time.Now().Add(-TTL).Unix(), th.BasicUser) + assert.Nil(t, err) + + user, err := th.App.ValidateDesktopToken("unauthenticated_token", time.Now().Add(-TTL).Unix()) + assert.Nil(t, err) + assert.NotNil(t, user) + assert.Equal(t, th.BasicUser.Id, user.Id) + }) + + t.Run("authenticate token - expired", func(t *testing.T) { + err := th.App.AuthenticateDesktopToken("expired_token", time.Now().Add(-TTL).Unix(), th.BasicUser) + assert.NotNil(t, err) + assert.Equal(t, "app.desktop_token.authenticate.invalid_or_expired", err.Id) + + _, err = th.App.ValidateDesktopToken("expired_token", time.Now().Add(-TTL).Unix()) + assert.NotNil(t, err) + }) +} + +func TestValidateDesktopToken(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + err := th.App.CreateDesktopToken("authenticated_token", time.Now().Unix()) + require.Nil(t, err) + err = th.App.AuthenticateDesktopToken("authenticated_token", time.Now().Add(-TTL).Unix(), th.BasicUser) + require.Nil(t, err) + + err = th.App.CreateDesktopToken("expired_token_2", time.Now().Add(-ExpiredLength).Unix()) + require.Nil(t, err) + err = th.App.AuthenticateDesktopToken("expired_token_2", time.Now().Add(-ExpiredLength).Unix(), th.BasicUser) + require.Nil(t, err) + + err = th.App.CreateDesktopToken("unauthenticated_token_2", time.Now().Unix()) + require.Nil(t, err) + + badUser := model.User{Id: "some_garbage_user_id"} + err = th.App.CreateDesktopToken("authenticated_token_bad_user", time.Now().Unix()) + require.Nil(t, err) + err = th.App.AuthenticateDesktopToken("authenticated_token_bad_user", time.Now().Add(-TTL).Unix(), &badUser) + require.Nil(t, err) + + t.Run("validate token", func(t *testing.T) { + user, err := th.App.ValidateDesktopToken("authenticated_token", 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("expired_token_2", time.Now().Add(-TTL).Unix()) + assert.NotNil(t, err) + assert.Nil(t, user) + assert.Equal(t, "app.desktop_token.validate.expired", err.Id) + }) + + t.Run("validate token - not authenticated", func(t *testing.T) { + user, err := th.App.ValidateDesktopToken("unauthenticated_token_2", 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("authenticated_token_bad_user", 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 2a186b2fc7..e17ea40b13 100644 --- a/server/channels/app/oauth.go +++ b/server/channels/app/oauth.go @@ -431,7 +431,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 != "" { @@ -442,6 +442,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) @@ -452,13 +456,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 a9f6b929e0..769da33a7d 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -771,6 +771,28 @@ func (a *OpenTracingAppLayer) AttachSessionCookies(c *request.Context, w http.Re a.app.AttachSessionCookies(c, w, r) } +func (a *OpenTracingAppLayer) AuthenticateDesktopToken(token string, expiryTime int64, user *model.User) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.AuthenticateDesktopToken") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.AuthenticateDesktopToken(token, expiryTime, user) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) AuthenticateUserForLogin(c *request.Context, 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") @@ -2042,6 +2064,28 @@ func (a *OpenTracingAppLayer) CreateDefaultMemberships(c *request.Context, param return resultVar0 } +func (a *OpenTracingAppLayer) CreateDesktopToken(token string, createdAt int64) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateDesktopToken") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.CreateDesktopToken(token, createdAt) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) CreateEmoji(c request.CTX, sessionUserId string, emoji *model.Emoji, multiPartImageData *multipart.Form) (*model.Emoji, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateEmoji") @@ -7508,7 +7552,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") @@ -7520,7 +7564,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)) @@ -7530,7 +7574,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") @@ -7542,7 +7586,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)) @@ -18676,6 +18720,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 a70eca0459..30d39a8aa2 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" @@ -1635,6 +1636,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 3fa6593781..170f1be529 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -218,6 +218,8 @@ channels/db/migrations/mysql/000108_remove_orphaned_oauth_preferences.down.sql channels/db/migrations/mysql/000108_remove_orphaned_oauth_preferences.up.sql channels/db/migrations/mysql/000109_create_persistent_notifications.down.sql channels/db/migrations/mysql/000109_create_persistent_notifications.up.sql +channels/db/migrations/mysql/000110_create_desktop_tokens.down.sql +channels/db/migrations/mysql/000110_create_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 @@ -436,3 +438,5 @@ channels/db/migrations/postgres/000108_remove_orphaned_oauth_preferences.down.sq channels/db/migrations/postgres/000108_remove_orphaned_oauth_preferences.up.sql channels/db/migrations/postgres/000109_create_persistent_notifications.down.sql channels/db/migrations/postgres/000109_create_persistent_notifications.up.sql +channels/db/migrations/postgres/000110_create_desktop_tokens.down.sql +channels/db/migrations/postgres/000110_create_desktop_tokens.up.sql diff --git a/server/channels/db/migrations/mysql/000110_create_desktop_tokens.down.sql b/server/channels/db/migrations/mysql/000110_create_desktop_tokens.down.sql new file mode 100644 index 0000000000..6ca1d2d0c5 --- /dev/null +++ b/server/channels/db/migrations/mysql/000110_create_desktop_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS DesktopTokens; \ No newline at end of file diff --git a/server/channels/db/migrations/mysql/000110_create_desktop_tokens.up.sql b/server/channels/db/migrations/mysql/000110_create_desktop_tokens.up.sql new file mode 100644 index 0000000000..f6ea08db50 --- /dev/null +++ b/server/channels/db/migrations/mysql/000110_create_desktop_tokens.up.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS DesktopTokens ( + DesktopToken varchar(64) NOT NULL, + UserId varchar(26) NULL, + CreateAt bigint NOT NULL, + PRIMARY KEY (DesktopToken) +); + +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, + 'SELECT 1', + 'CREATE INDEX idx_desktoptokens_createat ON DesktopTokens(CreateAt);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; \ No newline at end of file diff --git a/server/channels/db/migrations/postgres/000110_create_desktop_tokens.down.sql b/server/channels/db/migrations/postgres/000110_create_desktop_tokens.down.sql new file mode 100644 index 0000000000..43af74ea2a --- /dev/null +++ b/server/channels/db/migrations/postgres/000110_create_desktop_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS desktoptokens; diff --git a/server/channels/db/migrations/postgres/000110_create_desktop_tokens.up.sql b/server/channels/db/migrations/postgres/000110_create_desktop_tokens.up.sql new file mode 100644 index 0000000000..44f923923c --- /dev/null +++ b/server/channels/db/migrations/postgres/000110_create_desktop_tokens.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS desktoptokens ( + desktoptoken VARCHAR(64) NOT NULL, + userid VARCHAR(26), + createat BIGINT NOT NULL, + PRIMARY KEY (desktoptoken) +); + +CREATE INDEX IF NOT EXISTS idx_desktoptokens_createat ON desktoptokens(createat); \ No newline at end of file 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 d349ce7eaa..2b2b56e633 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -27,6 +27,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 @@ -96,6 +97,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 } @@ -276,6 +281,11 @@ type OpenTracingLayerComplianceStore struct { Root *OpenTracingLayer } +type OpenTracingLayerDesktopTokensStore struct { + store.DesktopTokensStore + Root *OpenTracingLayer +} + type OpenTracingLayerDraftStore struct { store.DraftStore Root *OpenTracingLayer @@ -3271,6 +3281,114 @@ func (s *OpenTracingLayerComplianceStore) Update(compliance *model.Compliance) ( return result, err } +func (s *OpenTracingLayerDesktopTokensStore) Delete(desktopToken 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(desktopToken) + 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(desktopToken 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(desktopToken, minCreatedAt) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerDesktopTokensStore) Insert(desktopToken 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(desktopToken, createdAt, userId) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerDesktopTokensStore) SetUserId(desktopToken string, minCreatedAt int64, userId string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DesktopTokensStore.SetUserId") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.DesktopTokensStore.SetUserId(desktopToken, minCreatedAt, 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") @@ -13030,6 +13148,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 cb34c00a22..dd0a689ad0 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -30,6 +30,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 @@ -99,6 +100,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 } @@ -279,6 +284,11 @@ type RetryLayerComplianceStore struct { Root *RetryLayer } +type RetryLayerDesktopTokensStore struct { + store.DesktopTokensStore + Root *RetryLayer +} + type RetryLayerDraftStore struct { store.DraftStore Root *RetryLayer @@ -3650,6 +3660,132 @@ func (s *RetryLayerComplianceStore) Update(compliance *model.Compliance) (*model } +func (s *RetryLayerDesktopTokensStore) Delete(desktopToken string) error { + + tries := 0 + for { + err := s.DesktopTokensStore.Delete(desktopToken) + 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(desktopToken string, minCreatedAt int64) (string, error) { + + tries := 0 + for { + result, err := s.DesktopTokensStore.GetUserId(desktopToken, 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(desktopToken string, createdAt int64, userId *string) error { + + tries := 0 + for { + err := s.DesktopTokensStore.Insert(desktopToken, 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 *RetryLayerDesktopTokensStore) SetUserId(desktopToken string, minCreatedAt int64, userId string) error { + + tries := 0 + for { + err := s.DesktopTokensStore.SetUserId(desktopToken, minCreatedAt, 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 @@ -14853,6 +14989,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..11d6e95bc4 --- /dev/null +++ b/server/channels/store/sqlstore/desktop_tokens_store.go @@ -0,0 +1,163 @@ +// 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(desktopToken string, minCreateAt int64) (string, error) { + query := s.getQueryBuilder(). + Select("UserId"). + From("DesktopTokens"). + Where(sq.Eq{ + "DesktopToken": desktopToken, + }). + Where(sq.GtOrEq{ + "CreateAt": minCreateAt, + }) + + dt := struct{ UserId sql.NullString }{} + err := s.GetReplicaX().GetBuilder(&dt, query) + + if err != nil { + if err == sql.ErrNoRows { + return "", store.NewErrNotFound("DesktopTokens", desktopToken) + } + return "", errors.Wrapf(err, "No token for %s", desktopToken) + } + + // Check if the string is NULL, if so just return a blank string + if dt.UserId.Valid { + return dt.UserId.String, nil + } + + return "", nil +} + +func (s *SqlDesktopTokensStore) Insert(desktopToken string, createAt int64, userId *string) error { + builder := s.getQueryBuilder(). + Insert("DesktopTokens"). + Columns("DesktopToken", "CreateAt", "UserId"). + Values(desktopToken, 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) SetUserId(desktopToken string, minCreateAt int64, userId string) error { + builder := s.getQueryBuilder(). + Update("DesktopTokens"). + Set("UserId", userId). + Where(sq.Eq{ + "DesktopToken": desktopToken, + }). + Where(sq.GtOrEq{ + "CreateAt": minCreateAt, + }) + + query, args, err := builder.ToSql() + + if err != nil { + return errors.Wrap(err, "set_userid_desktoptokens_tosql") + } + + result, err := s.GetMasterX().Exec(query, args...) + if err != nil { + return errors.Wrap(err, "failed to update token row") + } + + num, err := result.RowsAffected() + if err != nil { + return errors.Wrap(err, "nothing updated") + } + if num == 0 { + return errors.New("no rows updated") + } + + return nil +} + +func (s *SqlDesktopTokensStore) Delete(desktopToken string) error { + builder := s.getQueryBuilder(). + Delete("DesktopTokens"). + Where(sq.Eq{ + "DesktopToken": desktopToken, + }) + + 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 3ea68e4605..d4bb823e3a 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -105,6 +105,7 @@ type SqlStoreStores struct { postAcknowledgement store.PostAcknowledgementStore postPersistentNotification store.PostPersistentNotificationStore trueUpReview store.TrueUpReviewStore + desktopTokens store.DesktopTokensStore } type SqlStore struct { @@ -226,6 +227,7 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS 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() @@ -1077,6 +1079,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() { var tableSchemaFn string if ss.DriverName() == model.DatabaseDriverPostgres { diff --git a/server/channels/store/store.go b/server/channels/store/store.go index b9dda01150..4d226ff127 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -86,6 +86,7 @@ type Store interface { PostAcknowledgement() PostAcknowledgementStore PostPersistentNotification() PostPersistentNotificationStore TrueUpReview() TrueUpReviewStore + DesktopTokens() DesktopTokensStore } type RetentionPolicyStore interface { @@ -665,6 +666,15 @@ type TokenStore interface { RemoveAllTokensByType(tokenType string) error } +type DesktopTokensStore interface { + GetUserId(desktopToken string, minCreatedAt int64) (string, error) + Insert(desktopToken string, createdAt int64, userId *string) error + SetUserId(desktopToken string, minCreatedAt int64, userId string) error + Delete(desktopToken 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..d7d5156717 --- /dev/null +++ b/server/channels/store/storetest/desktop_tokens_store.go @@ -0,0 +1,173 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "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("SetUserId", func(t *testing.T) { testSetUserId(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, nil) + require.NoError(t, err) + err = ss.DesktopTokens().SetUserId("token_with_id", 1000, "user_id") + require.NoError(t, err) + + err = ss.DesktopTokens().Insert("token_without_id", 2000, nil) + 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.Equal(t, "", userId) + }) + + t.Run("get user id - no id set", func(t *testing.T) { + userId, err := ss.DesktopTokens().GetUserId("token_without_id", 2000) + assert.NoError(t, err) + assert.Equal(t, "", userId) + }) +} + +func testInsert(t *testing.T, ss store.Store) { + t.Run("insert", func(t *testing.T) { + err := ss.DesktopTokens().Insert("token", 1000, nil) + assert.NoError(t, err) + }) + + t.Run("insert with user id", func(t *testing.T) { + err := ss.DesktopTokens().Insert("token_2", 1000, model.NewString("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, + nil, + ) + assert.Error(t, err) + }) +} + +func testSetUserId(t *testing.T, ss store.Store) { + err := ss.DesktopTokens().Insert("new_token", 1000, nil) + require.NoError(t, err) + err = ss.DesktopTokens().Insert("new_token_2", 1000, nil) + require.NoError(t, err) + + t.Run("set user id", func(t *testing.T) { + err := ss.DesktopTokens().SetUserId("new_token", 1000, "user_id") + assert.NoError(t, err) + userId, err := ss.DesktopTokens().GetUserId("new_token", 1000) + assert.NoError(t, err) + assert.Equal(t, "user_id", userId) + }) + + t.Run("set user id - doesn't exist", func(t *testing.T) { + err := ss.DesktopTokens().SetUserId("different_token", 1000, "user_id") + assert.Error(t, err) + _, err = ss.DesktopTokens().GetUserId("different_token", 1000) + assert.Error(t, err) + }) + + t.Run("set user id - expired", func(t *testing.T) { + err := ss.DesktopTokens().SetUserId("new_token", 2000, "user_id") + assert.Error(t, err) + _, err = ss.DesktopTokens().GetUserId("new_token", 2000) + assert.Error(t, err) + }) + + t.Run("set user id - user id too long", func(t *testing.T) { + err := ss.DesktopTokens().SetUserId( + "new_token_2", + 1000, + "user_id that is WAAAAAAAAAAAAAY WAAAAAAAAAAAAAY WAAAAAAAAAAAAAY TOO LONG", + ) + assert.Error(t, err) + userId, err := ss.DesktopTokens().GetUserId("new_token_2", 1000) + assert.NoError(t, err) + assert.Equal(t, "", userId) + }) +} + +func testDeleteToken(t *testing.T, ss store.Store) { + err := ss.DesktopTokens().Insert("deleteable_token", 3000, nil) + require.NoError(t, err) + err = ss.DesktopTokens().SetUserId("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, nil) + require.NoError(t, err) + err = ss.DesktopTokens().SetUserId("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, nil) + require.NoError(t, err) + err = ss.DesktopTokens().Insert("deleteable_token_new", 5000, nil) + 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..5add045a98 --- /dev/null +++ b/server/channels/store/storetest/mocks/DesktopTokensStore.go @@ -0,0 +1,121 @@ +// 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: desktopToken +func (_m *DesktopTokensStore) Delete(desktopToken string) error { + ret := _m.Called(desktopToken) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(desktopToken) + } 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: desktopToken, minCreatedAt +func (_m *DesktopTokensStore) GetUserId(desktopToken string, minCreatedAt int64) (string, error) { + ret := _m.Called(desktopToken, minCreatedAt) + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string, int64) (string, error)); ok { + return rf(desktopToken, minCreatedAt) + } + if rf, ok := ret.Get(0).(func(string, int64) string); ok { + r0 = rf(desktopToken, minCreatedAt) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string, int64) error); ok { + r1 = rf(desktopToken, minCreatedAt) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Insert provides a mock function with given fields: desktopToken, createdAt, userId +func (_m *DesktopTokensStore) Insert(desktopToken string, createdAt int64, userId *string) error { + ret := _m.Called(desktopToken, createdAt, userId) + + var r0 error + if rf, ok := ret.Get(0).(func(string, int64, *string) error); ok { + r0 = rf(desktopToken, createdAt, userId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SetUserId provides a mock function with given fields: desktopToken, minCreatedAt, userId +func (_m *DesktopTokensStore) SetUserId(desktopToken string, minCreatedAt int64, userId string) error { + ret := _m.Called(desktopToken, minCreatedAt, userId) + + var r0 error + if rf, ok := ret.Get(0).(func(string, int64, string) error); ok { + r0 = rf(desktopToken, minCreatedAt, 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 2a50f246e0..c6fd7311b5 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 2aa1a4f5d0..f37c67789c 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 } @@ -176,5 +178,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 e94b3580ba..ed95d5d813 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 @@ -3000,6 +3010,102 @@ func (s *TimerLayerComplianceStore) Update(compliance *model.Compliance) (*model return result, err } +func (s *TimerLayerDesktopTokensStore) Delete(desktopToken string) error { + start := time.Now() + + err := s.DesktopTokensStore.Delete(desktopToken) + + 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(desktopToken string, minCreatedAt int64) (string, error) { + start := time.Now() + + result, err := s.DesktopTokensStore.GetUserId(desktopToken, 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(desktopToken string, createdAt int64, userId *string) error { + start := time.Now() + + err := s.DesktopTokensStore.Insert(desktopToken, 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 *TimerLayerDesktopTokensStore) SetUserId(desktopToken string, minCreatedAt int64, userId string) error { + start := time.Now() + + err := s.DesktopTokensStore.SetUserId(desktopToken, minCreatedAt, 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.SetUserId", success, elapsed) + } + return err +} + func (s *TimerLayerDraftStore) Delete(userID string, channelID string, rootID string) error { start := time.Now() @@ -11741,6 +11847,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 21e2eca3fb..07f9525d10 100644 --- a/server/channels/web/oauth.go +++ b/server/channels/web/oauth.go @@ -10,6 +10,7 @@ import ( "net/url" "path/filepath" "strings" + "time" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/i18n" @@ -343,6 +344,29 @@ 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 != "" { + desktopTokenErr := c.App.AuthenticateDesktopToken(desktopToken, time.Now().Add(-model.DesktopTokenTTL).Unix(), user) + if desktopTokenErr != nil { + desktopTokenErr.Translate(c.AppContext.T) + c.LogErrorByCode(desktopTokenErr) + renderError(desktopTokenErr) + return + } + + queryString := map[string]string{ + "desktopAuthComplete": "true", + } + if val, ok := props["redirect_to"]; ok { + queryString["redirect_to"] = val + } + redirectURL = utils.AppendQueryParamsToURL(c.GetSiteURLHeader()+"/login/desktop", queryString) + } } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -357,6 +381,15 @@ 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 desktopToken != "" { + desktopTokenErr := c.App.CreateDesktopToken(desktopToken, time.Now().Unix()) + + if desktopTokenErr != nil { + c.Err = desktopTokenErr + return + } + } if redirectURL != "" && !utils.IsValidWebAuthRedirectURL(c.App.Config(), redirectURL) { c.Err = model.NewAppError("loginWithOAuth", "api.invalid_redirect_url", nil, "", http.StatusBadRequest) @@ -369,7 +402,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 @@ -398,7 +431,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 @@ -426,7 +459,17 @@ 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") + if desktopToken != "" { + desktopTokenErr := c.App.CreateDesktopToken(desktopToken, time.Now().Unix()) + + if desktopTokenErr != nil { + c.Err = desktopTokenErr + return + } + } + + 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..f124454d7b 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,16 @@ func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) { relayProps["redirect_to"] = redirectURL } + desktopToken := r.URL.Query().Get("desktop_token") + if desktopToken != "" { + desktopTokenErr := c.App.CreateDesktopToken(desktopToken, time.Now().Unix()) + if desktopTokenErr != nil { + c.Err = err + return + } + relayProps["desktop_token"] = desktopToken + } + relayProps[model.UserAuthServiceIsMobile] = strconv.FormatBool(isMobile) if len(relayProps) > 0 { @@ -185,6 +196,26 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { c.App.AttachSessionCookies(c.AppContext, w, r) + desktopToken := relayProps["desktop_token"] + if desktopToken != "" { + desktopTokenErr := c.App.AuthenticateDesktopToken(desktopToken, time.Now().Add(-model.DesktopTokenTTL).Unix(), user) + if desktopTokenErr != nil { + handleError(desktopTokenErr) + return + } + + queryString := map[string]string{ + "desktopAuthComplete": "true", + } + if val, ok := relayProps["redirect_to"]; ok { + queryString["redirect_to"] = val + } + + 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 055802a4d1..77909fcad5 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -5023,6 +5023,30 @@ "id": "app.custom_group.unique_name", "translation": "group name is not unique" }, + { + "id": "app.desktop_token.authenticate.invalid_or_expired", + "translation": "Token does not exist or is expired" + }, + { + "id": "app.desktop_token.create.collision", + "translation": "This token already exists in the system" + }, + { + "id": "app.desktop_token.create.error", + "translation": "An error occured attempted to create the token" + }, + { + "id": "app.desktop_token.validate.expired", + "translation": "Token is expired" + }, + { + "id": "app.desktop_token.validate.invalid", + "translation": "Token is not valid" + }, + { + "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 9c3b010c30..e4c53c5677 100644 --- a/server/public/model/job.go +++ b/server/public/model/job.go @@ -35,6 +35,7 @@ const ( JobTypePostPersistentNotifications = "post_persistent_notifications" JobTypeInstallPluginNotifyAdmin = "install_plugin_notify_admin" JobTypeHostedPurchaseScreening = "hosted_purchase_screening" + JobTypeCleanupDesktopTokens = "cleanup_desktop_tokens" JobStatusPending = "pending" JobStatusInProgress = "in_progress" @@ -65,6 +66,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 39f19b9cc9..0437be07d3 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..bbbc54a4f0 --- /dev/null +++ b/webapp/channels/src/components/desktop_auth_token.tsx @@ -0,0 +1,240 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useRef, useState} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useDispatch, useSelector} 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 {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {DispatchFunc} from 'mattermost-redux/types/actions'; + +import {loginWithDesktopToken} from 'actions/views/login'; + +import './desktop_auth_token.scss'; + +const BOTTOM_MESSAGE_TIMEOUT = 10000; +const POLLING_INTERVAL = 2000; + +enum DesktopAuthStatus { + None, + Polling, + Expired, + Complete, +} + +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 [status, setStatus] = useState(query.get('desktopAuthComplete') ? DesktopAuthStatus.Complete : DesktopAuthStatus.None); + const [token, setToken] = useState(''); + const [showBottomMessage, setShowBottomMessage] = useState(); + + const interval = useRef(); + + const {SiteURL} = useSelector(getConfig); + + const tryDesktopLogin = async () => { + const {data: userProfile, error: loginError} = await dispatch(loginWithDesktopToken(token)); + + if (loginError && loginError.server_error_id && loginError.server_error_id.length !== 0) { + if (loginError.server_error_id === 'app.desktop_token.validate.expired') { + clearInterval(interval.current as unknown as number); + setStatus(DesktopAuthStatus.Expired); + } + return; + } + + clearInterval(interval.current as unknown as number); + setStatus(DesktopAuthStatus.Complete); + await onLogin(userProfile as UserProfile); + }; + + const getExternalLoginURL = () => { + const parsedURL = new URL(href); + + const params = new URLSearchParams(parsedURL.searchParams); + params.set('desktop_token', token); + + return `${parsedURL.origin}${parsedURL.pathname}?${params.toString()}`; + }; + + const openDesktopApp = () => { + if (!SiteURL) { + return; + } + const url = new URL(SiteURL); + const redirectTo = query.get('redirect_to'); + if (redirectTo) { + url.pathname += redirectTo; + } + 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 (!token) { + return () => {}; + } + + const url = getExternalLoginURL(); + window.open(url); + + setStatus(DesktopAuthStatus.Polling); + interval.current = setInterval(tryDesktopLogin, POLLING_INTERVAL); + + return () => { + clearInterval(interval.current as unknown as number); + }; + }, [token]); + + useEffect(() => { + if (status === DesktopAuthStatus.Complete) { + openDesktopApp(); + return; + } + + setToken(crypto.randomBytes(32).toString('hex')); + }, []); + + let mainMessage; + let subMessage; + let bottomMessage; + + if (status === DesktopAuthStatus.Polling) { + mainMessage = ( + + ); + subMessage = ( + + ); + + bottomMessage = ( + { + return ( + + {chunks} + + ); + }, + }} + /> + ); + } + + if (status === DesktopAuthStatus.Complete) { + mainMessage = ( + + ); + subMessage = ( + { + return ( + + {chunks} + + ); + }, + b: (chunks: React.ReactNode) => ({chunks}), + }} + /> + ); + + bottomMessage = ( + { + return ( + history.push('/')}> + {chunks} + + ); + }, + }} + /> + ); + } + + if (status === DesktopAuthStatus.Expired) { + 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 d0689b6164..ec1b33c72a 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); @@ -378,6 +403,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('desktopAuthComplete')) { + return; + } + if (currentUser) { if (redirectTo && redirectTo.match(/^\/([^/]|$)/)) { history.push(redirectTo); @@ -596,6 +626,10 @@ const Login = ({onCustomizeHeader}: LoginProps) => { return; } + await postSubmit(userProfile); + }; + + const postSubmit = async (userProfile: UserProfile) => { if (graphQLEnabled) { await dispatch(loadMe()); } else { @@ -754,6 +788,20 @@ const Login = ({onCustomizeHeader}: LoginProps) => { ); } + if (desktopLoginLink || query.get('desktopAuthComplete')) { + return ( + ( + + )} + /> + ); + } + return ( <>
diff --git a/webapp/channels/src/components/signup/signup.tsx b/webapp/channels/src/components/signup/signup.tsx index 072661e655..5136d6209a 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 { @@ -695,6 +727,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 c761e4e12f..3d75183524 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3288,6 +3288,14 @@ "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.expired.restartFlow": "Click here to try again.", + "desktop_auth_token.expired.somethingWentWrong": "Something went wrong", + "desktop_auth_token.polling.awaitingToken": "Authenticating in the browser, awaiting valid token.", + "desktop_auth_token.polling.isComplete": "Authentication complete? Check token now", + "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 ec34bbe780..a5555993c7 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -756,6 +756,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 = {