diff options
-rw-r--r-- | api/context.go | 8 | ||||
-rw-r--r-- | api/post.go | 12 | ||||
-rw-r--r-- | api4/context.go | 16 | ||||
-rw-r--r-- | api4/params.go | 5 | ||||
-rw-r--r-- | api4/post.go | 12 | ||||
-rw-r--r-- | api4/post_test.go | 47 | ||||
-rw-r--r-- | api4/user.go | 134 | ||||
-rw-r--r-- | api4/user_test.go | 252 | ||||
-rw-r--r-- | app/session.go | 130 | ||||
-rw-r--r-- | app/user.go | 4 | ||||
-rw-r--r-- | config/config.json | 1 | ||||
-rw-r--r-- | i18n/en.json | 96 | ||||
-rw-r--r-- | model/authorization.go | 69 | ||||
-rw-r--r-- | model/client4.go | 58 | ||||
-rw-r--r-- | model/config.go | 6 | ||||
-rw-r--r-- | model/session.go | 22 | ||||
-rw-r--r-- | model/user_access_token.go | 81 | ||||
-rw-r--r-- | model/user_access_token_test.go | 58 | ||||
-rw-r--r-- | store/layered_store.go | 4 | ||||
-rw-r--r-- | store/sql_store.go | 1 | ||||
-rw-r--r-- | store/sql_supplier.go | 47 | ||||
-rw-r--r-- | store/sql_user_access_token_store.go | 262 | ||||
-rw-r--r-- | store/sql_user_access_token_store_test.go | 86 | ||||
-rw-r--r-- | store/store.go | 10 | ||||
-rw-r--r-- | utils/config.go | 1 |
25 files changed, 1378 insertions, 44 deletions
diff --git a/api/context.go b/api/context.go index 09cb1e583..d0036d077 100644 --- a/api/context.go +++ b/api/context.go @@ -308,9 +308,13 @@ func (c *Context) LogDebug(err *model.AppError) { } func (c *Context) UserRequired() { + if !*utils.Cfg.ServiceSettings.EnableUserAccessTokens && c.Session.Props[model.SESSION_PROP_TYPE] == model.SESSION_TYPE_USER_ACCESS_TOKEN { + c.Err = model.NewAppError("", "api.context.session_expired.app_error", nil, "UserAccessToken", http.StatusUnauthorized) + return + } + if len(c.Session.UserId) == 0 { - c.Err = model.NewLocAppError("", "api.context.session_expired.app_error", nil, "UserRequired") - c.Err.StatusCode = http.StatusUnauthorized + c.Err = model.NewAppError("", "api.context.session_expired.app_error", nil, "UserRequired", http.StatusUnauthorized) return } } diff --git a/api/post.go b/api/post.go index 192b01bd5..367696ec1 100644 --- a/api/post.go +++ b/api/post.go @@ -51,7 +51,17 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) { post.UserId = c.Session.UserId - if !app.SessionHasPermissionToChannel(c.Session, post.ChannelId, model.PERMISSION_CREATE_POST) { + hasPermission := false + if app.SessionHasPermissionToChannel(c.Session, post.ChannelId, model.PERMISSION_CREATE_POST) { + hasPermission = true + } else if channel, err := app.GetChannel(post.ChannelId); err == nil { + // Temporary permission check method until advanced permissions, please do not copy + if channel.Type == model.CHANNEL_OPEN && app.SessionHasPermissionToTeam(c.Session, channel.TeamId, model.PERMISSION_CREATE_POST_PUBLIC) { + hasPermission = true + } + } + + if !hasPermission { c.SetPermissionError(model.PERMISSION_CREATE_POST) return } diff --git a/api4/context.go b/api4/context.go index 61c318266..d72b3593d 100644 --- a/api4/context.go +++ b/api4/context.go @@ -239,6 +239,11 @@ func (c *Context) IsSystemAdmin() bool { } func (c *Context) SessionRequired() { + if !*utils.Cfg.ServiceSettings.EnableUserAccessTokens && c.Session.Props[model.SESSION_PROP_TYPE] == model.SESSION_TYPE_USER_ACCESS_TOKEN { + c.Err = model.NewAppError("", "api.context.session_expired.app_error", nil, "UserAccessToken", http.StatusUnauthorized) + return + } + if len(c.Session.UserId) == 0 { c.Err = model.NewAppError("", "api.context.session_expired.app_error", nil, "UserRequired", http.StatusUnauthorized) return @@ -361,6 +366,17 @@ func (c *Context) RequireInviteId() *Context { return c } +func (c *Context) RequireTokenId() *Context { + if c.Err != nil { + return c + } + + if len(c.Params.TokenId) != 26 { + c.SetInvalidUrlParam("token_id") + } + return c +} + func (c *Context) RequireChannelId() *Context { if c.Err != nil { return c diff --git a/api4/params.go b/api4/params.go index 999bf8e77..b48e5fc1b 100644 --- a/api4/params.go +++ b/api4/params.go @@ -20,6 +20,7 @@ type ApiParams struct { UserId string TeamId string InviteId string + TokenId string ChannelId string PostId string FileId string @@ -60,6 +61,10 @@ func ApiParamsFromRequest(r *http.Request) *ApiParams { params.InviteId = val } + if val, ok := props["token_id"]; ok { + params.TokenId = val + } + if val, ok := props["channel_id"]; ok { params.ChannelId = val } diff --git a/api4/post.go b/api4/post.go index 3d0c681d1..deaad1e1c 100644 --- a/api4/post.go +++ b/api4/post.go @@ -40,7 +40,17 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) { post.UserId = c.Session.UserId - if !app.SessionHasPermissionToChannel(c.Session, post.ChannelId, model.PERMISSION_CREATE_POST) { + hasPermission := false + if app.SessionHasPermissionToChannel(c.Session, post.ChannelId, model.PERMISSION_CREATE_POST) { + hasPermission = true + } else if channel, err := app.GetChannel(post.ChannelId); err == nil { + // Temporary permission check method until advanced permissions, please do not copy + if channel.Type == model.CHANNEL_OPEN && app.SessionHasPermissionToTeam(c.Session, channel.TeamId, model.PERMISSION_CREATE_POST_PUBLIC) { + hasPermission = true + } + } + + if !hasPermission { c.SetPermissionError(model.PERMISSION_CREATE_POST) return } diff --git a/api4/post_test.go b/api4/post_test.go index d554ca472..53babc6e6 100644 --- a/api4/post_test.go +++ b/api4/post_test.go @@ -197,7 +197,7 @@ func testCreatePostWithOutgoingHook( if triggerWord != "" { triggerWords = []string{triggerWord} } - + hook = &model.OutgoingWebhook{ ChannelId: channel.Id, TeamId: team.Id, @@ -257,6 +257,51 @@ func TestCreatePostWithOutgoingHook_no_content_type(t *testing.T) { testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TRIGGERWORDS_STARTS_WITH) } +func TestCreatePostPublic(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + defer TearDown() + Client := th.Client + + post := &model.Post{ChannelId: th.BasicChannel.Id, Message: "#hashtag a" + model.NewId() + "a"} + + user := model.User{Email: GenerateTestEmail(), Nickname: "Joram Wilander", Password: "hello1", Username: GenerateTestUsername(), Roles: model.ROLE_SYSTEM_USER.Id} + + ruser, resp := Client.CreateUser(&user) + CheckNoError(t, resp) + + Client.Login(user.Email, user.Password) + + _, resp = Client.CreatePost(post) + CheckForbiddenStatus(t, resp) + + app.UpdateUserRoles(ruser.Id, model.ROLE_SYSTEM_USER.Id+" "+model.ROLE_SYSTEM_POST_ALL_PUBLIC.Id) + app.InvalidateAllCaches() + + Client.Login(user.Email, user.Password) + + _, resp = Client.CreatePost(post) + CheckNoError(t, resp) + + post.ChannelId = th.BasicPrivateChannel.Id + _, resp = Client.CreatePost(post) + CheckForbiddenStatus(t, resp) + + app.UpdateUserRoles(ruser.Id, model.ROLE_SYSTEM_USER.Id) + app.JoinUserToTeam(th.BasicTeam, ruser, "") + app.UpdateTeamMemberRoles(th.BasicTeam.Id, ruser.Id, model.ROLE_TEAM_USER.Id+" "+model.ROLE_TEAM_POST_ALL_PUBLIC.Id) + app.InvalidateAllCaches() + + Client.Login(user.Email, user.Password) + + post.ChannelId = th.BasicPrivateChannel.Id + _, resp = Client.CreatePost(post) + CheckForbiddenStatus(t, resp) + + post.ChannelId = th.BasicChannel.Id + _, resp = Client.CreatePost(post) + CheckNoError(t, resp) +} + func TestUpdatePost(t *testing.T) { th := Setup().InitBasic().InitSystemAdmin() defer TearDown() diff --git a/api4/user.go b/api4/user.go index f13c33f0b..16c1f4a74 100644 --- a/api4/user.go +++ b/api4/user.go @@ -55,6 +55,11 @@ func InitUser() { BaseRoutes.User.Handle("/sessions/revoke", ApiSessionRequired(revokeSession)).Methods("POST") BaseRoutes.Users.Handle("/sessions/device", ApiSessionRequired(attachDeviceId)).Methods("PUT") BaseRoutes.User.Handle("/audits", ApiSessionRequired(getUserAudits)).Methods("GET") + + BaseRoutes.User.Handle("/tokens", ApiSessionRequired(createUserAccessToken)).Methods("POST") + BaseRoutes.User.Handle("/tokens", ApiSessionRequired(getUserAccessTokens)).Methods("GET") + BaseRoutes.Users.Handle("/tokens/{token_id:[A-Za-z0-9]+}", ApiSessionRequired(getUserAccessToken)).Methods("GET") + BaseRoutes.Users.Handle("/tokens/revoke", ApiSessionRequired(revokeUserAccessToken)).Methods("POST") } func createUser(c *Context, w http.ResponseWriter, r *http.Request) { @@ -1081,3 +1086,132 @@ func switchAccountType(c *Context, w http.ResponseWriter, r *http.Request) { c.LogAudit("success") w.Write([]byte(model.MapToJson(map[string]string{"follow_link": link}))) } + +func createUserAccessToken(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId() + if c.Err != nil { + return + } + + accessToken := model.UserAccessTokenFromJson(r.Body) + if accessToken == nil { + c.SetInvalidParam("user_access_token") + return + } + + if accessToken.Description == "" { + c.SetInvalidParam("description") + return + } + + c.LogAudit("") + + if !app.SessionHasPermissionTo(c.Session, model.PERMISSION_CREATE_USER_ACCESS_TOKEN) { + c.SetPermissionError(model.PERMISSION_CREATE_USER_ACCESS_TOKEN) + return + } + + if !app.SessionHasPermissionToUser(c.Session, c.Params.UserId) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + + accessToken.UserId = c.Params.UserId + accessToken.Token = "" + + var err *model.AppError + accessToken, err = app.CreateUserAccessToken(accessToken) + if err != nil { + c.Err = err + return + } + + c.LogAudit("success - token_id=" + accessToken.Id) + w.Write([]byte(accessToken.ToJson())) +} + +func getUserAccessTokens(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId() + if c.Err != nil { + return + } + + if !app.SessionHasPermissionTo(c.Session, model.PERMISSION_READ_USER_ACCESS_TOKEN) { + c.SetPermissionError(model.PERMISSION_READ_USER_ACCESS_TOKEN) + return + } + + if !app.SessionHasPermissionToUser(c.Session, c.Params.UserId) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + + accessTokens, err := app.GetUserAccessTokensForUser(c.Params.UserId, c.Params.Page, c.Params.PerPage) + if err != nil { + c.Err = err + return + } + + w.Write([]byte(model.UserAccessTokenListToJson(accessTokens))) +} + +func getUserAccessToken(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireTokenId() + if c.Err != nil { + return + } + + if !app.SessionHasPermissionTo(c.Session, model.PERMISSION_READ_USER_ACCESS_TOKEN) { + c.SetPermissionError(model.PERMISSION_READ_USER_ACCESS_TOKEN) + return + } + + accessToken, err := app.GetUserAccessToken(c.Params.TokenId, true) + if err != nil { + c.Err = err + return + } + + if !app.SessionHasPermissionToUser(c.Session, accessToken.UserId) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + + w.Write([]byte(accessToken.ToJson())) +} + +func revokeUserAccessToken(c *Context, w http.ResponseWriter, r *http.Request) { + props := model.MapFromJson(r.Body) + tokenId := props["token_id"] + + if tokenId == "" { + c.SetInvalidParam("token_id") + } + + c.LogAudit("") + + if !app.SessionHasPermissionTo(c.Session, model.PERMISSION_REVOKE_USER_ACCESS_TOKEN) { + c.SetPermissionError(model.PERMISSION_REVOKE_USER_ACCESS_TOKEN) + return + } + + accessToken, err := app.GetUserAccessToken(tokenId, false) + if err != nil { + c.Err = err + return + } + + if !app.SessionHasPermissionToUser(c.Session, accessToken.UserId) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + + err = app.RevokeUserAccessToken(accessToken) + if err != nil { + c.Err = err + return + } + + c.LogAudit("success - token_id=" + accessToken.Id) + ReturnStatusOK(w) +} diff --git a/api4/user_test.go b/api4/user_test.go index 77157e250..37ecd660d 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -2139,3 +2139,255 @@ func TestSwitchAccount(t *testing.T) { _, resp = Client.SwitchAccountType(sr) CheckUnauthorizedStatus(t, resp) } + +func TestCreateUserAccessToken(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + defer TearDown() + Client := th.Client + AdminClient := th.SystemAdminClient + + testDescription := "test token" + + enableUserAccessTokens := *utils.Cfg.ServiceSettings.EnableUserAccessTokens + defer func() { + *utils.Cfg.ServiceSettings.EnableUserAccessTokens = enableUserAccessTokens + }() + *utils.Cfg.ServiceSettings.EnableUserAccessTokens = true + + _, resp := Client.CreateUserAccessToken(th.BasicUser.Id, testDescription) + CheckForbiddenStatus(t, resp) + + _, resp = Client.CreateUserAccessToken("notarealuserid", testDescription) + CheckBadRequestStatus(t, resp) + + _, resp = Client.CreateUserAccessToken(th.BasicUser.Id, "") + CheckBadRequestStatus(t, resp) + + app.UpdateUserRoles(th.BasicUser.Id, model.ROLE_SYSTEM_USER.Id+" "+model.ROLE_SYSTEM_USER_ACCESS_TOKEN.Id) + + *utils.Cfg.ServiceSettings.EnableUserAccessTokens = false + _, resp = Client.CreateUserAccessToken(th.BasicUser.Id, testDescription) + CheckNotImplementedStatus(t, resp) + *utils.Cfg.ServiceSettings.EnableUserAccessTokens = true + + rtoken, resp := Client.CreateUserAccessToken(th.BasicUser.Id, testDescription) + CheckNoError(t, resp) + + if rtoken.UserId != th.BasicUser.Id { + t.Fatal("wrong user id") + } else if rtoken.Token == "" { + t.Fatal("token should not be empty") + } else if rtoken.Id == "" { + t.Fatal("id should not be empty") + } else if rtoken.Description != testDescription { + t.Fatal("description did not match") + } + + oldSessionToken := Client.AuthToken + Client.AuthToken = rtoken.Token + ruser, resp := Client.GetMe("") + CheckNoError(t, resp) + + if ruser.Id != th.BasicUser.Id { + t.Fatal("returned wrong user") + } + + Client.AuthToken = oldSessionToken + + _, resp = Client.CreateUserAccessToken(th.BasicUser2.Id, testDescription) + CheckForbiddenStatus(t, resp) + + rtoken, resp = AdminClient.CreateUserAccessToken(th.BasicUser.Id, testDescription) + CheckNoError(t, resp) + + if rtoken.UserId != th.BasicUser.Id { + t.Fatal("wrong user id") + } + + oldSessionToken = Client.AuthToken + Client.AuthToken = rtoken.Token + ruser, resp = Client.GetMe("") + CheckNoError(t, resp) + + if ruser.Id != th.BasicUser.Id { + t.Fatal("returned wrong user") + } +} + +func TestGetUserAccessToken(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + defer TearDown() + Client := th.Client + AdminClient := th.SystemAdminClient + + testDescription := "test token" + + enableUserAccessTokens := *utils.Cfg.ServiceSettings.EnableUserAccessTokens + defer func() { + *utils.Cfg.ServiceSettings.EnableUserAccessTokens = enableUserAccessTokens + }() + *utils.Cfg.ServiceSettings.EnableUserAccessTokens = true + + _, resp := Client.GetUserAccessToken("123") + CheckBadRequestStatus(t, resp) + + _, resp = Client.GetUserAccessToken(model.NewId()) + CheckForbiddenStatus(t, resp) + + app.UpdateUserRoles(th.BasicUser.Id, model.ROLE_SYSTEM_USER.Id+" "+model.ROLE_SYSTEM_USER_ACCESS_TOKEN.Id) + token, resp := Client.CreateUserAccessToken(th.BasicUser.Id, testDescription) + CheckNoError(t, resp) + + rtoken, resp := Client.GetUserAccessToken(token.Id) + CheckNoError(t, resp) + + if rtoken.UserId != th.BasicUser.Id { + t.Fatal("wrong user id") + } else if rtoken.Token != "" { + t.Fatal("token should be blank") + } else if rtoken.Id == "" { + t.Fatal("id should not be empty") + } else if rtoken.Description != testDescription { + t.Fatal("description did not match") + } + + _, resp = AdminClient.GetUserAccessToken(token.Id) + CheckNoError(t, resp) + + token, resp = Client.CreateUserAccessToken(th.BasicUser.Id, testDescription) + CheckNoError(t, resp) + + rtokens, resp := Client.GetUserAccessTokensForUser(th.BasicUser.Id, 0, 100) + CheckNoError(t, resp) + + if len(rtokens) != 2 { + t.Fatal("should have 2 tokens") + } + + for _, uat := range rtokens { + if uat.UserId != th.BasicUser.Id { + t.Fatal("wrong user id") + } + } + + rtokens, resp = Client.GetUserAccessTokensForUser(th.BasicUser.Id, 1, 1) + CheckNoError(t, resp) + + if len(rtokens) != 1 { + t.Fatal("should have 1 token") + } + + rtokens, resp = AdminClient.GetUserAccessTokensForUser(th.BasicUser.Id, 0, 100) + CheckNoError(t, resp) + + if len(rtokens) != 2 { + t.Fatal("should have 2 tokens") + } +} + +func TestRevokeUserAccessToken(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + defer TearDown() + Client := th.Client + AdminClient := th.SystemAdminClient + + testDescription := "test token" + + enableUserAccessTokens := *utils.Cfg.ServiceSettings.EnableUserAccessTokens + defer func() { + *utils.Cfg.ServiceSettings.EnableUserAccessTokens = enableUserAccessTokens + }() + *utils.Cfg.ServiceSettings.EnableUserAccessTokens = true + + app.UpdateUserRoles(th.BasicUser.Id, model.ROLE_SYSTEM_USER.Id+" "+model.ROLE_SYSTEM_USER_ACCESS_TOKEN.Id) + token, resp := Client.CreateUserAccessToken(th.BasicUser.Id, testDescription) + CheckNoError(t, resp) + + oldSessionToken := Client.AuthToken + Client.AuthToken = token.Token + _, resp = Client.GetMe("") + CheckNoError(t, resp) + Client.AuthToken = oldSessionToken + + ok, resp := Client.RevokeUserAccessToken(token.Id) + CheckNoError(t, resp) + + if !ok { + t.Fatal("should have passed") + } + + oldSessionToken = Client.AuthToken + Client.AuthToken = token.Token + _, resp = Client.GetMe("") + CheckUnauthorizedStatus(t, resp) + Client.AuthToken = oldSessionToken + + token, resp = AdminClient.CreateUserAccessToken(th.BasicUser2.Id, testDescription) + CheckNoError(t, resp) + + ok, resp = Client.RevokeUserAccessToken(token.Id) + CheckForbiddenStatus(t, resp) + + if ok { + t.Fatal("should have failed") + } +} + +func TestUserAccessTokenInactiveUser(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + defer TearDown() + Client := th.Client + + testDescription := "test token" + + enableUserAccessTokens := *utils.Cfg.ServiceSettings.EnableUserAccessTokens + defer func() { + *utils.Cfg.ServiceSettings.EnableUserAccessTokens = enableUserAccessTokens + }() + *utils.Cfg.ServiceSettings.EnableUserAccessTokens = true + + app.UpdateUserRoles(th.BasicUser.Id, model.ROLE_SYSTEM_USER.Id+" "+model.ROLE_SYSTEM_USER_ACCESS_TOKEN.Id) + token, resp := Client.CreateUserAccessToken(th.BasicUser.Id, testDescription) + CheckNoError(t, resp) + + Client.AuthToken = token.Token + _, resp = Client.GetMe("") + CheckNoError(t, resp) + + app.UpdateActive(th.BasicUser, false) + + _, resp = Client.GetMe("") + CheckUnauthorizedStatus(t, resp) +} + +func TestUserAccessTokenDisableConfig(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + defer TearDown() + Client := th.Client + + testDescription := "test token" + + enableUserAccessTokens := *utils.Cfg.ServiceSettings.EnableUserAccessTokens + defer func() { + *utils.Cfg.ServiceSettings.EnableUserAccessTokens = enableUserAccessTokens + }() + *utils.Cfg.ServiceSettings.EnableUserAccessTokens = true + + app.UpdateUserRoles(th.BasicUser.Id, model.ROLE_SYSTEM_USER.Id+" "+model.ROLE_SYSTEM_USER_ACCESS_TOKEN.Id) + token, resp := Client.CreateUserAccessToken(th.BasicUser.Id, testDescription) + CheckNoError(t, resp) + + oldSessionToken := Client.AuthToken + Client.AuthToken = token.Token + _, resp = Client.GetMe("") + CheckNoError(t, resp) + + *utils.Cfg.ServiceSettings.EnableUserAccessTokens = false + + _, resp = Client.GetMe("") + CheckUnauthorizedStatus(t, resp) + + Client.AuthToken = oldSessionToken + _, resp = Client.GetMe("") + CheckNoError(t, resp) +} diff --git a/app/session.go b/app/session.go index 4b1ea18f2..07f5c6e0a 100644 --- a/app/session.go +++ b/app/session.go @@ -16,6 +16,8 @@ import ( var sessionCache *utils.Cache = utils.NewLru(model.SESSION_CACHE_SIZE) func CreateSession(session *model.Session) (*model.Session, *model.AppError) { + session.Token = "" + if result := <-Srv.Store.Session().Save(session); result.Err != nil { return nil, result.Err } else { @@ -43,22 +45,31 @@ func GetSession(token string) (*model.Session, *model.AppError) { } if session == nil { - if sessionResult := <-Srv.Store.Session().Get(token); sessionResult.Err != nil { - return nil, model.NewLocAppError("GetSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token, "Error": sessionResult.Err.DetailedError}, "") - } else { + if sessionResult := <-Srv.Store.Session().Get(token); sessionResult.Err == nil { session = sessionResult.Data.(*model.Session) - if session == nil || session.IsExpired() || session.Token != token { - return nil, model.NewLocAppError("GetSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token, "Error": ""}, "") - } else { - AddSessionToCache(session) - return session, nil + if session != nil { + if session.Token != token { + return nil, model.NewAppError("GetSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token, "Error": ""}, "", http.StatusUnauthorized) + } + + if !session.IsExpired() { + AddSessionToCache(session) + } } } } + if session == nil { + var err *model.AppError + session, err = createSessionForUserAccessToken(token) + if err != nil { + return nil, model.NewAppError("GetSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token}, err.Error(), http.StatusUnauthorized) + } + } + if session == nil || session.IsExpired() { - return nil, model.NewLocAppError("GetSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token}, "") + return nil, model.NewAppError("GetSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token}, "", http.StatusUnauthorized) } return session, nil @@ -200,3 +211,104 @@ func UpdateLastActivityAtIfNeeded(session model.Session) { session.LastActivityAt = now AddSessionToCache(&session) } + +func CreateUserAccessToken(token *model.UserAccessToken) (*model.UserAccessToken, *model.AppError) { + if !*utils.Cfg.ServiceSettings.EnableUserAccessTokens { + return nil, model.NewAppError("CreateUserAccessToken", "app.user_access_token.disabled", nil, "", http.StatusNotImplemented) + } + + token.Token = model.NewId() + + if result := <-Srv.Store.UserAccessToken().Save(token); result.Err != nil { + return nil, result.Err + } else { + return result.Data.(*model.UserAccessToken), nil + } +} + +func createSessionForUserAccessToken(tokenString string) (*model.Session, *model.AppError) { + if !*utils.Cfg.ServiceSettings.EnableUserAccessTokens { + return nil, model.NewAppError("createSessionForUserAccessToken", "app.user_access_token.invalid_or_missing", nil, "EnableUserAccessTokens=false", http.StatusUnauthorized) + } + + var token *model.UserAccessToken + if result := <-Srv.Store.UserAccessToken().GetByToken(tokenString); result.Err != nil { + return nil, model.NewAppError("createSessionForUserAccessToken", "app.user_access_token.invalid_or_missing", nil, result.Err.Error(), http.StatusUnauthorized) + } else { + token = result.Data.(*model.UserAccessToken) + } + + var user *model.User + if result := <-Srv.Store.User().Get(token.UserId); result.Err != nil { + return nil, result.Err + } else { + user = result.Data.(*model.User) + } + + if user.DeleteAt != 0 { + return nil, model.NewAppError("createSessionForUserAccessToken", "app.user_access_token.invalid_or_missing", nil, "inactive_user_id="+user.Id, http.StatusUnauthorized) + } + + session := &model.Session{ + Token: token.Token, + UserId: user.Id, + Roles: user.GetRawRoles(), + IsOAuth: false, + } + + session.AddProp(model.SESSION_PROP_USER_ACCESS_TOKEN_ID, token.Id) + session.AddProp(model.SESSION_PROP_TYPE, model.SESSION_TYPE_USER_ACCESS_TOKEN) + session.SetExpireInDays(model.SESSION_USER_ACCESS_TOKEN_EXPIRY) + + if result := <-Srv.Store.Session().Save(session); result.Err != nil { + return nil, result.Err + } else { + session := result.Data.(*model.Session) + + AddSessionToCache(session) + + return session, nil + } +} + +func RevokeUserAccessToken(token *model.UserAccessToken) *model.AppError { + var session *model.Session + if result := <-Srv.Store.Session().Get(token.Token); result.Err == nil { + session = result.Data.(*model.Session) + } + + if result := <-Srv.Store.UserAccessToken().Delete(token.Id); result.Err != nil { + return result.Err + } + + if session == nil { + return nil + } + + return RevokeSession(session) +} + +func GetUserAccessTokensForUser(userId string, page, perPage int) ([]*model.UserAccessToken, *model.AppError) { + if result := <-Srv.Store.UserAccessToken().GetByUser(userId, page*perPage, perPage); result.Err != nil { + return nil, result.Err + } else { + tokens := result.Data.([]*model.UserAccessToken) + for _, token := range tokens { + token.Token = "" + } + + return tokens, nil + } +} + +func GetUserAccessToken(tokenId string, sanitize bool) (*model.UserAccessToken, *model.AppError) { + if result := <-Srv.Store.UserAccessToken().Get(tokenId); result.Err != nil { + return nil, result.Err + } else { + token := result.Data.(*model.UserAccessToken) + if sanitize { + token.Token = "" + } + return token, nil + } +} diff --git a/app/user.go b/app/user.go index 0b9a79863..426a11bcb 100644 --- a/app/user.go +++ b/app/user.go @@ -1224,6 +1224,10 @@ func PermanentDeleteUser(user *model.User) *model.AppError { return result.Err } + if result := <-Srv.Store.UserAccessToken().DeleteAllForUser(user.Id); result.Err != nil { + return result.Err + } + if result := <-Srv.Store.OAuth().PermanentDeleteAuthDataByUser(user.Id); result.Err != nil { return result.Err } diff --git a/config/config.json b/config/config.json index 316e9de01..ad4dc6120 100644 --- a/config/config.json +++ b/config/config.json @@ -29,6 +29,7 @@ "EnableInsecureOutgoingConnections": false, "EnableMultifactorAuthentication": false, "EnforceMultifactorAuthentication": false, + "EnableUserAccessTokens": false, "AllowCorsFrom": "", "SessionLengthWebInDays": 30, "SessionLengthMobileInDays": 30, diff --git a/i18n/en.json b/i18n/en.json index d9ba232ef..c4940295b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3092,6 +3092,14 @@ "translation": "Invalid {{.Name}} parameter" }, { + "id": "app.user_access_token.disabled", + "translation": "User access tokens are disabled on this server. Please contact your system administrator for details." + }, + { + "id": "app.user_access_token.invalid_or_missing", + "translation": "Invalid or missing token" + }, + { "id": "app.channel.create_channel.no_team_id.app_error", "translation": "Must specify the team ID to create a channel" }, @@ -3496,6 +3504,62 @@ "translation": "Team name missing from User's Team Membership." }, { + "id": "authentication.roles.system_post_all_public.name", + "translation": "Post in Public Channels" + }, + { + "id": "authentication.roles.system_post_all_public.description", + "translation": "A role with the permission to post in any public channel on the system" + }, + { + "id": "authentication.roles.team_post_all_public.name", + "translation": "Post in Public Channels" + }, + { + "id": "authentication.roles.team_post_all_public.description", + "translation": "A role with the permission to post in any public channel on the team" + }, + { + "id": "authentication.roles.system_user_access_token.name", + "translation": "User Access Token" + }, + { + "id": "authentication.roles.system_user_access_token.description", + "translation": "A role with the permissions to create, read and revoke user access tokens" + }, + { + "id": "authentication.permissions.create_post_public.name", + "translation": "Create Posts in Public Channels" + }, + { + "id": "authentication.permissions.create_post_public.description", + "translation": "Ability to create posts in public channels" + }, + { + "id": "authentication.permissions.create_user_access_token.name", + "translation": "Create User Access Token" + }, + { + "id": "authentication.permissions.create_user_access_token.description", + "translation": "Ability to create user access tokens" + }, + { + "id": "authentication.permissions.read_user_access_token.name", + "translation": "Read User Access Tokens" + }, + { + "id": "authentication.permissions.read_user_access_token.description", + "translation": "Ability to read user access tokens' id, description and user_id fields" + }, + { + "id": "authentication.permissions.revoke_user_access_token.name", + "translation": "Revoke User Access Token" + }, + { + "id": "authentication.permissions.revoke_user_access_token.description", + "translation": "Ability to revoke user access tokens" + }, + { "id": "authentication.permissions.create_group_channel.description", "translation": "Ability to create new group message channels" }, @@ -4016,6 +4080,18 @@ "translation": "Invalid access token" }, { + "id": "model.user_access_token.is_valid.token.app_error", + "translation": "Invalid access token" + }, + { + "id": "model.user_access_token.is_valid.user_id.app_error", + "translation": "Invalid user id" + }, + { + "id": "model.user_access_token.is_valid.description.app_error", + "translation": "Invalid description, must be 255 or less characters" + }, + { "id": "model.access.is_valid.client_id.app_error", "translation": "Invalid client id" }, @@ -6056,6 +6132,26 @@ "translation": "We couldn't update the team name" }, { + "id": "store.sql_user_access_token.get_by_user.app_error", + "translation": "We couldn't get the user access tokens by user" + }, + { + "id": "store.sql_user_access_token.get_by_token.app_error", + "translation": "We couldn't get the user access token by token" + }, + { + "id": "store.sql_user_access_token.get.app_error", + "translation": "We couldn't get the user access token" + }, + { + "id": "store.sql_user_access_token.delete.app_error", + "translation": "We couldn't delete the user access token" + }, + { + "id": "store.sql_user_access_token.save.app_error", + "translation": "We couldn't save the user access token" + }, + { "id": "store.sql_user.analytics_get_inactive_users_count.app_error", "translation": "We could not count the inactive users" }, diff --git a/model/authorization.go b/model/authorization.go index 880d25e27..cf7e2b481 100644 --- a/model/authorization.go +++ b/model/authorization.go @@ -48,6 +48,7 @@ var PERMISSION_MANAGE_OTHERS_WEBHOOKS *Permission var PERMISSION_MANAGE_OAUTH *Permission var PERMISSION_MANAGE_SYSTEM_WIDE_OAUTH *Permission var PERMISSION_CREATE_POST *Permission +var PERMISSION_CREATE_POST_PUBLIC *Permission var PERMISSION_EDIT_POST *Permission var PERMISSION_EDIT_OTHERS_POSTS *Permission var PERMISSION_DELETE_POST *Permission @@ -59,6 +60,9 @@ var PERMISSION_IMPORT_TEAM *Permission var PERMISSION_VIEW_TEAM *Permission var PERMISSION_LIST_USERS_WITHOUT_TEAM *Permission var PERMISSION_MANAGE_JOBS *Permission +var PERMISSION_CREATE_USER_ACCESS_TOKEN *Permission +var PERMISSION_READ_USER_ACCESS_TOKEN *Permission +var PERMISSION_REVOKE_USER_ACCESS_TOKEN *Permission // General permission that encompases all system admin functions // in the future this could be broken up to allow access to some @@ -67,9 +71,12 @@ var PERMISSION_MANAGE_SYSTEM *Permission var ROLE_SYSTEM_USER *Role var ROLE_SYSTEM_ADMIN *Role +var ROLE_SYSTEM_POST_ALL_PUBLIC *Role +var ROLE_SYSTEM_USER_ACCESS_TOKEN *Role var ROLE_TEAM_USER *Role var ROLE_TEAM_ADMIN *Role +var ROLE_TEAM_POST_ALL_PUBLIC *Role var ROLE_CHANNEL_USER *Role var ROLE_CHANNEL_ADMIN *Role @@ -243,6 +250,11 @@ func InitalizePermissions() { "authentication.permissions.create_post.name", "authentication.permissions.create_post.description", } + PERMISSION_CREATE_POST_PUBLIC = &Permission{ + "create_post_public", + "authentication.permissions.create_post_public.name", + "authentication.permissions.create_post_public.description", + } PERMISSION_EDIT_POST = &Permission{ "edit_post", "authentication.permissions.edit_post.name", @@ -290,8 +302,23 @@ func InitalizePermissions() { } PERMISSION_LIST_USERS_WITHOUT_TEAM = &Permission{ "list_users_without_team", - "authentication.permisssions.list_users_without_team.name", - "authentication.permisssions.list_users_without_team.description", + "authentication.permissions.list_users_without_team.name", + "authentication.permissions.list_users_without_team.description", + } + PERMISSION_CREATE_USER_ACCESS_TOKEN = &Permission{ + "create_user_access_token", + "authentication.permissions.create_user_access_token.name", + "authentication.permissions.create_user_access_token.description", + } + PERMISSION_READ_USER_ACCESS_TOKEN = &Permission{ + "read_user_access_token", + "authentication.permissions.read_user_access_token.name", + "authentication.permissions.read_user_access_token.description", + } + PERMISSION_REVOKE_USER_ACCESS_TOKEN = &Permission{ + "revoke_user_access_token", + "authentication.permissions.revoke_user_access_token.name", + "authentication.permissions.revoke_user_access_token.description", } PERMISSION_MANAGE_JOBS = &Permission{ "manage_jobs", @@ -348,6 +375,17 @@ func InitalizeRoles() { }, } BuiltInRoles[ROLE_TEAM_USER.Id] = ROLE_TEAM_USER + + ROLE_TEAM_POST_ALL_PUBLIC = &Role{ + "team_post_all_public", + "authentication.roles.team_post_all_public.name", + "authentication.roles.team_post_all_public.description", + []string{ + PERMISSION_CREATE_POST_PUBLIC.Id, + }, + } + BuiltInRoles[ROLE_TEAM_POST_ALL_PUBLIC.Id] = ROLE_TEAM_POST_ALL_PUBLIC + ROLE_TEAM_ADMIN = &Role{ "team_admin", "authentication.roles.team_admin.name", @@ -378,6 +416,29 @@ func InitalizeRoles() { }, } BuiltInRoles[ROLE_SYSTEM_USER.Id] = ROLE_SYSTEM_USER + + ROLE_SYSTEM_POST_ALL_PUBLIC = &Role{ + "system_post_all_public", + "authentication.roles.system_post_all_public.name", + "authentication.roles.system_post_all_public.description", + []string{ + PERMISSION_CREATE_POST_PUBLIC.Id, + }, + } + BuiltInRoles[ROLE_SYSTEM_POST_ALL_PUBLIC.Id] = ROLE_SYSTEM_POST_ALL_PUBLIC + + ROLE_SYSTEM_USER_ACCESS_TOKEN = &Role{ + "system_user_access_token", + "authentication.roles.system_user_access_token.name", + "authentication.roles.system_user_access_token.description", + []string{ + PERMISSION_CREATE_USER_ACCESS_TOKEN.Id, + PERMISSION_READ_USER_ACCESS_TOKEN.Id, + PERMISSION_REVOKE_USER_ACCESS_TOKEN.Id, + }, + } + BuiltInRoles[ROLE_SYSTEM_USER_ACCESS_TOKEN.Id] = ROLE_SYSTEM_USER_ACCESS_TOKEN + ROLE_SYSTEM_ADMIN = &Role{ "system_admin", "authentication.roles.global_admin.name", @@ -412,6 +473,10 @@ func InitalizeRoles() { PERMISSION_ADD_USER_TO_TEAM.Id, PERMISSION_LIST_USERS_WITHOUT_TEAM.Id, PERMISSION_MANAGE_JOBS.Id, + PERMISSION_CREATE_POST_PUBLIC.Id, + PERMISSION_CREATE_USER_ACCESS_TOKEN.Id, + PERMISSION_READ_USER_ACCESS_TOKEN.Id, + PERMISSION_REVOKE_USER_ACCESS_TOKEN.Id, }, ROLE_TEAM_USER.Permissions..., ), diff --git a/model/client4.go b/model/client4.go index 6f5eb03c6..2daca4dc9 100644 --- a/model/client4.go +++ b/model/client4.go @@ -70,6 +70,10 @@ func (c *Client4) GetUserRoute(userId string) string { return fmt.Sprintf(c.GetUsersRoute()+"/%v", userId) } +func (c *Client4) GetUserAccessTokenRoute(tokenId string) string { + return fmt.Sprintf(c.GetUsersRoute()+"/tokens/%v", tokenId) +} + func (c *Client4) GetUserByUsernameRoute(userName string) string { return fmt.Sprintf(c.GetUsersRoute()+"/username/%v", userName) } @@ -957,6 +961,60 @@ func (c *Client4) SetProfileImage(userId string, data []byte) (bool, *Response) } } +// CreateUserAccessToken will generate a user access token that can be used in place +// of a session token to access the REST API. Must have the 'create_user_access_token' +// permission and if generating for another user, must have the 'edit_other_users' +// permission. A non-blank description is required. +func (c *Client4) CreateUserAccessToken(userId, description string) (*UserAccessToken, *Response) { + requestBody := map[string]string{"description": description} + if r, err := c.DoApiPost(c.GetUserRoute(userId)+"/tokens", MapToJson(requestBody)); err != nil { + return nil, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return UserAccessTokenFromJson(r.Body), BuildResponse(r) + } +} + +// GetUserAccessToken will get a user access token's id, description and the user_id +// of the user it is for. The actual token will not be returned. Must have the +// 'read_user_access_token' permission and if getting for another user, must have the +// 'edit_other_users' permission. +func (c *Client4) GetUserAccessToken(tokenId string) (*UserAccessToken, *Response) { + if r, err := c.DoApiGet(c.GetUserAccessTokenRoute(tokenId), ""); err != nil { + return nil, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return UserAccessTokenFromJson(r.Body), BuildResponse(r) + } +} + +// GetUserAccessTokensForUser will get a paged list of user access tokens showing id, +// description and user_id for each. The actual tokens will not be returned. Must have +// the 'read_user_access_token' permission and if getting for another user, must have the +// 'edit_other_users' permission. +func (c *Client4) GetUserAccessTokensForUser(userId string, page, perPage int) ([]*UserAccessToken, *Response) { + query := fmt.Sprintf("?page=%v&per_page=%v", page, perPage) + if r, err := c.DoApiGet(c.GetUserRoute(userId)+"/tokens"+query, ""); err != nil { + return nil, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return UserAccessTokenListFromJson(r.Body), BuildResponse(r) + } +} + +// RevokeUserAccessToken will revoke a user access token by id. Must have the +// 'revoke_user_access_token' permission and if revoking for another user, must have the +// 'edit_other_users' permission. +func (c *Client4) RevokeUserAccessToken(tokenId string) (bool, *Response) { + requestBody := map[string]string{"token_id": tokenId} + if r, err := c.DoApiPost(c.GetUsersRoute()+"/tokens/revoke", MapToJson(requestBody)); err != nil { + return false, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return CheckStatusOK(r), BuildResponse(r) + } +} + // Team Section // CreateTeam creates a team in the system based on the provided team struct. diff --git a/model/config.go b/model/config.go index 475e512f9..0755f514f 100644 --- a/model/config.go +++ b/model/config.go @@ -154,6 +154,7 @@ type ServiceSettings struct { EnableInsecureOutgoingConnections *bool EnableMultifactorAuthentication *bool EnforceMultifactorAuthentication *bool + EnableUserAccessTokens *bool AllowCorsFrom *string SessionLengthWebInDays *int SessionLengthMobileInDays *int @@ -618,6 +619,11 @@ func (o *Config) SetDefaults() { *o.ServiceSettings.EnforceMultifactorAuthentication = false } + if o.ServiceSettings.EnableUserAccessTokens == nil { + o.ServiceSettings.EnableUserAccessTokens = new(bool) + *o.ServiceSettings.EnableUserAccessTokens = false + } + if o.PasswordSettings.MinimumLength == nil { o.PasswordSettings.MinimumLength = new(int) *o.PasswordSettings.MinimumLength = PASSWORD_MINIMUM_LENGTH diff --git a/model/session.go b/model/session.go index 4f3547582..960c18cbf 100644 --- a/model/session.go +++ b/model/session.go @@ -10,13 +10,17 @@ import ( ) const ( - SESSION_COOKIE_TOKEN = "MMAUTHTOKEN" - SESSION_COOKIE_USER = "MMUSERID" - SESSION_CACHE_SIZE = 35000 - SESSION_PROP_PLATFORM = "platform" - SESSION_PROP_OS = "os" - SESSION_PROP_BROWSER = "browser" - SESSION_ACTIVITY_TIMEOUT = 1000 * 60 * 5 // 5 minutes + SESSION_COOKIE_TOKEN = "MMAUTHTOKEN" + SESSION_COOKIE_USER = "MMUSERID" + SESSION_CACHE_SIZE = 35000 + SESSION_PROP_PLATFORM = "platform" + SESSION_PROP_OS = "os" + SESSION_PROP_BROWSER = "browser" + SESSION_PROP_TYPE = "type" + SESSION_PROP_USER_ACCESS_TOKEN_ID = "user_access_token_id" + SESSION_TYPE_USER_ACCESS_TOKEN = "UserAccessToken" + SESSION_ACTIVITY_TIMEOUT = 1000 * 60 * 5 // 5 minutes + SESSION_USER_ACCESS_TOKEN_EXPIRY = 100 * 365 // 100 years ) type Session struct { @@ -58,7 +62,9 @@ func (me *Session) PreSave() { me.Id = NewId() } - me.Token = NewId() + if me.Token == "" { + me.Token = NewId() + } me.CreateAt = GetMillis() me.LastActivityAt = me.CreateAt diff --git a/model/user_access_token.go b/model/user_access_token.go new file mode 100644 index 000000000..090780fd0 --- /dev/null +++ b/model/user_access_token.go @@ -0,0 +1,81 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" + "net/http" +) + +type UserAccessToken struct { + Id string `json:"id"` + Token string `json:"token,omitempty"` + UserId string `json:"user_id"` + Description string `json:"description"` +} + +func (t *UserAccessToken) IsValid() *AppError { + if len(t.Id) != 26 { + return NewAppError("UserAccessToken.IsValid", "model.user_access_token.is_valid.id.app_error", nil, "", http.StatusBadRequest) + } + + if len(t.Token) != 26 { + return NewAppError("UserAccessToken.IsValid", "model.user_access_token.is_valid.token.app_error", nil, "", http.StatusBadRequest) + } + + if len(t.UserId) != 26 { + return NewAppError("UserAccessToken.IsValid", "model.user_access_token.is_valid.user_id.app_error", nil, "", http.StatusBadRequest) + } + + if len(t.Description) > 255 { + return NewAppError("UserAccessToken.IsValid", "model.user_access_token.is_valid.description.app_error", nil, "", http.StatusBadRequest) + } + + return nil +} + +func (t *UserAccessToken) PreSave() { + t.Id = NewId() +} + +func (t *UserAccessToken) ToJson() string { + b, err := json.Marshal(t) + if err != nil { + return "" + } else { + return string(b) + } +} + +func UserAccessTokenFromJson(data io.Reader) *UserAccessToken { + decoder := json.NewDecoder(data) + var t UserAccessToken + err := decoder.Decode(&t) + if err == nil { + return &t + } else { + return nil + } +} + +func UserAccessTokenListToJson(t []*UserAccessToken) string { + b, err := json.Marshal(t) + if err != nil { + return "" + } else { + return string(b) + } +} + +func UserAccessTokenListFromJson(data io.Reader) []*UserAccessToken { + decoder := json.NewDecoder(data) + var t []*UserAccessToken + err := decoder.Decode(&t) + if err == nil { + return t + } else { + return nil + } +} diff --git a/model/user_access_token_test.go b/model/user_access_token_test.go new file mode 100644 index 000000000..1b4a9ccfd --- /dev/null +++ b/model/user_access_token_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestUserAccessTokenJson(t *testing.T) { + a1 := UserAccessToken{} + a1.UserId = NewId() + a1.Token = NewId() + + json := a1.ToJson() + ra1 := UserAccessTokenFromJson(strings.NewReader(json)) + + if a1.Token != ra1.Token { + t.Fatal("tokens didn't match") + } + + tokens := []*UserAccessToken{&a1} + json = UserAccessTokenListToJson(tokens) + tokens = UserAccessTokenListFromJson(strings.NewReader(json)) + + if tokens[0].Token != a1.Token { + t.Fatal("tokens didn't match") + } +} + +func TestUserAccessTokenIsValid(t *testing.T) { + ad := UserAccessToken{} + + if err := ad.IsValid(); err == nil || err.Id != "model.user_access_token.is_valid.id.app_error" { + t.Fatal(err) + } + + ad.Id = NewRandomString(26) + if err := ad.IsValid(); err == nil || err.Id != "model.user_access_token.is_valid.token.app_error" { + t.Fatal(err) + } + + ad.Token = NewRandomString(26) + if err := ad.IsValid(); err == nil || err.Id != "model.user_access_token.is_valid.user_id.app_error" { + t.Fatal(err) + } + + ad.UserId = NewRandomString(26) + if err := ad.IsValid(); err != nil { + t.Fatal(err) + } + + ad.Description = NewRandomString(256) + if err := ad.IsValid(); err == nil || err.Id != "model.user_access_token.is_valid.description.app_error" { + t.Fatal(err) + } +} diff --git a/store/layered_store.go b/store/layered_store.go index 3d3f941e8..4eb908659 100644 --- a/store/layered_store.go +++ b/store/layered_store.go @@ -139,6 +139,10 @@ func (s *LayeredStore) Job() JobStore { return s.DatabaseLayer.Job() } +func (s *LayeredStore) UserAccessToken() UserAccessTokenStore { + return s.DatabaseLayer.UserAccessToken() +} + func (s *LayeredStore) MarkSystemRanUnitTests() { s.DatabaseLayer.MarkSystemRanUnitTests() } diff --git a/store/sql_store.go b/store/sql_store.go index a039401f3..817f3fb0f 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -80,4 +80,5 @@ type SqlStore interface { FileInfo() FileInfoStore Reaction() ReactionStore Job() JobStore + UserAccessToken() UserAccessTokenStore } diff --git a/store/sql_supplier.go b/store/sql_supplier.go index df934f2cb..a5f4f71b6 100644 --- a/store/sql_supplier.go +++ b/store/sql_supplier.go @@ -58,26 +58,27 @@ const ( ) type SqlSupplierOldStores struct { - team TeamStore - channel ChannelStore - post PostStore - user UserStore - audit AuditStore - cluster ClusterDiscoveryStore - compliance ComplianceStore - session SessionStore - oauth OAuthStore - system SystemStore - webhook WebhookStore - command CommandStore - preference PreferenceStore - license LicenseStore - token TokenStore - emoji EmojiStore - status StatusStore - fileInfo FileInfoStore - reaction ReactionStore - job JobStore + team TeamStore + channel ChannelStore + post PostStore + user UserStore + audit AuditStore + cluster ClusterDiscoveryStore + compliance ComplianceStore + session SessionStore + oauth OAuthStore + system SystemStore + webhook WebhookStore + command CommandStore + preference PreferenceStore + license LicenseStore + token TokenStore + emoji EmojiStore + status StatusStore + fileInfo FileInfoStore + reaction ReactionStore + job JobStore + userAccessToken UserAccessTokenStore } type SqlSupplier struct { @@ -117,6 +118,7 @@ func NewSqlSupplier() *SqlSupplier { supplier.oldStores.status = NewSqlStatusStore(supplier) supplier.oldStores.fileInfo = NewSqlFileInfoStore(supplier) supplier.oldStores.job = NewSqlJobStore(supplier) + supplier.oldStores.userAccessToken = NewSqlUserAccessTokenStore(supplier) initSqlSupplierReactions(supplier) @@ -147,6 +149,7 @@ func NewSqlSupplier() *SqlSupplier { supplier.oldStores.status.(*SqlStatusStore).CreateIndexesIfNotExists() supplier.oldStores.fileInfo.(*SqlFileInfoStore).CreateIndexesIfNotExists() supplier.oldStores.job.(*SqlJobStore).CreateIndexesIfNotExists() + supplier.oldStores.userAccessToken.(*SqlUserAccessTokenStore).CreateIndexesIfNotExists() supplier.oldStores.preference.(*SqlPreferenceStore).DeleteUnusedFeatures() @@ -760,6 +763,10 @@ func (ss *SqlSupplier) Job() JobStore { return ss.oldStores.job } +func (ss *SqlSupplier) UserAccessToken() UserAccessTokenStore { + return ss.oldStores.userAccessToken +} + func (ss *SqlSupplier) DropAllTables() { ss.master.TruncateTables() } diff --git a/store/sql_user_access_token_store.go b/store/sql_user_access_token_store.go new file mode 100644 index 000000000..c8a67bbe7 --- /dev/null +++ b/store/sql_user_access_token_store.go @@ -0,0 +1,262 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "database/sql" + "net/http" + + "github.com/mattermost/gorp" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +type SqlUserAccessTokenStore struct { + SqlStore +} + +func NewSqlUserAccessTokenStore(sqlStore SqlStore) UserAccessTokenStore { + s := &SqlUserAccessTokenStore{sqlStore} + + for _, db := range sqlStore.GetAllConns() { + table := db.AddTableWithName(model.UserAccessToken{}, "UserAccessTokens").SetKeys(false, "Id") + table.ColMap("Id").SetMaxSize(26) + table.ColMap("Token").SetMaxSize(26).SetUnique(true) + table.ColMap("UserId").SetMaxSize(26) + table.ColMap("Description").SetMaxSize(512) + } + + return s +} + +func (s SqlUserAccessTokenStore) CreateIndexesIfNotExists() { + s.CreateIndexIfNotExists("idx_user_access_tokens_token", "UserAccessTokens", "Token") + s.CreateIndexIfNotExists("idx_user_access_tokens_user_id", "UserAccessTokens", "UserId") +} + +func (s SqlUserAccessTokenStore) Save(token *model.UserAccessToken) StoreChannel { + + storeChannel := make(StoreChannel, 1) + + go func() { + result := StoreResult{} + + token.PreSave() + + if result.Err = token.IsValid(); result.Err != nil { + storeChannel <- result + close(storeChannel) + return + } + + if err := s.GetMaster().Insert(token); err != nil { + result.Err = model.NewAppError("SqlUserAccessTokenStore.Save", "store.sql_user_access_token.save.app_error", nil, "", http.StatusInternalServerError) + } else { + result.Data = token + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlUserAccessTokenStore) Delete(tokenId string) StoreChannel { + + storeChannel := make(StoreChannel, 1) + + go func() { + result := StoreResult{} + + transaction, err := s.GetMaster().Begin() + if err != nil { + result.Err = model.NewAppError("SqlUserAccessTokenStore.Delete", "store.sql_user_access_token.delete.app_error", nil, err.Error(), http.StatusInternalServerError) + } else { + if extrasResult := s.deleteSessionsAndTokensById(transaction, tokenId); extrasResult.Err != nil { + result = extrasResult + } + + if result.Err == nil { + if err := transaction.Commit(); err != nil { + // don't need to rollback here since the transaction is already closed + result.Err = model.NewAppError("SqlUserAccessTokenStore.Delete", "store.sql_user_access_token.delete.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } else { + if err := transaction.Rollback(); err != nil { + result.Err = model.NewAppError("SqlUserAccessTokenStore.Delete", "store.sql_user_access_token.delete.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlUserAccessTokenStore) deleteSessionsAndTokensById(transaction *gorp.Transaction, tokenId string) StoreResult { + result := StoreResult{} + + query := "" + if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES { + query = "DELETE FROM Sessions s USING UserAccessTokens o WHERE o.Token = s.Token AND o.Id = :Id" + } else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_MYSQL { + query = "DELETE s.* FROM Sessions s INNER JOIN UserAccessTokens o ON o.Token = s.Token WHERE o.Id = :Id" + } + + if _, err := transaction.Exec(query, map[string]interface{}{"Id": tokenId}); err != nil { + result.Err = model.NewAppError("SqlUserAccessTokenStore.deleteSessionsById", "store.sql_user_access_token.delete.app_error", nil, "id="+tokenId+", err="+err.Error(), http.StatusInternalServerError) + return result + } + + return s.deleteTokensById(transaction, tokenId) +} + +func (s SqlUserAccessTokenStore) deleteTokensById(transaction *gorp.Transaction, tokenId string) StoreResult { + result := StoreResult{} + + if _, err := transaction.Exec("DELETE FROM UserAccessTokens WHERE Id = :Id", map[string]interface{}{"Id": tokenId}); err != nil { + result.Err = model.NewAppError("SqlUserAccessTokenStore.deleteTokensById", "store.sql_user_access_token.delete.app_error", nil, "", http.StatusInternalServerError) + } + + return result +} + +func (s SqlUserAccessTokenStore) DeleteAllForUser(userId string) StoreChannel { + + storeChannel := make(StoreChannel, 1) + + go func() { + result := StoreResult{} + + transaction, err := s.GetMaster().Begin() + if err != nil { + result.Err = model.NewAppError("SqlUserAccessTokenStore.DeleteAllForUser", "store.sql_user_access_token.delete.app_error", nil, err.Error(), http.StatusInternalServerError) + } else { + if extrasResult := s.deleteSessionsandTokensByUser(transaction, userId); extrasResult.Err != nil { + result = extrasResult + } + + if result.Err == nil { + if err := transaction.Commit(); err != nil { + // don't need to rollback here since the transaction is already closed + result.Err = model.NewAppError("SqlUserAccessTokenStore.DeleteAllForUser", "store.sql_user_access_token.delete.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } else { + if err := transaction.Rollback(); err != nil { + result.Err = model.NewAppError("SqlUserAccessTokenStore.DeleteAllForUser", "store.sql_user_access_token.delete.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlUserAccessTokenStore) deleteSessionsandTokensByUser(transaction *gorp.Transaction, userId string) StoreResult { + result := StoreResult{} + + query := "" + if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_POSTGRES { + query = "DELETE FROM Sessions s USING UserAccessTokens o WHERE o.Token = s.Token AND o.UserId = :UserId" + } else if utils.Cfg.SqlSettings.DriverName == model.DATABASE_DRIVER_MYSQL { + query = "DELETE s.* FROM Sessions s INNER JOIN UserAccessTokens o ON o.Token = s.Token WHERE o.UserId = :UserId" + } + + if _, err := transaction.Exec(query, map[string]interface{}{"UserId": userId}); err != nil { + result.Err = model.NewAppError("SqlUserAccessTokenStore.deleteSessionsByUser", "store.sql_user_access_token.delete.app_error", nil, "user_id="+userId+", err="+err.Error(), http.StatusInternalServerError) + return result + } + + return s.deleteTokensByUser(transaction, userId) +} + +func (s SqlUserAccessTokenStore) deleteTokensByUser(transaction *gorp.Transaction, userId string) StoreResult { + result := StoreResult{} + + if _, err := transaction.Exec("DELETE FROM UserAccessTokens WHERE UserId = :UserId", map[string]interface{}{"UserId": userId}); err != nil { + result.Err = model.NewAppError("SqlUserAccessTokenStore.deleteTokensByUser", "store.sql_user_access_token.delete.app_error", nil, "", http.StatusInternalServerError) + } + + return result +} + +func (s SqlUserAccessTokenStore) Get(tokenId string) StoreChannel { + + storeChannel := make(StoreChannel, 1) + + go func() { + result := StoreResult{} + + token := model.UserAccessToken{} + + if err := s.GetReplica().SelectOne(&token, "SELECT * FROM UserAccessTokens WHERE Id = :Id", map[string]interface{}{"Id": tokenId}); err != nil { + if err == sql.ErrNoRows { + result.Err = model.NewAppError("SqlUserAccessTokenStore.Get", "store.sql_user_access_token.get.app_error", nil, err.Error(), http.StatusNotFound) + } else { + result.Err = model.NewAppError("SqlUserAccessTokenStore.Get", "store.sql_user_access_token.get.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + + result.Data = &token + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlUserAccessTokenStore) GetByToken(tokenString string) StoreChannel { + + storeChannel := make(StoreChannel, 1) + + go func() { + result := StoreResult{} + + token := model.UserAccessToken{} + + if err := s.GetReplica().SelectOne(&token, "SELECT * FROM UserAccessTokens WHERE Token = :Token", map[string]interface{}{"Token": tokenString}); err != nil { + if err == sql.ErrNoRows { + result.Err = model.NewAppError("SqlUserAccessTokenStore.GetByToken", "store.sql_user_access_token.get_by_token.app_error", nil, err.Error(), http.StatusNotFound) + } else { + result.Err = model.NewAppError("SqlUserAccessTokenStore.GetByToken", "store.sql_user_access_token.get_by_token.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + + result.Data = &token + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlUserAccessTokenStore) GetByUser(userId string, offset, limit int) StoreChannel { + + storeChannel := make(StoreChannel, 1) + + go func() { + result := StoreResult{} + + tokens := []*model.UserAccessToken{} + + if _, err := s.GetReplica().Select(&tokens, "SELECT * FROM UserAccessTokens WHERE UserId = :UserId LIMIT :Limit OFFSET :Offset", map[string]interface{}{"UserId": userId, "Offset": offset, "Limit": limit}); err != nil { + result.Err = model.NewAppError("SqlUserAccessTokenStore.GetByUser", "store.sql_user_access_token.get_by_user.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + result.Data = tokens + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_user_access_token_store_test.go b/store/sql_user_access_token_store_test.go new file mode 100644 index 000000000..db4424991 --- /dev/null +++ b/store/sql_user_access_token_store_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "testing" + + "github.com/mattermost/platform/model" +) + +func TestUserAccessTokenSaveGetDelete(t *testing.T) { + Setup() + + uat := &model.UserAccessToken{ + Token: model.NewId(), + UserId: model.NewId(), + Description: "testtoken", + } + + s1 := model.Session{} + s1.UserId = uat.UserId + s1.Token = uat.Token + + Must(store.Session().Save(&s1)) + + if result := <-store.UserAccessToken().Save(uat); result.Err != nil { + t.Fatal(result.Err) + } + + if result := <-store.UserAccessToken().Get(uat.Id); result.Err != nil { + t.Fatal(result.Err) + } else if received := result.Data.(*model.UserAccessToken); received.Token != uat.Token { + t.Fatal("received incorrect token after save") + } + + if result := <-store.UserAccessToken().GetByToken(uat.Token); result.Err != nil { + t.Fatal(result.Err) + } else if received := result.Data.(*model.UserAccessToken); received.Token != uat.Token { + t.Fatal("received incorrect token after save") + } + + if result := <-store.UserAccessToken().GetByToken("notarealtoken"); result.Err == nil { + t.Fatal("should have failed on bad token") + } + + if result := <-store.UserAccessToken().GetByUser(uat.UserId, 0, 100); result.Err != nil { + t.Fatal(result.Err) + } else if received := result.Data.([]*model.UserAccessToken); len(received) != 1 { + t.Fatal("received incorrect number of tokens after save") + } + + if result := <-store.UserAccessToken().Delete(uat.Id); result.Err != nil { + t.Fatal(result.Err) + } + + if err := (<-store.Session().Get(s1.Token)).Err; err == nil { + t.Fatal("should error - session should be deleted") + } + + if err := (<-store.UserAccessToken().GetByToken(s1.Token)).Err; err == nil { + t.Fatal("should error - access token should be deleted") + } + + s2 := model.Session{} + s2.UserId = uat.UserId + s2.Token = uat.Token + + Must(store.Session().Save(&s2)) + + if result := <-store.UserAccessToken().Save(uat); result.Err != nil { + t.Fatal(result.Err) + } + + if result := <-store.UserAccessToken().DeleteAllForUser(uat.UserId); result.Err != nil { + t.Fatal(result.Err) + } + + if err := (<-store.Session().Get(s2.Token)).Err; err == nil { + t.Fatal("should error - session should be deleted") + } + + if err := (<-store.UserAccessToken().GetByToken(s2.Token)).Err; err == nil { + t.Fatal("should error - access token should be deleted") + } +} diff --git a/store/store.go b/store/store.go index 6d84a0919..d883ea5a2 100644 --- a/store/store.go +++ b/store/store.go @@ -49,6 +49,7 @@ type Store interface { FileInfo() FileInfoStore Reaction() ReactionStore Job() JobStore + UserAccessToken() UserAccessTokenStore MarkSystemRanUnitTests() Close() DropAllTables() @@ -398,3 +399,12 @@ type JobStore interface { GetAllByStatus(status string) StoreChannel Delete(id string) StoreChannel } + +type UserAccessTokenStore interface { + Save(token *model.UserAccessToken) StoreChannel + Delete(tokenId string) StoreChannel + DeleteAllForUser(userId string) StoreChannel + Get(tokenId string) StoreChannel + GetByToken(tokenString string) StoreChannel + GetByUser(userId string, page, perPage int) StoreChannel +} diff --git a/utils/config.go b/utils/config.go index e008377d3..87f3d11f9 100644 --- a/utils/config.go +++ b/utils/config.go @@ -421,6 +421,7 @@ func getClientConfig(c *model.Config) map[string]string { props["EnableOnlyAdminIntegrations"] = strconv.FormatBool(*c.ServiceSettings.EnableOnlyAdminIntegrations) props["EnablePostUsernameOverride"] = strconv.FormatBool(c.ServiceSettings.EnablePostUsernameOverride) props["EnablePostIconOverride"] = strconv.FormatBool(c.ServiceSettings.EnablePostIconOverride) + props["EnableUserAccessTokens"] = strconv.FormatBool(*c.ServiceSettings.EnableUserAccessTokens) props["EnableLinkPreviews"] = strconv.FormatBool(*c.ServiceSettings.EnableLinkPreviews) props["EnableTesting"] = strconv.FormatBool(c.ServiceSettings.EnableTesting) props["EnableDeveloper"] = strconv.FormatBool(*c.ServiceSettings.EnableDeveloper) |