From f69b28610a32a8610e1bfa89b45282071e6f2787 Mon Sep 17 00:00:00 2001 From: Ibrahim Serdar Acikgoz Date: Fri, 18 Jun 2021 19:25:03 +0300 Subject: [PATCH] user service: add oauth and profile images (#17784) * users: add cache to service * reflect review comments * add oauth * move profile picture genaration * reflect review comments * move default bot image to users package * add missing wraps and apply suggestions * add comment for app.GetSession --- app/app_iface.go | 4 +- app/channel_test.go | 4 +- app/login.go | 8 +- app/oauth.go | 36 ++-- app/oauth_test.go | 23 +-- app/opentracing/opentracing_layer.go | 15 -- app/server.go | 25 +-- app/session.go | 132 +++++--------- app/session_test.go | 52 ------ app/user.go | 162 ++++-------------- app/user_test.go | 15 -- app/web_hub_test.go | 5 +- .../users}/bot_default_image.go | 4 +- services/users/errors.go | 17 +- services/users/helper_test.go | 18 ++ services/users/profile_picture.go | 117 +++++++++++++ services/users/profile_picture_test.go | 26 +++ services/users/service.go | 82 +++++++++ services/users/service_test.go | 30 ++++ services/users/session.go | 151 +++++++++++++++- services/users/session_test.go | 84 +++++++++ services/users/users.go | 106 +++++------- testlib/store.go | 2 + 23 files changed, 681 insertions(+), 437 deletions(-) rename {model => services/users}/bot_default_image.go (99%) create mode 100644 services/users/profile_picture.go create mode 100644 services/users/profile_picture_test.go create mode 100644 services/users/service.go create mode 100644 services/users/service_test.go diff --git a/app/app_iface.go b/app/app_iface.go index 45b5ce683c..4c850945ad 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -328,8 +328,6 @@ type AppIface interface { // the member's group memberships and the configuration of those groups to the syncable. This method should only // be invoked on group-synced (aka group-constrained) syncables. SyncSyncableRoles(syncableID string, syncableType model.GroupSyncableType) *model.AppError - // TODO: migrate this after the user service implementation is completed - GetSanitizeOptions(asAdmin bool) map[string]bool // TeamMembersMinusGroupMembers returns the set of users on the given team minus the set of users in the given // groups. // @@ -721,6 +719,7 @@ type AppIface interface { GetSamlCertificateStatus() *model.SamlCertificateStatus GetSamlMetadata() (string, *model.AppError) GetSamlMetadataFromIdp(idpMetadataUrl string) (*model.SamlMetadataResponse, *model.AppError) + GetSanitizeOptions(asAdmin bool) map[string]bool GetScheme(id string) (*model.Scheme, *model.AppError) GetSchemeByName(name string) (*model.Scheme, *model.AppError) GetSchemeRolesForTeam(teamID string) (string, string, string, *model.AppError) @@ -1072,7 +1071,6 @@ type AppIface interface { UpdateRemoteClusterTopics(remoteClusterId string, topics string) (*model.RemoteCluster, *model.AppError) UpdateRole(role *model.Role) (*model.Role, *model.AppError) UpdateScheme(scheme *model.Scheme) (*model.Scheme, *model.AppError) - UpdateSessionsIsGuest(userID string, isGuest bool) UpdateSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error) UpdateSharedChannelRemoteCursor(id string, cursor model.GetPostsSinceForSyncCursor) error UpdateSidebarCategories(userID, teamID string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) diff --git a/app/channel_test.go b/app/channel_test.go index 4972f73679..ebd8667e58 100644 --- a/app/channel_test.go +++ b/app/channel_test.go @@ -1970,10 +1970,12 @@ func TestMarkChannelsAsViewedPanic(t *testing.T) { } mockChannelStore.On("UpdateLastViewedAt", []string{"channelID"}, "userID", false).Return(times, nil) mockSessionStore := mocks.SessionStore{} + mockOAuthStore := mocks.OAuthStore{} var err error - th.App.srv.userService, err = users.New(users.ServiceInitializer{ + th.App.srv.userService, err = users.New(users.ServiceConfig{ UserStore: &mockUserStore, SessionStore: &mockSessionStore, + OAuthStore: &mockOAuthStore, ConfigFn: th.App.srv.Config, }) require.NoError(t, err) diff --git a/app/login.go b/app/login.go index c9fb060366..54233f4f86 100644 --- a/app/login.go +++ b/app/login.go @@ -177,7 +177,7 @@ func (a *App) DoLogin(c *request.Context, w http.ResponseWriter, r *http.Request session.GenerateCSRF() if deviceID != "" { - a.SetSessionExpireInDays(session, *a.Config().ServiceSettings.SessionLengthMobileInDays) + a.srv.userService.SetSessionExpireInDays(session, *a.Config().ServiceSettings.SessionLengthMobileInDays) // A special case where we logout of all other sessions with the same Id if err := a.RevokeSessionsForDeviceId(user.Id, deviceID, ""); err != nil { @@ -185,11 +185,11 @@ func (a *App) DoLogin(c *request.Context, w http.ResponseWriter, r *http.Request return err } } else if isMobile { - a.SetSessionExpireInDays(session, *a.Config().ServiceSettings.SessionLengthMobileInDays) + a.srv.userService.SetSessionExpireInDays(session, *a.Config().ServiceSettings.SessionLengthMobileInDays) } else if isOAuthUser || isSaml { - a.SetSessionExpireInDays(session, *a.Config().ServiceSettings.SessionLengthSSOInDays) + a.srv.userService.SetSessionExpireInDays(session, *a.Config().ServiceSettings.SessionLengthSSOInDays) } else { - a.SetSessionExpireInDays(session, *a.Config().ServiceSettings.SessionLengthWebInDays) + a.srv.userService.SetSessionExpireInDays(session, *a.Config().ServiceSettings.SessionLengthWebInDays) } ua := uasurfer.Parse(r.UserAgent()) diff --git a/app/oauth.go b/app/oauth.go index b33c622ed5..86f86ffc40 100644 --- a/app/oauth.go +++ b/app/oauth.go @@ -20,6 +20,7 @@ import ( "github.com/mattermost/mattermost-server/v5/app/request" "github.com/mattermost/mattermost-server/v5/einterfaces" "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/services/users" "github.com/mattermost/mattermost-server/v5/shared/i18n" "github.com/mattermost/mattermost-server/v5/shared/mlog" "github.com/mattermost/mattermost-server/v5/store" @@ -369,7 +370,7 @@ func (a *App) newSession(appName string, user *model.User) (*model.Session, *mod // Set new token an session session := &model.Session{UserId: user.Id, Roles: user.Roles, IsOAuth: true} session.GenerateCSRF() - a.SetSessionExpireInDays(session, *a.Config().ServiceSettings.SessionLengthSSOInDays) + a.srv.userService.SetSessionExpireInDays(session, *a.Config().ServiceSettings.SessionLengthSSOInDays) session.AddProp(model.SESSION_PROP_PLATFORM, appName) session.AddProp(model.SESSION_PROP_OS, "OAuth2") session.AddProp(model.SESSION_PROP_BROWSER, "OAuth2") @@ -518,30 +519,15 @@ func (a *App) RegenerateOAuthAppSecret(app *model.OAuthApp) (*model.OAuthApp, *m } func (a *App) RevokeAccessToken(token string) *model.AppError { - session, _ := a.GetSession(token) - - defer a.srv.userService.ReturnSessionToPool(session) - - schan := make(chan error, 1) - go func() { - schan <- a.Srv().Store.Session().Remove(token) - close(schan) - }() - - if _, err := a.Srv().Store.OAuth().GetAccessData(token); err != nil { - return model.NewAppError("RevokeAccessToken", "api.oauth.revoke_access_token.get.app_error", nil, err.Error(), http.StatusBadRequest) - } - - if err := a.Srv().Store.OAuth().RemoveAccessData(token); err != nil { - return model.NewAppError("RevokeAccessToken", "api.oauth.revoke_access_token.del_token.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - if err := <-schan; err != nil { - return model.NewAppError("RevokeAccessToken", "api.oauth.revoke_access_token.del_session.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - if session != nil { - a.ClearSessionCacheForUser(session.UserId) + if err := a.srv.userService.RevokeAccessToken(token); err != nil { + switch { + case errors.Is(err, users.GetTokenError): + return model.NewAppError("RevokeAccessToken", "api.oauth.revoke_access_token.get.app_error", nil, err.Error(), http.StatusBadRequest) + case errors.Is(err, users.DeleteTokenError): + return model.NewAppError("RevokeAccessToken", "api.oauth.revoke_access_token.del_token.app_error", nil, err.Error(), http.StatusInternalServerError) + case errors.Is(err, users.DeleteSessionError): + return model.NewAppError("RevokeAccessToken", "api.oauth.revoke_access_token.del_session.app_error", nil, err.Error(), http.StatusInternalServerError) + } } return nil diff --git a/app/oauth_test.go b/app/oauth_test.go index 3493ad7495..2fbfbcaaba 100644 --- a/app/oauth_test.go +++ b/app/oauth_test.go @@ -70,9 +70,6 @@ func TestOAuthRevokeAccessToken(t *testing.T) { th := Setup(t) defer th.TearDown() - err := th.App.RevokeAccessToken(model.NewRandomString(16)) - require.NotNil(t, err, "Should have failed bad token") - session := &model.Session{} session.CreateAt = model.GetMillis() session.UserId = model.NewId() @@ -80,22 +77,12 @@ func TestOAuthRevokeAccessToken(t *testing.T) { session.Roles = model.SYSTEM_USER_ROLE_ID th.App.SetSessionExpireInDays(session, 1) - session, _ = th.App.CreateSession(session) + var err *model.AppError + session, err = th.App.CreateSession(session) + require.Nil(t, err) err = th.App.RevokeAccessToken(session.Token) require.NotNil(t, err, "Should have failed does not have an access token") - - accessData := &model.AccessData{} - accessData.Token = session.Token - accessData.UserId = session.UserId - accessData.RedirectUri = "http://example.com" - accessData.ClientId = model.NewId() - accessData.ExpiresAt = session.ExpiresAt - - _, nErr := th.App.Srv().Store.OAuth().SaveAccessData(accessData) - require.NoError(t, nErr) - - err = th.App.RevokeAccessToken(accessData.Token) - require.Nil(t, err) + require.Equal(t, http.StatusBadRequest, err.StatusCode) } func TestOAuthDeleteApp(t *testing.T) { @@ -120,7 +107,7 @@ func TestOAuthDeleteApp(t *testing.T) { session.Token = model.NewId() session.Roles = model.SYSTEM_USER_ROLE_ID session.IsOAuth = true - th.App.SetSessionExpireInDays(session, 1) + th.App.srv.userService.SetSessionExpireInDays(session, 1) session, _ = th.App.CreateSession(session) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 8f55a25761..333abcca89 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -16469,21 +16469,6 @@ func (a *OpenTracingAppLayer) UpdateScheme(scheme *model.Scheme) (*model.Scheme, return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) UpdateSessionsIsGuest(userID string, isGuest bool) { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateSessionsIsGuest") - - a.ctx = newCtx - a.app.Srv().Store.SetContext(newCtx) - defer func() { - a.app.Srv().Store.SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - a.app.UpdateSessionsIsGuest(userID, isGuest) -} - func (a *OpenTracingAppLayer) UpdateSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateSharedChannel") diff --git a/app/server.go b/app/server.go index bed0a4c524..11d8a2fca0 100644 --- a/app/server.go +++ b/app/server.go @@ -409,9 +409,10 @@ func NewServer(options ...Option) (*Server, error) { return nil, errors.Wrap(err, "cannot create store") } - s.userService, err = users.New(users.ServiceInitializer{ + s.userService, err = users.New(users.ServiceConfig{ UserStore: s.Store.User(), SessionStore: s.Store.Session(), + OAuthStore: s.Store.OAuth(), ConfigFn: s.Config, Metrics: s.Metrics, Cluster: s.Cluster, @@ -2264,18 +2265,18 @@ func (s *Server) GetProfileImage(user *model.User) ([]byte, bool, *model.AppErro } func (s *Server) GetDefaultProfileImage(user *model.User) ([]byte, *model.AppError) { - var img []byte - var appErr *model.AppError + img, err := s.userService.GetDefaultProfileImage(user) + if err != nil { + switch { + case errors.Is(err, users.DefaultFontError): + return nil, model.NewAppError("GetDefaultProfileImage", "api.user.create_profile_image.default_font.app_error", nil, err.Error(), http.StatusInternalServerError) + case errors.Is(err, users.UserInitialsError): + return nil, model.NewAppError("GetDefaultProfileImage", "api.user.create_profile_image.initial.app_error", nil, err.Error(), http.StatusInternalServerError) + default: + return nil, model.NewAppError("GetDefaultProfileImage", "api.user.create_profile_image.encode.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } - if user.IsBot { - img = model.BotDefaultImage - appErr = nil - } else { - img, appErr = CreateProfileImage(user.Username, user.Id, *s.Config().FileSettings.InitialFont) - } - if appErr != nil { - return nil, appErr - } return img, nil } diff --git a/app/session.go b/app/session.go index 837573e250..95d25dde85 100644 --- a/app/session.go +++ b/app/session.go @@ -6,16 +6,15 @@ package app import ( "context" "errors" - "fmt" "math" "net/http" "os" "github.com/mattermost/mattermost-server/v5/audit" "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/services/users" "github.com/mattermost/mattermost-server/v5/shared/mlog" "github.com/mattermost/mattermost-server/v5/store" - "github.com/mattermost/mattermost-server/v5/store/sqlstore" ) func (a *App) CreateSession(session *model.Session) (*model.Session, *model.AppError) { @@ -64,25 +63,16 @@ func (a *App) GetRemoteClusterSession(token string, remoteId string) (*model.Ses } func (a *App) GetSession(token string) (*model.Session, *model.AppError) { - session, err := a.srv.userService.GetSession(token) - if err != nil { - return nil, model.NewAppError("GetSession", "app.session.get.app_error", nil, err.Error(), http.StatusInternalServerError) - } + var session *model.Session + // We intentionally skip the error check here, we only want to check if the token is valid. + // If we don't have the session we are going to create one with the token eventually. + if session, _ = a.srv.userService.GetSession(token); session != nil { + if session.Token != token { + return nil, model.NewAppError("GetSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token, "Error": ""}, "session token is different from the one in DB", http.StatusUnauthorized) + } - if session.Id == "" { - var nErr error - if session, nErr = a.Srv().Store.Session().Get(sqlstore.WithMaster(context.Background()), token); nErr == nil { - if session != nil { - if session.Token != token { - return nil, model.NewAppError("GetSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token, "Error": ""}, "session token is different from the one in DB", http.StatusUnauthorized) - } - - if !session.IsExpired() { - a.srv.userService.AddSessionToCache(session) - } - } - } else if nfErr := new(store.ErrNotFound); !errors.As(nErr, &nfErr) { - return nil, model.NewAppError("GetSession", "app.session.get.app_error", nil, nErr.Error(), http.StatusInternalServerError) + if !session.IsExpired() { + a.srv.userService.AddSessionToCache(session) } } @@ -134,7 +124,7 @@ func (a *App) GetSession(token string) (*model.Session, *model.AppError) { } func (a *App) GetSessions(userID string) ([]*model.Session, *model.AppError) { - sessions, err := a.Srv().Store.Session().GetSessions(userID) + sessions, err := a.srv.userService.GetSessions(userID) if err != nil { return nil, model.NewAppError("GetSessions", "app.session.get_sessions.app_error", nil, err.Error(), http.StatusInternalServerError) } @@ -142,41 +132,18 @@ func (a *App) GetSessions(userID string) ([]*model.Session, *model.AppError) { return sessions, nil } -func (a *App) UpdateSessionsIsGuest(userID string, isGuest bool) { - sessions, err := a.Srv().Store.Session().GetSessions(userID) - if err != nil { - mlog.Error("Unable to get user sessions", mlog.String("user_id", userID), mlog.Err(err)) - return - } - - for _, session := range sessions { - session.AddProp(model.SESSION_PROP_IS_GUEST, fmt.Sprintf("%t", isGuest)) - err := a.Srv().Store.Session().UpdateProps(session) - if err != nil { - mlog.Warn("Unable to update isGuest session", mlog.Err(err)) - continue - } - a.srv.userService.AddSessionToCache(session) - } -} - func (a *App) RevokeAllSessions(userID string) *model.AppError { - sessions, err := a.Srv().Store.Session().GetSessions(userID) - if err != nil { - return model.NewAppError("RevokeAllSessions", "app.session.get_sessions.app_error", nil, err.Error(), http.StatusInternalServerError) - } - for _, session := range sessions { - if session.IsOAuth { - a.RevokeAccessToken(session.Token) - } else { - if err := a.Srv().Store.Session().Remove(session.Id); err != nil { - return model.NewAppError("RevokeAllSessions", "app.session.remove.app_error", nil, err.Error(), http.StatusInternalServerError) - } + if err := a.srv.userService.RevokeAllSessions(userID); err != nil { + switch { + case errors.Is(err, users.GetSessionError): + return model.NewAppError("RevokeAllSessions", "app.session.get_sessions.app_error", nil, err.Error(), http.StatusInternalServerError) + case errors.Is(err, users.DeleteSessionError): + return model.NewAppError("RevokeAllSessions", "app.session.remove.app_error", nil, err.Error(), http.StatusInternalServerError) + default: + return model.NewAppError("RevokeAllSessions", "app.session.remove.app_error", nil, err.Error(), http.StatusInternalServerError) } } - a.ClearSessionCacheForUser(userID) - return nil } @@ -187,16 +154,14 @@ func (a *App) AddSessionToCache(session *model.Session) { // RevokeSessionsFromAllUsers will go through all the sessions active // in the server and revoke them func (a *App) RevokeSessionsFromAllUsers() *model.AppError { - // revoke tokens before sessions so they can't be used to relogin - nErr := a.Srv().Store.OAuth().RemoveAllAccessData() - if nErr != nil { - return model.NewAppError("RevokeSessionsFromAllUsers", "app.oauth.remove_access_data.app_error", nil, nErr.Error(), http.StatusInternalServerError) + if err := a.srv.userService.RevokeSessionsFromAllUsers(); err != nil { + switch { + case errors.Is(err, users.DeleteAllAccessDataError): + return model.NewAppError("RevokeSessionsFromAllUsers", "app.oauth.remove_access_data.app_error", nil, err.Error(), http.StatusInternalServerError) + default: + return model.NewAppError("RevokeSessionsFromAllUsers", "app.session.remove_all_sessions_for_team.app_error", nil, err.Error(), http.StatusInternalServerError) + } } - err := a.Srv().Store.Session().RemoveAllSessions() - if err != nil { - return model.NewAppError("RevokeSessionsFromAllUsers", "app.session.remove_all_sessions_for_team.app_error", nil, err.Error(), http.StatusInternalServerError) - } - a.ClearSessionCacheForAllUsers() return nil } @@ -222,24 +187,15 @@ func (a *App) ClearSessionCacheForAllUsersSkipClusterSend() { } func (a *App) RevokeSessionsForDeviceId(userID string, deviceID string, currentSessionId string) *model.AppError { - sessions, err := a.Srv().Store.Session().GetSessions(userID) - if err != nil { + if err := a.srv.userService.RevokeSessionsForDeviceId(userID, deviceID, currentSessionId); err != nil { return model.NewAppError("RevokeSessionsForDeviceId", "app.session.get_sessions.app_error", nil, err.Error(), http.StatusInternalServerError) } - for _, session := range sessions { - if session.DeviceId == deviceID && session.Id != currentSessionId { - mlog.Debug("Revoking sessionId for userId. Re-login with the same device Id", mlog.String("session_id", session.Id), mlog.String("user_id", userID)) - if err := a.RevokeSession(session); err != nil { - mlog.Warn("Could not revoke session for device", mlog.String("device_id", deviceID), mlog.Err(err)) - } - } - } return nil } func (a *App) GetSessionById(sessionID string) (*model.Session, *model.AppError) { - session, err := a.Srv().Store.Session().Get(context.Background(), sessionID) + session, err := a.srv.userService.GetSessionByID(sessionID) if err != nil { return nil, model.NewAppError("GetSessionById", "app.session.get.app_error", nil, err.Error(), http.StatusBadRequest) } @@ -248,7 +204,7 @@ func (a *App) GetSessionById(sessionID string) (*model.Session, *model.AppError) } func (a *App) RevokeSessionById(sessionID string) *model.AppError { - session, err := a.Srv().Store.Session().Get(context.Background(), sessionID) + session, err := a.GetSessionById(sessionID) if err != nil { return model.NewAppError("RevokeSessionById", "app.session.get.app_error", nil, err.Error(), http.StatusBadRequest) } @@ -257,18 +213,15 @@ func (a *App) RevokeSessionById(sessionID string) *model.AppError { } func (a *App) RevokeSession(session *model.Session) *model.AppError { - if session.IsOAuth { - if err := a.RevokeAccessToken(session.Token); err != nil { - return err - } - } else { - if err := a.Srv().Store.Session().Remove(session.Id); err != nil { + if err := a.srv.userService.RevokeSession(session); err != nil { + switch { + case errors.Is(err, users.DeleteSessionError): + return model.NewAppError("RevokeSession", "app.session.remove.app_error", nil, err.Error(), http.StatusInternalServerError) + default: return model.NewAppError("RevokeSession", "app.session.remove.app_error", nil, err.Error(), http.StatusInternalServerError) } } - a.ClearSessionCacheForUser(session.UserId) - return nil } @@ -375,16 +328,11 @@ func (a *App) GetSessionLengthInMillis(session *model.Session) int64 { // relative to either the session creation date or the current time, depending // on the `ExtendSessionOnActivity` config setting. func (a *App) SetSessionExpireInDays(session *model.Session, days int) { - if session.CreateAt == 0 || *a.Config().ServiceSettings.ExtendSessionLengthWithActivity { - session.ExpiresAt = model.GetMillis() + (1000 * 60 * 60 * 24 * int64(days)) - } else { - session.ExpiresAt = session.CreateAt + (1000 * 60 * 60 * 24 * int64(days)) - } + a.srv.userService.SetSessionExpireInDays(session, days) } func (a *App) CreateUserAccessToken(token *model.UserAccessToken) (*model.UserAccessToken, *model.AppError) { - - user, nErr := a.Srv().Store.User().Get(context.Background(), token.UserId) + user, nErr := a.srv.userService.GetUser(token.UserId) if nErr != nil { var nfErr *store.ErrNotFound switch { @@ -469,7 +417,7 @@ func (a *App) createSessionForUserAccessToken(tokenString string) (*model.Sessio } else { session.AddProp(model.SESSION_PROP_IS_GUEST, "false") } - a.SetSessionExpireInDays(session, model.SESSION_USER_ACCESS_TOKEN_EXPIRY) + a.srv.userService.SetSessionExpireInDays(session, model.SESSION_USER_ACCESS_TOKEN_EXPIRY) session, nErr = a.Srv().Store.Session().Save(session) if nErr != nil { @@ -490,7 +438,7 @@ func (a *App) createSessionForUserAccessToken(tokenString string) (*model.Sessio func (a *App) RevokeUserAccessToken(token *model.UserAccessToken) *model.AppError { var session *model.Session - session, _ = a.Srv().Store.Session().Get(context.Background(), token.Token) + session, _ = a.srv.userService.GetSessionContext(context.Background(), token.Token) if err := a.Srv().Store.UserAccessToken().Delete(token.Id); err != nil { return model.NewAppError("RevokeUserAccessToken", "app.user_access_token.delete.app_error", nil, err.Error(), http.StatusInternalServerError) @@ -505,7 +453,7 @@ func (a *App) RevokeUserAccessToken(token *model.UserAccessToken) *model.AppErro func (a *App) DisableUserAccessToken(token *model.UserAccessToken) *model.AppError { var session *model.Session - session, _ = a.Srv().Store.Session().Get(context.Background(), token.Token) + session, _ = a.srv.userService.GetSessionContext(context.Background(), token.Token) if err := a.Srv().Store.UserAccessToken().UpdateTokenDisable(token.Id); err != nil { return model.NewAppError("DisableUserAccessToken", "app.user_access_token.update_token_disable.app_error", nil, err.Error(), http.StatusInternalServerError) @@ -520,7 +468,7 @@ func (a *App) DisableUserAccessToken(token *model.UserAccessToken) *model.AppErr func (a *App) EnableUserAccessToken(token *model.UserAccessToken) *model.AppError { var session *model.Session - session, _ = a.Srv().Store.Session().Get(context.Background(), token.Token) + session, _ = a.srv.userService.GetSessionContext(context.Background(), token.Token) err := a.Srv().Store.UserAccessToken().UpdateTokenEnable(token.Id) if err != nil { diff --git a/app/session_test.go b/app/session_test.go index 5f85594c0b..9cf00e41be 100644 --- a/app/session_test.go +++ b/app/session_test.go @@ -329,58 +329,6 @@ func TestApp_ExtendExpiryIfNeeded(t *testing.T) { } } -const ( - dayInMillis = 86400000 - grace = 5 * 1000 - thirtyDays = dayInMillis * 30 -) - -func TestApp_SetSessionExpireInDays(t *testing.T) { - th := Setup(t) - defer th.TearDown() - - now := model.GetMillis() - createAt := now - (dayInMillis * 20) - - tests := []struct { - name string - extend bool - create bool - days int - want int64 - }{ - {name: "zero days, extend", extend: true, create: true, days: 0, want: now}, - {name: "zero days, extend", extend: true, create: false, days: 0, want: now}, - {name: "zero days, no extend", extend: false, create: true, days: 0, want: createAt}, - {name: "zero days, no extend", extend: false, create: false, days: 0, want: now}, - {name: "thirty days, extend", extend: true, create: true, days: 30, want: now + thirtyDays}, - {name: "thirty days, extend", extend: true, create: false, days: 30, want: now + thirtyDays}, - {name: "thirty days, no extend", extend: false, create: true, days: 30, want: createAt + thirtyDays}, - {name: "thirty days, no extend", extend: false, create: false, days: 30, want: now + thirtyDays}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.ExtendSessionLengthWithActivity = tt.extend - }) - var create int64 - if tt.create { - create = createAt - } - - session := &model.Session{ - CreateAt: create, - ExpiresAt: model.GetMillis() + dayInMillis, - } - th.App.SetSessionExpireInDays(session, tt.days) - - // must be within 5 seconds of expected time. - require.GreaterOrEqual(t, session.ExpiresAt, tt.want-grace) - require.LessOrEqual(t, session.ExpiresAt, tt.want+grace) - }) - } -} - func TestGetCloudSession(t *testing.T) { th := Setup(t) defer func() { diff --git a/app/user.go b/app/user.go index e2e730fab9..4680c226f2 100644 --- a/app/user.go +++ b/app/user.go @@ -6,26 +6,15 @@ package app import ( "bytes" "context" - b64 "encoding/base64" "encoding/json" "errors" "fmt" - "hash/fnv" - "image" - "image/color" - "image/draw" - "image/png" "io" - "io/ioutil" "mime/multipart" "net/http" - "path/filepath" "strconv" "strings" - "github.com/golang/freetype" - "github.com/golang/freetype/truetype" - "github.com/mattermost/mattermost-server/v5/app/imaging" "github.com/mattermost/mattermost-server/v5/app/request" "github.com/mattermost/mattermost-server/v5/einterfaces" @@ -36,7 +25,6 @@ import ( "github.com/mattermost/mattermost-server/v5/shared/mfa" "github.com/mattermost/mattermost-server/v5/shared/mlog" "github.com/mattermost/mattermost-server/v5/store" - "github.com/mattermost/mattermost-server/v5/utils/fileutils" ) const ( @@ -320,12 +308,12 @@ func (a *App) CreateOAuthUser(c *request.Context, service string, userData io.Re } } - userByAuth, _ := a.Srv().Store.User().GetByAuth(user.AuthData, service) + userByAuth, _ := a.srv.userService.GetUserByAuth(user.AuthData, service) if userByAuth != nil { return userByAuth, nil } - userByEmail, _ := a.Srv().Store.User().GetByEmail(user.Email) + userByEmail, _ := a.srv.userService.GetUserByEmail(user.Email) if userByEmail != nil { if userByEmail.AuthService == "" { return nil, model.NewAppError("CreateOAuthUser", "api.user.create_oauth_user.already_attached.app_error", map[string]interface{}{"Service": service, "Auth": model.USER_AUTH_SERVICE_EMAIL}, "email="+user.Email, http.StatusBadRequest) @@ -633,7 +621,7 @@ func (a *App) GetUsersByGroupChannelIds(c *request.Context, channelIDs []string, } func (a *App) GetUsersByUsernames(usernames []string, asAdmin bool, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, *model.AppError) { - users, err := a.Srv().Store.User().GetProfilesByUsernames(usernames, viewRestrictions) + users, err := a.srv.userService.GetUsersByUsernames(usernames, &model.UserGetOptions{ViewRestrictions: viewRestrictions}) if err != nil { return nil, model.NewAppError("GetUsersByUsernames", "app.user.get_profiles.app_error", nil, err.Error(), http.StatusInternalServerError) } @@ -658,28 +646,18 @@ func (a *App) GenerateMfaSecret(userID string) (*model.MfaSecret, *model.AppErro return nil, model.NewAppError("GenerateMfaSecret", "mfa.mfa_disabled.app_error", nil, "", http.StatusNotImplemented) } - secret, img, err := mfa.New(a.Srv().Store.User()).GenerateSecret(*a.Config().ServiceSettings.SiteURL, user.Email, user.Id) + mfaSecret, err := a.srv.userService.GenerateMfaSecret(user) if err != nil { return nil, model.NewAppError("GenerateMfaSecret", "mfa.generate_qr_code.create_code.app_error", nil, err.Error(), http.StatusInternalServerError) } - // Make sure the old secret is not cached on any cluster nodes. - a.InvalidateCacheForUser(user.Id) - - mfaSecret := &model.MfaSecret{Secret: secret, QRCode: b64.StdEncoding.EncodeToString(img)} return mfaSecret, nil } func (a *App) ActivateMfa(userID, token string) *model.AppError { - user, err := a.Srv().Store.User().Get(context.Background(), userID) - if err != nil { - var nfErr *store.ErrNotFound - switch { - case errors.As(err, &nfErr): - return model.NewAppError("ActivateMfa", MissingAccountError, nil, nfErr.Error(), http.StatusNotFound) - default: - return model.NewAppError("ActivateMfa", "app.user.get.app_error", nil, err.Error(), http.StatusInternalServerError) - } + user, appErr := a.GetUser(userID) + if appErr != nil { + return appErr } if user.AuthService != "" && user.AuthService != model.USER_AUTH_SERVICE_LDAP { @@ -690,7 +668,7 @@ func (a *App) ActivateMfa(userID, token string) *model.AppError { return model.NewAppError("ActivateMfa", "mfa.mfa_disabled.app_error", nil, "", http.StatusNotImplemented) } - if err := mfa.New(a.Srv().Store.User()).Activate(user.MfaSecret, user.Id, token); err != nil { + if err := a.srv.userService.ActivateMfa(user, token); err != nil { switch { case errors.Is(err, mfa.InvalidToken): return model.NewAppError("ActivateMfa", "mfa.activate.bad_token.app_error", nil, "", http.StatusUnauthorized) @@ -706,7 +684,12 @@ func (a *App) ActivateMfa(userID, token string) *model.AppError { } func (a *App) DeactivateMfa(userID string) *model.AppError { - if err := mfa.New(a.Srv().Store.User()).Deactivate(userID); err != nil { + user, appErr := a.GetUser(userID) + if appErr != nil { + return appErr + } + + if err := a.srv.userService.DeactivateMfa(user); err != nil { return model.NewAppError("DeactivateMfa", "mfa.deactivate.app_error", nil, err.Error(), http.StatusInternalServerError) } @@ -716,89 +699,6 @@ func (a *App) DeactivateMfa(userID string) *model.AppError { return nil } -func CreateProfileImage(username string, userID string, initialFont string) ([]byte, *model.AppError) { - colors := []color.NRGBA{ - {197, 8, 126, 255}, - {227, 207, 18, 255}, - {28, 181, 105, 255}, - {35, 188, 224, 255}, - {116, 49, 196, 255}, - {197, 8, 126, 255}, - {197, 19, 19, 255}, - {250, 134, 6, 255}, - {227, 207, 18, 255}, - {123, 201, 71, 255}, - {28, 181, 105, 255}, - {35, 188, 224, 255}, - {116, 49, 196, 255}, - {197, 8, 126, 255}, - {197, 19, 19, 255}, - {250, 134, 6, 255}, - {227, 207, 18, 255}, - {123, 201, 71, 255}, - {28, 181, 105, 255}, - {35, 188, 224, 255}, - {116, 49, 196, 255}, - {197, 8, 126, 255}, - {197, 19, 19, 255}, - {250, 134, 6, 255}, - {227, 207, 18, 255}, - {123, 201, 71, 255}, - } - - h := fnv.New32a() - h.Write([]byte(userID)) - seed := h.Sum32() - - initial := string(strings.ToUpper(username)[0]) - - font, err := getFont(initialFont) - if err != nil { - return nil, model.NewAppError("CreateProfileImage", "api.user.create_profile_image.default_font.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - color := colors[int64(seed)%int64(len(colors))] - dstImg := image.NewRGBA(image.Rect(0, 0, ImageProfilePixelDimension, ImageProfilePixelDimension)) - srcImg := image.White - draw.Draw(dstImg, dstImg.Bounds(), &image.Uniform{color}, image.Point{}, draw.Src) - size := float64(ImageProfilePixelDimension / 2) - - c := freetype.NewContext() - c.SetFont(font) - c.SetFontSize(size) - c.SetClip(dstImg.Bounds()) - c.SetDst(dstImg) - c.SetSrc(srcImg) - - pt := freetype.Pt(ImageProfilePixelDimension/5, ImageProfilePixelDimension*2/3) - _, err = c.DrawString(initial, pt) - if err != nil { - return nil, model.NewAppError("CreateProfileImage", "api.user.create_profile_image.initial.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - buf := new(bytes.Buffer) - - if imgErr := png.Encode(buf, dstImg); imgErr != nil { - return nil, model.NewAppError("CreateProfileImage", "api.user.create_profile_image.encode.app_error", nil, imgErr.Error(), http.StatusInternalServerError) - } - return buf.Bytes(), nil -} - -func getFont(initialFont string) (*truetype.Font, error) { - // Some people have the old default font still set, so just treat that as if they're using the new default - if initialFont == "luximbi.ttf" { - initialFont = "nunito-bold.ttf" - } - - fontDir, _ := fileutils.FindDir("fonts") - fontBytes, err := ioutil.ReadFile(filepath.Join(fontDir, initialFont)) - if err != nil { - return nil, err - } - - return freetype.ParseFont(fontBytes) -} - func (a *App) GetProfileImage(user *model.User) ([]byte, bool, *model.AppError) { return a.srv.GetProfileImage(user) } @@ -929,10 +829,6 @@ func (a *App) UpdatePasswordAsUser(userID, currentPassword, newPassword string) } func (a *App) userDeactivated(c *request.Context, userID string) *model.AppError { - if err := a.RevokeAllSessions(userID); err != nil { - return err - } - a.SetStatusOffline(userID, false) user, err := a.GetUser(userID) @@ -982,7 +878,7 @@ func (a *App) UpdateActive(c *request.Context, user *model.User, active bool) (* user.DeleteAt = user.UpdateAt } - userUpdate, err := a.Srv().Store.User().Update(user, true) + userUpdate, err := a.srv.userService.UpdateUser(user, true) if err != nil { var appErr *model.AppError var invErr *store.ErrInvalidInput @@ -998,6 +894,9 @@ func (a *App) UpdateActive(c *request.Context, user *model.User, active bool) (* ruser := userUpdate.New if !active { + if err := a.RevokeAllSessions(ruser.Id); err != nil { + return nil, err + } if err := a.userDeactivated(c, ruser.Id); err != nil { return nil, err } @@ -1012,7 +911,7 @@ func (a *App) UpdateActive(c *request.Context, user *model.User, active bool) (* } func (a *App) DeactivateGuests(c *request.Context) *model.AppError { - userIDs, err := a.Srv().Store.User().DeactivateGuests() + userIDs, err := a.srv.userService.DeactivateAllGuests() if err != nil { return model.NewAppError("DeactivateGuests", "app.user.update_active_for_multiple_users.updating.app_error", nil, err.Error(), http.StatusInternalServerError) } @@ -1032,19 +931,12 @@ func (a *App) DeactivateGuests(c *request.Context) *model.AppError { return nil } -// TODO: migrate this after the user service implementation is completed func (a *App) GetSanitizeOptions(asAdmin bool) map[string]bool { - options := a.Config().GetSanitizeOptions() - if asAdmin { - options["email"] = true - options["fullname"] = true - options["authservice"] = true - } - return options + return a.srv.userService.GetSanitizeOptions(asAdmin) } func (a *App) SanitizeProfile(user *model.User, asAdmin bool) { - options := a.GetSanitizeOptions(asAdmin) + options := a.srv.userService.GetSanitizeOptions(asAdmin) user.SanitizeProfile(options) } @@ -1136,7 +1028,7 @@ func (a *App) sendUpdatedUserEvent(user model.User) { } func (a *App) UpdateUser(user *model.User, sendNotifications bool) (*model.User, *model.AppError) { - prev, err := a.Srv().Store.User().Get(context.Background(), user.Id) + prev, err := a.srv.userService.GetUser(user.Id) if err != nil { var nfErr *store.ErrNotFound switch { @@ -1179,7 +1071,7 @@ func (a *App) UpdateUser(user *model.User, sendNotifications bool) (*model.User, } } - userUpdate, err := a.Srv().Store.User().Update(user, false) + userUpdate, err := a.srv.userService.UpdateUser(user, false) if err != nil { var appErr *model.AppError var invErr *store.ErrInvalidInput @@ -2120,7 +2012,9 @@ func (a *App) PromoteGuestToUser(c *request.Context, user *model.User, requestor mlog.Warn("Failed to get user on promote guest to user", mlog.Err(err)) } else { a.sendUpdatedUserEvent(*promotedUser) - a.UpdateSessionsIsGuest(promotedUser.Id, promotedUser.IsGuest()) + if uErr := a.srv.userService.UpdateSessionsIsGuest(promotedUser.Id, promotedUser.IsGuest()); uErr != nil { + mlog.Warn("Unable to update user sessions", mlog.String("user_id", promotedUser.Id), mlog.Err(uErr)) + } } teamMembers, err := a.GetTeamMembersForUser(user.Id) @@ -2159,7 +2053,9 @@ func (a *App) DemoteUserToGuest(user *model.User) *model.AppError { } a.sendUpdatedUserEvent(*demotedUser) - a.UpdateSessionsIsGuest(demotedUser.Id, demotedUser.IsGuest()) + if uErr := a.srv.userService.UpdateSessionsIsGuest(demotedUser.Id, demotedUser.IsGuest()); uErr != nil { + mlog.Warn("Unable to update user sessions", mlog.String("user_id", demotedUser.Id), mlog.Err(uErr)) + } teamMembers, err := a.GetTeamMembersForUser(user.Id) if err != nil { diff --git a/app/user_test.go b/app/user_test.go index 4eecb662e0..44ba323df6 100644 --- a/app/user_test.go +++ b/app/user_test.go @@ -8,8 +8,6 @@ import ( "context" "encoding/json" "errors" - "image" - "image/color" "strings" "testing" "time" @@ -82,19 +80,6 @@ func TestCreateOAuthUser(t *testing.T) { }) } -func TestCreateProfileImage(t *testing.T) { - b, err := CreateProfileImage("Corey Hulen", "eo1zkdr96pdj98pjmq8zy35wba", "nunito-bold.ttf") - require.Nil(t, err) - - rdr := bytes.NewReader(b) - img, _, err2 := image.Decode(rdr) - require.NoError(t, err2) - - colorful := color.RGBA{116, 49, 196, 255} - - require.Equal(t, colorful, img.At(1, 1), "Failed to create correct color") -} - func TestSetDefaultProfileImage(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/app/web_hub_test.go b/app/web_hub_test.go index b3ad225cc0..469bbf657e 100644 --- a/app/web_hub_test.go +++ b/app/web_hub_test.go @@ -153,15 +153,18 @@ func TestHubSessionRevokeRace(t *testing.T) { mockStatusStore.On("UpdateLastActivityAt", "user1", mock.Anything).Return(nil) mockStatusStore.On("SaveOrUpdate", mock.AnythingOfType("*model.Status")).Return(nil) + mockOAuthStore := mocks.OAuthStore{} mockStore.On("Session").Return(&mockSessionStore) + mockStore.On("OAuth").Return(&mockOAuthStore) mockStore.On("Status").Return(&mockStatusStore) mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) - userService, err := users.New(users.ServiceInitializer{ + userService, err := users.New(users.ServiceConfig{ UserStore: &mockUserStore, SessionStore: &mockSessionStore, + OAuthStore: &mockOAuthStore, ConfigFn: th.App.srv.Config, Metrics: th.App.Metrics(), Cluster: th.App.Cluster(), diff --git a/model/bot_default_image.go b/services/users/bot_default_image.go similarity index 99% rename from model/bot_default_image.go rename to services/users/bot_default_image.go index d9cdd2e240..0183f3d5fa 100644 --- a/model/bot_default_image.go +++ b/services/users/bot_default_image.go @@ -1,9 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package model +package users -var BotDefaultImage = []byte{ +var botDefaultImage = []byte{ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x7e, 0x08, 0x06, 0x00, 0x00, 0x00, 0xec, 0xa6, 0x19, 0xa2, 0x00, 0x00, 0x00, diff --git a/services/users/errors.go b/services/users/errors.go index 54c4c07464..0900db6474 100644 --- a/services/users/errors.go +++ b/services/users/errors.go @@ -6,9 +6,20 @@ package users import "errors" var ( - AcceptedDomainError = errors.New("the email provided does not belong to an accepted domain") - VerifyUserError = errors.New("could not update verify email field") - UserCountError = errors.New("could not get the total number of the users.") + AcceptedDomainError = errors.New("the email provided does not belong to an accepted domain") + VerifyUserError = errors.New("could not update verify email field") + UserCountError = errors.New("could not get the total number of the users.") + UserCreationDisabledError = errors.New("user creation is not allowed") + + GetTokenError = errors.New("could not get token") + GetSessionError = errors.New("could not get session") + DeleteTokenError = errors.New("could not delete token") + DeleteSessionError = errors.New("could not delete session") + DeleteAllAccessDataError = errors.New("could not delete all access data") + + DefaultFontError = errors.New("could not get default font") + UserInitialsError = errors.New("could not get user initials") + ImageEncodingError = errors.New("could not encode image") ) // ErrInvalidPassword indicates an error against the password settings diff --git a/services/users/helper_test.go b/services/users/helper_test.go index cb2f8434e8..46cdeccd7c 100644 --- a/services/users/helper_test.go +++ b/services/users/helper_test.go @@ -87,8 +87,14 @@ func setupTestHelper(s store.Store, includeCacheLayer bool, tb testing.TB) *Test service: &UserService{ store: s.User(), sessionStore: s.Session(), + oAuthStore: s.OAuth(), sessionCache: cache, config: configStore.Get, + sessionPool: sync.Pool{ + New: func() interface{} { + return &model.Session{} + }, + }, }, Context: &request.Context{}, configStore: configStore, @@ -155,3 +161,15 @@ func (th *TestHelper) TearDown() { os.RemoveAll(th.workspace) } } + +func (th *TestHelper) UpdateConfig(f func(*model.Config)) { + if th.configStore.IsReadOnly() { + return + } + old := th.configStore.Get() + updated := old.Clone() + f(updated) + if _, _, err := th.configStore.Set(updated); err != nil { + panic(err) + } +} diff --git a/services/users/profile_picture.go b/services/users/profile_picture.go new file mode 100644 index 0000000000..11b5486981 --- /dev/null +++ b/services/users/profile_picture.go @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package users + +import ( + "bytes" + "hash/fnv" + "image" + "image/color" + "image/draw" + "image/png" + "io/ioutil" + "path/filepath" + "strings" + + "github.com/golang/freetype" + "github.com/golang/freetype/truetype" + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/utils/fileutils" +) + +const ( + imageProfilePixelDimension = 128 +) + +func (us *UserService) GetDefaultProfileImage(user *model.User) ([]byte, error) { + if user.IsBot { + return botDefaultImage, nil + } + + return createProfileImage(user.Username, user.Id, *us.config().FileSettings.InitialFont) +} + +func createProfileImage(username string, userID string, initialFont string) ([]byte, error) { + colors := []color.NRGBA{ + {197, 8, 126, 255}, + {227, 207, 18, 255}, + {28, 181, 105, 255}, + {35, 188, 224, 255}, + {116, 49, 196, 255}, + {197, 8, 126, 255}, + {197, 19, 19, 255}, + {250, 134, 6, 255}, + {227, 207, 18, 255}, + {123, 201, 71, 255}, + {28, 181, 105, 255}, + {35, 188, 224, 255}, + {116, 49, 196, 255}, + {197, 8, 126, 255}, + {197, 19, 19, 255}, + {250, 134, 6, 255}, + {227, 207, 18, 255}, + {123, 201, 71, 255}, + {28, 181, 105, 255}, + {35, 188, 224, 255}, + {116, 49, 196, 255}, + {197, 8, 126, 255}, + {197, 19, 19, 255}, + {250, 134, 6, 255}, + {227, 207, 18, 255}, + {123, 201, 71, 255}, + } + + h := fnv.New32a() + h.Write([]byte(userID)) + seed := h.Sum32() + + initial := string(strings.ToUpper(username)[0]) + + font, err := getFont(initialFont) + if err != nil { + return nil, DefaultFontError + } + + color := colors[int64(seed)%int64(len(colors))] + dstImg := image.NewRGBA(image.Rect(0, 0, imageProfilePixelDimension, imageProfilePixelDimension)) + srcImg := image.White + draw.Draw(dstImg, dstImg.Bounds(), &image.Uniform{color}, image.Point{}, draw.Src) + size := float64(imageProfilePixelDimension / 2) + + c := freetype.NewContext() + c.SetFont(font) + c.SetFontSize(size) + c.SetClip(dstImg.Bounds()) + c.SetDst(dstImg) + c.SetSrc(srcImg) + + pt := freetype.Pt(imageProfilePixelDimension/5, imageProfilePixelDimension*2/3) + _, err = c.DrawString(initial, pt) + if err != nil { + return nil, UserInitialsError + } + + buf := new(bytes.Buffer) + + if imgErr := png.Encode(buf, dstImg); imgErr != nil { + return nil, ImageEncodingError + } + + return buf.Bytes(), nil +} + +func getFont(initialFont string) (*truetype.Font, error) { + // Some people have the old default font still set, so just treat that as if they're using the new default + if initialFont == "luximbi.ttf" { + initialFont = "nunito-bold.ttf" + } + + fontDir, _ := fileutils.FindDir("fonts") + fontBytes, err := ioutil.ReadFile(filepath.Join(fontDir, initialFont)) + if err != nil { + return nil, err + } + + return freetype.ParseFont(fontBytes) +} diff --git a/services/users/profile_picture_test.go b/services/users/profile_picture_test.go new file mode 100644 index 0000000000..6d2bc40030 --- /dev/null +++ b/services/users/profile_picture_test.go @@ -0,0 +1,26 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package users + +import ( + "bytes" + "image" + "image/color" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateProfileImage(t *testing.T) { + b, err := createProfileImage("Corey Hulen", "eo1zkdr96pdj98pjmq8zy35wba", "nunito-bold.ttf") + require.NoError(t, err) + + rdr := bytes.NewReader(b) + img, _, err2 := image.Decode(rdr) + require.NoError(t, err2) + + colorful := color.RGBA{116, 49, 196, 255} + + require.Equal(t, colorful, img.At(1, 1), "Failed to create correct color") +} diff --git a/services/users/service.go b/services/users/service.go new file mode 100644 index 0000000000..41f4c82550 --- /dev/null +++ b/services/users/service.go @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package users + +import ( + "errors" + "fmt" + "runtime" + "sync" + + "github.com/mattermost/mattermost-server/v5/einterfaces" + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/services/cache" + "github.com/mattermost/mattermost-server/v5/store" +) + +type UserService struct { + store store.UserStore + sessionStore store.SessionStore + oAuthStore store.OAuthStore + sessionCache cache.Cache + sessionPool sync.Pool + metrics einterfaces.MetricsInterface + cluster einterfaces.ClusterInterface + config func() *model.Config +} + +// ServiceConfig is used to initialize the UserService. +type ServiceConfig struct { + // Mandatory fields + UserStore store.UserStore + SessionStore store.SessionStore + OAuthStore store.OAuthStore + ConfigFn func() *model.Config + // Optional fields + Metrics einterfaces.MetricsInterface + Cluster einterfaces.ClusterInterface +} + +func New(c ServiceConfig) (*UserService, error) { + if err := c.validate(); err != nil { + return nil, err + } + + cacheProvider := cache.NewProvider() + if err := cacheProvider.Connect(); err != nil { + return nil, fmt.Errorf("could not connect to cache provider: %w", err) + } + + sessionCache, err := cacheProvider.NewCache(&cache.CacheOptions{ + Size: model.SESSION_CACHE_SIZE, + Striped: true, + StripedBuckets: maxInt(runtime.NumCPU()-1, 1), + }) + if err != nil { + return nil, fmt.Errorf("could not create session cache: %w", err) + } + + return &UserService{ + store: c.UserStore, + sessionStore: c.SessionStore, + oAuthStore: c.OAuthStore, + config: c.ConfigFn, + metrics: c.Metrics, + cluster: c.Cluster, + sessionCache: sessionCache, + sessionPool: sync.Pool{ + New: func() interface{} { + return &model.Session{} + }, + }, + }, nil +} + +func (c *ServiceConfig) validate() error { + if in := c; in.ConfigFn == nil || in.UserStore == nil || in.SessionStore == nil || in.OAuthStore == nil { + return errors.New("required parameters are not provided") + } + + return nil +} diff --git a/services/users/service_test.go b/services/users/service_test.go new file mode 100644 index 0000000000..af04f3bc65 --- /dev/null +++ b/services/users/service_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package users + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v5/model" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + _, err := New(ServiceConfig{}) + require.Error(t, err) + + dbStore := mainHelper.GetStore() + + cfn := func() *model.Config { + return &model.Config{} + } + + _, err = New(ServiceConfig{ + UserStore: dbStore.User(), + SessionStore: dbStore.Session(), + OAuthStore: dbStore.OAuth(), + ConfigFn: cfn, + }) + require.NoError(t, err) +} diff --git a/services/users/session.go b/services/users/session.go index 2ec44d618a..14dc2805f8 100644 --- a/services/users/session.go +++ b/services/users/session.go @@ -4,9 +4,14 @@ package users import ( + "context" + "fmt" "time" "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/shared/mlog" + "github.com/mattermost/mattermost-server/v5/store/sqlstore" + "github.com/pkg/errors" ) func (us *UserService) ReturnSessionToPool(session *model.Session) { @@ -40,7 +45,20 @@ func (us *UserService) GetSession(token string) (*model.Session, error) { us.metrics.IncrementMemCacheMissCounterSession() } } - return session, nil + + if session.Id != "" { + return session, nil + } + + return us.GetSessionContext(sqlstore.WithMaster(context.Background()), token) +} + +func (us *UserService) GetSessionContext(ctx context.Context, token string) (*model.Session, error) { + return us.sessionStore.Get(ctx, token) +} + +func (us *UserService) GetSessions(userID string) ([]*model.Session, error) { + return us.sessionStore.GetSessions(userID) } func (us *UserService) AddSessionToCache(session *model.Session) { @@ -98,3 +116,134 @@ func (us *UserService) ClearAllUsersSessionCache() { us.cluster.SendClusterMessage(msg) } } + +func (us *UserService) GetSessionByID(sessionID string) (*model.Session, error) { + return us.sessionStore.Get(context.Background(), sessionID) +} + +func (us *UserService) RevokeSessionsFromAllUsers() error { + // revoke tokens before sessions so they can't be used to relogin + nErr := us.oAuthStore.RemoveAllAccessData() + if nErr != nil { + return errors.Wrap(DeleteAllAccessDataError, nErr.Error()) + } + err := us.sessionStore.RemoveAllSessions() + if err != nil { + return err + } + + us.ClearAllUsersSessionCache() + return nil +} + +func (us *UserService) RevokeSessionsForDeviceId(userID string, deviceID string, currentSessionId string) error { + sessions, err := us.sessionStore.GetSessions(userID) + if err != nil { + return err + } + for _, session := range sessions { + if session.DeviceId == deviceID && session.Id != currentSessionId { + mlog.Debug("Revoking sessionId for userId. Re-login with the same device Id", mlog.String("session_id", session.Id), mlog.String("user_id", userID)) + if err := us.RevokeSession(session); err != nil { + mlog.Warn("Could not revoke session for device", mlog.String("device_id", deviceID), mlog.Err(err)) + } + } + } + + return nil +} + +func (us *UserService) RevokeSession(session *model.Session) error { + if session.IsOAuth { + if err := us.RevokeAccessToken(session.Token); err != nil { + return err + } + } else { + if err := us.sessionStore.Remove(session.Id); err != nil { + return errors.Wrap(DeleteSessionError, err.Error()) + } + } + + us.ClearUserSessionCache(session.UserId) + + return nil +} + +func (us *UserService) RevokeAccessToken(token string) error { + session, _ := us.GetSession(token) + + defer us.ReturnSessionToPool(session) + + schan := make(chan error, 1) + go func() { + schan <- us.sessionStore.Remove(token) + close(schan) + }() + + if _, err := us.oAuthStore.GetAccessData(token); err != nil { + return errors.Wrap(GetTokenError, err.Error()) + } + + if err := us.oAuthStore.RemoveAccessData(token); err != nil { + return errors.Wrap(DeleteTokenError, err.Error()) + } + + if err := <-schan; err != nil { + return errors.Wrap(DeleteSessionError, err.Error()) + } + + if session != nil { + us.ClearUserSessionCache(session.UserId) + } + + return nil +} + +// SetSessionExpireInDays sets the session's expiry the specified number of days +// relative to either the session creation date or the current time, depending +// on the `ExtendSessionOnActivity` config setting. +func (us *UserService) SetSessionExpireInDays(session *model.Session, days int) { + if session.CreateAt == 0 || *us.config().ServiceSettings.ExtendSessionLengthWithActivity { + session.ExpiresAt = model.GetMillis() + (1000 * 60 * 60 * 24 * int64(days)) + } else { + session.ExpiresAt = session.CreateAt + (1000 * 60 * 60 * 24 * int64(days)) + } +} + +func (us *UserService) UpdateSessionsIsGuest(userID string, isGuest bool) error { + sessions, err := us.GetSessions(userID) + if err != nil { + return err + } + + for _, session := range sessions { + session.AddProp(model.SESSION_PROP_IS_GUEST, fmt.Sprintf("%t", isGuest)) + err := us.sessionStore.UpdateProps(session) + if err != nil { + mlog.Warn("Unable to update isGuest session", mlog.Err(err)) + continue + } + us.AddSessionToCache(session) + } + return nil +} + +func (us *UserService) RevokeAllSessions(userID string) error { + sessions, err := us.sessionStore.GetSessions(userID) + if err != nil { + return errors.Wrap(GetSessionError, err.Error()) + } + for _, session := range sessions { + if session.IsOAuth { + us.RevokeAccessToken(session.Token) + } else { + if err := us.sessionStore.Remove(session.Id); err != nil { + return errors.Wrap(DeleteSessionError, err.Error()) + } + } + } + + us.ClearUserSessionCache(userID) + + return nil +} diff --git a/services/users/session_test.go b/services/users/session_test.go index dff59d7a8d..9ebd105d11 100644 --- a/services/users/session_test.go +++ b/services/users/session_test.go @@ -11,6 +11,12 @@ import ( "github.com/stretchr/testify/require" ) +const ( + dayInMillis = 86400000 + grace = 5 * 1000 + thirtyDays = dayInMillis * 30 +) + func TestCache(t *testing.T) { th := Setup(t) defer th.TearDown() @@ -47,3 +53,81 @@ func TestCache(t *testing.T) { require.NoError(t, err) require.Empty(t, rkeys) } + +func TestSetSessionExpireInDays(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + now := model.GetMillis() + createAt := now - (dayInMillis * 20) + + tests := []struct { + name string + extend bool + create bool + days int + want int64 + }{ + {name: "zero days, extend", extend: true, create: true, days: 0, want: now}, + {name: "zero days, extend", extend: true, create: false, days: 0, want: now}, + {name: "zero days, no extend", extend: false, create: true, days: 0, want: createAt}, + {name: "zero days, no extend", extend: false, create: false, days: 0, want: now}, + {name: "thirty days, extend", extend: true, create: true, days: 30, want: now + thirtyDays}, + {name: "thirty days, extend", extend: true, create: false, days: 30, want: now + thirtyDays}, + {name: "thirty days, no extend", extend: false, create: true, days: 30, want: createAt + thirtyDays}, + {name: "thirty days, no extend", extend: false, create: false, days: 30, want: now + thirtyDays}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + th.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.ExtendSessionLengthWithActivity = tt.extend + }) + var create int64 + if tt.create { + create = createAt + } + + session := &model.Session{ + CreateAt: create, + ExpiresAt: model.GetMillis() + dayInMillis, + } + th.service.SetSessionExpireInDays(session, tt.days) + + // must be within 5 seconds of expected time. + require.GreaterOrEqual(t, session.ExpiresAt, tt.want-grace) + require.LessOrEqual(t, session.ExpiresAt, tt.want+grace) + }) + } +} + +func TestOAuthRevokeAccessToken(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + err := th.service.RevokeAccessToken(model.NewRandomString(16)) + require.Error(t, err, "Should have failed due to an incorrect token") + + session := &model.Session{} + session.CreateAt = model.GetMillis() + session.UserId = model.NewId() + session.Token = model.NewId() + session.Roles = model.SYSTEM_USER_ROLE_ID + th.service.SetSessionExpireInDays(session, 1) + + session, _ = th.service.CreateSession(session) + err = th.service.RevokeAccessToken(session.Token) + require.Error(t, err, "Should have failed does not have an access token") + + accessData := &model.AccessData{} + accessData.Token = session.Token + accessData.UserId = session.UserId + accessData.RedirectUri = "http://example.com" + accessData.ClientId = model.NewId() + accessData.ExpiresAt = session.ExpiresAt + + _, nErr := th.service.oAuthStore.SaveAccessData(accessData) + require.NoError(t, nErr) + + err = th.service.RevokeAccessToken(accessData.Token) + require.NoError(t, err) +} diff --git a/services/users/users.go b/services/users/users.go index 6d6cb3e23f..292f990574 100644 --- a/services/users/users.go +++ b/services/users/users.go @@ -5,79 +5,21 @@ package users import ( "context" - "errors" + "encoding/base64" "fmt" - "runtime" - "sync" - "github.com/mattermost/mattermost-server/v5/einterfaces" "github.com/mattermost/mattermost-server/v5/model" - "github.com/mattermost/mattermost-server/v5/services/cache" "github.com/mattermost/mattermost-server/v5/shared/i18n" + "github.com/mattermost/mattermost-server/v5/shared/mfa" "github.com/mattermost/mattermost-server/v5/shared/mlog" "github.com/mattermost/mattermost-server/v5/store" ) -type UserService struct { - store store.UserStore - sessionStore store.SessionStore - sessionCache cache.Cache - sessionPool sync.Pool - metrics einterfaces.MetricsInterface - cluster einterfaces.ClusterInterface - config func() *model.Config -} - type UserCreateOptions struct { Guest bool FromImport bool } -// ServiceInitializer is used to initialize the UserService. -type ServiceInitializer struct { - // Mandatory fields - UserStore store.UserStore - SessionStore store.SessionStore - ConfigFn func() *model.Config - // Optional fields - Metrics einterfaces.MetricsInterface - Cluster einterfaces.ClusterInterface -} - -func New(initializer ServiceInitializer) (*UserService, error) { - cacheProvider := cache.NewProvider() - if err := cacheProvider.Connect(); err != nil { - return nil, fmt.Errorf("could not create cache provider: %w", err) - } - - sessionCache, err := cacheProvider.NewCache(&cache.CacheOptions{ - Size: model.SESSION_CACHE_SIZE, - Striped: true, - StripedBuckets: maxInt(runtime.NumCPU()-1, 1), - }) - if err != nil { - return nil, fmt.Errorf("could not create session cache: %w", err) - } - - if initializer.ConfigFn == nil || initializer.UserStore == nil || initializer.SessionStore == nil { - return nil, errors.New("required parameters are not provided") - } - - return &UserService{ - store: initializer.UserStore, - sessionStore: initializer.SessionStore, - config: initializer.ConfigFn, - metrics: initializer.Metrics, - cluster: initializer.Cluster, - sessionCache: sessionCache, - sessionPool: sync.Pool{ - New: func() interface{} { - return &model.Session{} - }, - }, - }, nil -} - // CreateUser creates a user func (us *UserService) CreateUser(user *model.User, opts UserCreateOptions) (*model.User, error) { if opts.FromImport { @@ -167,6 +109,10 @@ func (us *UserService) GetUsers(options *model.UserGetOptions) ([]*model.User, e return us.store.GetAllProfiles(options) } +func (us *UserService) GetUsersByUsernames(usernames []string, options *model.UserGetOptions) ([]*model.User, error) { + return us.store.GetProfilesByUsernames(usernames, options.ViewRestrictions) +} + func (us *UserService) GetUsersPage(options *model.UserGetOptions, asAdmin bool) ([]*model.User, error) { users, err := us.GetUsers(options) if err != nil { @@ -243,6 +189,25 @@ func (us *UserService) GetUsersWithoutTeam(options *model.UserGetOptions) ([]*mo return users, nil } +func (us *UserService) UpdateUser(user *model.User, allowRoleUpdate bool) (*model.UserUpdate, error) { + return us.store.Update(user, allowRoleUpdate) +} + +func (us *UserService) DeactivateAllGuests() ([]string, error) { + users, err := us.store.DeactivateGuests() + if err != nil { + return nil, err + } + + for _, userID := range users { + if err := us.RevokeAllSessions(userID); err != nil { + return nil, err + } + } + + return users, nil +} + func (us *UserService) InvalidateCacheForUser(userID string) { us.store.InvalidateProfilesInChannelCacheByUser(userID) us.store.InvalidateProfileCacheForUser(userID) @@ -256,3 +221,24 @@ func (us *UserService) InvalidateCacheForUser(userID string) { us.cluster.SendClusterMessage(msg) } } + +func (us *UserService) GenerateMfaSecret(user *model.User) (*model.MfaSecret, error) { + secret, img, err := mfa.New(us.store).GenerateSecret(*us.config().ServiceSettings.SiteURL, user.Email, user.Id) + if err != nil { + return nil, err + } + + // Make sure the old secret is not cached on any cluster nodes. + us.InvalidateCacheForUser(user.Id) + + mfaSecret := &model.MfaSecret{Secret: secret, QRCode: base64.StdEncoding.EncodeToString(img)} + return mfaSecret, nil +} + +func (us *UserService) ActivateMfa(user *model.User, token string) error { + return mfa.New(us.store).Activate(user.MfaSecret, user.Id, token) +} + +func (us *UserService) DeactivateMfa(user *model.User) error { + return mfa.New(us.store).Deactivate(user.Id) +} diff --git a/testlib/store.go b/testlib/store.go index fffcad97e3..a1e476b787 100644 --- a/testlib/store.go +++ b/testlib/store.go @@ -88,6 +88,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store { roleStore.On("GetAll").Return([]*model.Role{}, nil) sessionStore := mocks.SessionStore{} + oAuthStore := mocks.OAuthStore{} mockStore.On("System").Return(&systemStore) mockStore.On("User").Return(&userStore) @@ -101,5 +102,6 @@ func GetMockStoreForSetupFunctions() *mocks.Store { mockStore.On("DropAllTables").Return(nil) mockStore.On("MarkSystemRanUnitTests").Return(nil) mockStore.On("Session").Return(&sessionStore) + mockStore.On("OAuth").Return(&oAuthStore) return &mockStore }