From c01d9ad6cf3f8bb2ad4145441816598d8ffa2d9e Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Mon, 30 Jan 2017 08:30:02 -0500 Subject: Implement APIv4 infrastructure (#5191) * Implement APIv4 infrastructure * Update parameter requirement functions per feedback --- api4/api.go | 189 ++++++++++++++++++++++++++++ api4/apitestlib.go | 211 +++++++++++++++++++++++++++++++ api4/context.go | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++++ api4/params.go | 61 +++++++++ api4/user.go | 169 +++++++++++++++++++++++++ api4/user_test.go | 186 +++++++++++++++++++++++++++ 6 files changed, 1176 insertions(+) create mode 100644 api4/api.go create mode 100644 api4/apitestlib.go create mode 100644 api4/context.go create mode 100644 api4/params.go create mode 100644 api4/user.go create mode 100644 api4/user_test.go (limited to 'api4') diff --git a/api4/api.go b/api4/api.go new file mode 100644 index 000000000..9314bb616 --- /dev/null +++ b/api4/api.go @@ -0,0 +1,189 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "net/http" + + l4g "github.com/alecthomas/log4go" + "github.com/gorilla/mux" + "github.com/mattermost/platform/app" + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" + + _ "github.com/nicksnyder/go-i18n/i18n" +) + +type Routes struct { + Root *mux.Router // '' + ApiRoot *mux.Router // 'api/v4' + + Users *mux.Router // 'api/v4/users' + User *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}' + UserByUsername *mux.Router // 'api/v4/users/username/{username:[A-Za-z0-9_-\.]+}' + UserByEmail *mux.Router // 'api/v4/users/email/{email}' + + Teams *mux.Router // 'api/v4/teams' + TeamsForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams' + Team *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}' + TeamByName *mux.Router // 'api/v4/teams/name/{team_name:[A-Za-z0-9_-]+}' + TeamMembers *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9_-]+}/members' + TeamMember *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9_-]+}/members/{user_id:[A-Za-z0-9_-]+}' + + Channels *mux.Router // 'api/v4/channels' + Channel *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}' + ChannelByName *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}/channels/name/{channel_name:[A-Za-z0-9_-]+}' + ChannelsForTeam *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}/channels' + ChannelMembers *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/members' + ChannelMember *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/members/{user_id:[A-Za-z0-9]+}' + ChannelMembersForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/channels/members' + + Posts *mux.Router // 'api/v4/posts' + Post *mux.Router // 'api/v4/posts/{post_id:[A-Za-z0-9]+}' + PostsForChannel *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/posts' + + Files *mux.Router // 'api/v4/files' + File *mux.Router // 'api/v4/files/{file_id:[A-Za-z0-9]+}' + + Commands *mux.Router // 'api/v4/commands' + Command *mux.Router // 'api/v4/commands/{command_id:[A-Za-z0-9]+}' + CommandsForTeam *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}/commands' + + Hooks *mux.Router // 'api/v4/teams/hooks' + IncomingHooks *mux.Router // 'api/v4/teams/hooks/incoming' + IncomingHook *mux.Router // 'api/v4/teams/hooks/incoming/{hook_id:[A-Za-z0-9]+}' + OutgoingHooks *mux.Router // 'api/v4/teams/hooks/outgoing' + OutgoingHook *mux.Router // 'api/v4/teams/hooks/outgoing/{hook_id:[A-Za-z0-9]+}' + + OAuth *mux.Router // 'api/v4/oauth' + + Admin *mux.Router // 'api/v4/admin' + + System *mux.Router // 'api/v4/system' + + Preferences *mux.Router // 'api/v4/preferences' + + License *mux.Router // 'api/v4/license' + + Public *mux.Router // 'api/v4/public' + + Emojis *mux.Router // 'api/v4/emoji' + Emoji *mux.Router // 'api/v4/emoji/{emoji_id:[A-Za-z0-9]+}' + + Webrtc *mux.Router // 'api/v4/webrtc' +} + +var BaseRoutes *Routes + +func InitRouter() { + app.Srv.Router = mux.NewRouter() + app.Srv.Router.NotFoundHandler = http.HandlerFunc(Handle404) + app.Srv.WebSocketRouter = app.NewWebSocketRouter() +} + +func InitApi(full bool) { + BaseRoutes = &Routes{} + BaseRoutes.Root = app.Srv.Router + BaseRoutes.ApiRoot = app.Srv.Router.PathPrefix(model.API_URL_SUFFIX).Subrouter() + + BaseRoutes.Users = BaseRoutes.ApiRoot.PathPrefix("/users").Subrouter() + BaseRoutes.User = BaseRoutes.Users.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter() + BaseRoutes.UserByUsername = BaseRoutes.Users.PathPrefix("/username/{username:[A-Za-z0-9_-.]+}").Subrouter() + BaseRoutes.UserByEmail = BaseRoutes.Users.PathPrefix("/email/{email}").Subrouter() + + BaseRoutes.Teams = BaseRoutes.ApiRoot.PathPrefix("/teams").Subrouter() + BaseRoutes.TeamsForUser = BaseRoutes.Users.PathPrefix("/teams").Subrouter() + BaseRoutes.Team = BaseRoutes.Teams.PathPrefix("/{team_id:[A-Za-z0-9]+}").Subrouter() + BaseRoutes.TeamByName = BaseRoutes.Teams.PathPrefix("/name/{team_name:[A-Za-z0-9_-]+}").Subrouter() + BaseRoutes.TeamMembers = BaseRoutes.Team.PathPrefix("/members").Subrouter() + BaseRoutes.TeamMember = BaseRoutes.TeamMembers.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter() + + BaseRoutes.Channels = BaseRoutes.ApiRoot.PathPrefix("/channels").Subrouter() + BaseRoutes.Channel = BaseRoutes.Channels.PathPrefix("/{channel_id:[A-Za-z0-9]+}").Subrouter() + BaseRoutes.ChannelByName = BaseRoutes.Team.PathPrefix("/name/{channel_name:[A-Za-z0-9_-]+}").Subrouter() + BaseRoutes.ChannelsForTeam = BaseRoutes.Team.PathPrefix("/channels").Subrouter() + BaseRoutes.ChannelMembers = BaseRoutes.Channel.PathPrefix("/members").Subrouter() + BaseRoutes.ChannelMember = BaseRoutes.ChannelMembers.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter() + BaseRoutes.ChannelMembersForUser = BaseRoutes.User.PathPrefix("/channels/members").Subrouter() + + BaseRoutes.Posts = BaseRoutes.ApiRoot.PathPrefix("/posts").Subrouter() + BaseRoutes.Post = BaseRoutes.Posts.PathPrefix("/{post_id:[A-Za-z0-9]+}").Subrouter() + BaseRoutes.PostsForChannel = BaseRoutes.Channel.PathPrefix("/posts").Subrouter() + + BaseRoutes.Files = BaseRoutes.ApiRoot.PathPrefix("/files").Subrouter() + BaseRoutes.File = BaseRoutes.Files.PathPrefix("/{file_id:[A-Za-z0-9]+}").Subrouter() + + BaseRoutes.Commands = BaseRoutes.ApiRoot.PathPrefix("/commands").Subrouter() + BaseRoutes.Command = BaseRoutes.Commands.PathPrefix("/{command_id:[A-Za-z0-9]+}").Subrouter() + BaseRoutes.CommandsForTeam = BaseRoutes.Team.PathPrefix("/commands").Subrouter() + + BaseRoutes.Hooks = BaseRoutes.ApiRoot.PathPrefix("/hooks").Subrouter() + BaseRoutes.IncomingHooks = BaseRoutes.Hooks.PathPrefix("/incoming").Subrouter() + BaseRoutes.IncomingHook = BaseRoutes.IncomingHooks.PathPrefix("/{hook_id:[A-Za-z0-9]+}").Subrouter() + BaseRoutes.OutgoingHooks = BaseRoutes.Hooks.PathPrefix("/outgoing").Subrouter() + BaseRoutes.OutgoingHook = BaseRoutes.OutgoingHooks.PathPrefix("/{hook_id:[A-Za-z0-9]+}").Subrouter() + + BaseRoutes.OAuth = BaseRoutes.ApiRoot.PathPrefix("/oauth").Subrouter() + BaseRoutes.Admin = BaseRoutes.ApiRoot.PathPrefix("/admin").Subrouter() + BaseRoutes.System = BaseRoutes.ApiRoot.PathPrefix("/system").Subrouter() + BaseRoutes.Preferences = BaseRoutes.ApiRoot.PathPrefix("/preferences").Subrouter() + BaseRoutes.License = BaseRoutes.ApiRoot.PathPrefix("/license").Subrouter() + BaseRoutes.Public = BaseRoutes.ApiRoot.PathPrefix("/public").Subrouter() + + BaseRoutes.Emojis = BaseRoutes.ApiRoot.PathPrefix("/emoji").Subrouter() + BaseRoutes.Emoji = BaseRoutes.Emojis.PathPrefix("/{emoji_id:[A-Za-z0-9]+}").Subrouter() + + BaseRoutes.Webrtc = BaseRoutes.ApiRoot.PathPrefix("/webrtc").Subrouter() + + InitUser() + + // REMOVE CONDITION WHEN APIv3 REMOVED + if full { + // 404 on any api route before web.go has a chance to serve it + app.Srv.Router.Handle("/api/{anything:.*}", http.HandlerFunc(Handle404)) + + utils.InitHTML() + + app.InitEmailBatching() + } +} + +func HandleEtag(etag string, routeName string, w http.ResponseWriter, r *http.Request) bool { + metrics := einterfaces.GetMetricsInterface() + if et := r.Header.Get(model.HEADER_ETAG_CLIENT); len(etag) > 0 { + if et == etag { + w.Header().Set(model.HEADER_ETAG_SERVER, etag) + w.WriteHeader(http.StatusNotModified) + if metrics != nil { + metrics.IncrementEtagHitCounter(routeName) + } + return true + } + } + + if metrics != nil { + metrics.IncrementEtagMissCounter(routeName) + } + + return false +} + +func Handle404(w http.ResponseWriter, r *http.Request) { + err := model.NewLocAppError("Handle404", "api.context.404.app_error", nil, "") + err.Translate(utils.T) + err.StatusCode = http.StatusNotFound + + l4g.Debug("%v: code=404 ip=%v", r.URL.Path, utils.GetIpAddress(r)) + + w.WriteHeader(err.StatusCode) + err.DetailedError = "There doesn't appear to be an api call for the url='" + r.URL.Path + "'." + w.Write([]byte(err.ToJson())) +} + +func ReturnStatusOK(w http.ResponseWriter) { + m := make(map[string]string) + m[model.STATUS] = model.STATUS_OK + w.Write([]byte(model.MapToJson(m))) +} diff --git a/api4/apitestlib.go b/api4/apitestlib.go new file mode 100644 index 000000000..05e8d76fe --- /dev/null +++ b/api4/apitestlib.go @@ -0,0 +1,211 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "net/http" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/mattermost/platform/app" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" + "github.com/mattermost/platform/utils" +) + +type TestHelper struct { + Client *model.Client4 + BasicUser *model.User + SystemAdminUser *model.User +} + +func Setup() *TestHelper { + if app.Srv == nil { + utils.TranslationsPreInit() + utils.LoadConfig("config.json") + utils.InitTranslations(utils.Cfg.LocalizationSettings) + utils.Cfg.TeamSettings.MaxUsersPerTeam = 50 + *utils.Cfg.RateLimitSettings.Enable = false + utils.Cfg.EmailSettings.SendEmailNotifications = true + utils.Cfg.EmailSettings.SMTPServer = "dockerhost" + utils.Cfg.EmailSettings.SMTPPort = "2500" + utils.Cfg.EmailSettings.FeedbackEmail = "test@example.com" + utils.DisableDebugLogForTest() + app.NewServer() + app.InitStores() + InitRouter() + app.StartServer() + InitApi(true) + utils.EnableDebugLogForTest() + app.Srv.Store.MarkSystemRanUnitTests() + + *utils.Cfg.TeamSettings.EnableOpenServer = true + } + + th := &TestHelper{} + th.Client = th.CreateClient() + return th +} + +func (me *TestHelper) InitBasic() *TestHelper { + me.BasicUser = me.CreateUser() + app.UpdateUserRoles(me.BasicUser.Id, model.ROLE_SYSTEM_USER.Id) + me.LoginBasic() + + return me +} + +func (me *TestHelper) InitSystemAdmin() *TestHelper { + me.SystemAdminUser = me.CreateUser() + app.UpdateUserRoles(me.SystemAdminUser.Id, model.ROLE_SYSTEM_USER.Id+" "+model.ROLE_SYSTEM_ADMIN.Id) + + return me +} + +func (me *TestHelper) CreateClient() *model.Client4 { + return model.NewAPIv4Client("http://localhost" + utils.Cfg.ServiceSettings.ListenAddress) +} + +func (me *TestHelper) CreateUser() *model.User { + id := model.NewId() + + user := &model.User{ + Email: GenerateTestEmail(), + Username: GenerateTestUsername(), + Nickname: "nn_" + id, + FirstName: "f_" + id, + LastName: "l_" + id, + Password: "Password1", + } + + utils.DisableDebugLogForTest() + ruser, _ := me.Client.CreateUser(user) + ruser.Password = "Password1" + VerifyUserEmail(ruser.Id) + utils.EnableDebugLogForTest() + return ruser +} + +func (me *TestHelper) LoginBasic() { + utils.DisableDebugLogForTest() + me.Client.Login(me.BasicUser.Email, me.BasicUser.Password) + utils.EnableDebugLogForTest() +} + +func (me *TestHelper) LoginSystemAdmin() { + utils.DisableDebugLogForTest() + me.Client.Login(me.SystemAdminUser.Email, me.SystemAdminUser.Password) + utils.EnableDebugLogForTest() +} + +func GenerateTestEmail() string { + return strings.ToLower("success+" + model.NewId() + "@simulator.amazonses.com") +} + +func GenerateTestUsername() string { + return "n" + model.NewId() +} + +func VerifyUserEmail(userId string) { + store.Must(app.Srv.Store.User().VerifyEmail(userId)) +} + +func CheckUserSanitization(t *testing.T, user *model.User) { + if user.Password != "" { + t.Fatal("password wasn't blank") + } + + if user.AuthData != nil && *user.AuthData != "" { + t.Fatal("auth data wasn't blank") + } + + if user.MfaSecret != "" { + t.Fatal("mfa secret wasn't blank") + } +} + +func CheckEtag(t *testing.T, data interface{}, resp *model.Response) { + if !reflect.ValueOf(data).IsNil() { + t.Fatal("etag data was not nil") + } + + if resp.StatusCode != http.StatusNotModified { + t.Log("actual: " + strconv.Itoa(resp.StatusCode)) + t.Log("expected: " + strconv.Itoa(http.StatusNotModified)) + t.Fatal("wrong status code for etag") + } +} + +func CheckNoError(t *testing.T, resp *model.Response) { + if resp.Error != nil { + t.Fatal(resp.Error) + } +} + +func CheckForbiddenStatus(t *testing.T, resp *model.Response) { + if resp.Error == nil { + t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusForbidden)) + return + } + + if resp.StatusCode != http.StatusForbidden { + t.Log("actual: " + strconv.Itoa(resp.StatusCode)) + t.Log("expected: " + strconv.Itoa(http.StatusForbidden)) + t.Fatal("wrong status code") + } +} + +func CheckUnauthorizedStatus(t *testing.T, resp *model.Response) { + if resp.Error == nil { + t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusUnauthorized)) + return + } + + if resp.StatusCode != http.StatusUnauthorized { + t.Log("actual: " + strconv.Itoa(resp.StatusCode)) + t.Log("expected: " + strconv.Itoa(http.StatusUnauthorized)) + t.Fatal("wrong status code") + } +} + +func CheckNotFoundStatus(t *testing.T, resp *model.Response) { + if resp.Error == nil { + t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusNotFound)) + return + } + + if resp.StatusCode != http.StatusNotFound { + t.Log("actual: " + strconv.Itoa(resp.StatusCode)) + t.Log("expected: " + strconv.Itoa(http.StatusNotFound)) + t.Fatal("wrong status code") + } +} + +func CheckBadRequestStatus(t *testing.T, resp *model.Response) { + if resp.Error == nil { + t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusBadRequest)) + return + } + + if resp.StatusCode != http.StatusBadRequest { + t.Log("actual: " + strconv.Itoa(resp.StatusCode)) + t.Log("expected: " + strconv.Itoa(http.StatusBadRequest)) + t.Fatal("wrong status code") + } +} + +func CheckErrorMessage(t *testing.T, resp *model.Response, message string) { + if resp.Error == nil { + t.Fatal("should have errored with message:" + message) + return + } + + if resp.Error.Message != message { + t.Log("actual: " + resp.Error.Message) + t.Log("expected: " + message) + t.Fatal("incorrect error message") + } +} diff --git a/api4/context.go b/api4/context.go new file mode 100644 index 000000000..1a3011795 --- /dev/null +++ b/api4/context.go @@ -0,0 +1,360 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "fmt" + "net/http" + "strings" + "time" + + l4g "github.com/alecthomas/log4go" + goi18n "github.com/nicksnyder/go-i18n/i18n" + + "github.com/mattermost/platform/app" + "github.com/mattermost/platform/einterfaces" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +type Context struct { + Session model.Session + Params *ApiParams + Err *model.AppError + T goi18n.TranslateFunc + RequestId string + IpAddress string + Path string + siteURL string +} + +func ApiHandler(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { + return &handler{ + handleFunc: h, + requireSession: false, + trustRequester: false, + requireMfa: false, + } +} + +func ApiSessionRequired(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { + return &handler{ + handleFunc: h, + requireSession: true, + trustRequester: false, + requireMfa: true, + } +} + +func ApiSessionRequiredMfa(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { + return &handler{ + handleFunc: h, + requireSession: true, + trustRequester: false, + requireMfa: false, + } +} + +func ApiHandlerTrustRequester(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { + return &handler{ + handleFunc: h, + requireSession: false, + trustRequester: true, + requireMfa: false, + } +} + +func ApiSessionRequiredTrustRequester(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { + return &handler{ + handleFunc: h, + requireSession: true, + trustRequester: true, + requireMfa: true, + } +} + +type handler struct { + handleFunc func(*Context, http.ResponseWriter, *http.Request) + requireSession bool + trustRequester bool + requireMfa bool +} + +func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + now := time.Now() + l4g.Debug("%v - %v", r.Method, r.URL.Path) + + c := &Context{} + c.T, _ = utils.GetTranslationsAndLocale(w, r) + c.RequestId = model.NewId() + c.IpAddress = utils.GetIpAddress(r) + c.Params = ApiParamsFromRequest(r) + + token := "" + isTokenFromQueryString := false + + // Attempt to parse token out of the header + authHeader := r.Header.Get(model.HEADER_AUTH) + if len(authHeader) > 6 && strings.ToUpper(authHeader[0:6]) == model.HEADER_BEARER { + // Default session token + token = authHeader[7:] + + } else if len(authHeader) > 5 && strings.ToLower(authHeader[0:5]) == model.HEADER_TOKEN { + // OAuth token + token = authHeader[6:] + } + + // Attempt to parse the token from the cookie + if len(token) == 0 { + if cookie, err := r.Cookie(model.SESSION_COOKIE_TOKEN); err == nil { + token = cookie.Value + + if h.requireSession && !h.trustRequester { + if r.Header.Get(model.HEADER_REQUESTED_WITH) != model.HEADER_REQUESTED_WITH_XML { + c.Err = model.NewLocAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token+" Appears to be a CSRF attempt") + token = "" + } + } + } + } + + // Attempt to parse token out of the query string + if len(token) == 0 { + token = r.URL.Query().Get("access_token") + isTokenFromQueryString = true + } + + if utils.GetSiteURL() == "" { + protocol := app.GetProtocol(r) + c.SetSiteURL(protocol + "://" + r.Host) + } + + w.Header().Set(model.HEADER_REQUEST_ID, c.RequestId) + w.Header().Set(model.HEADER_VERSION_ID, fmt.Sprintf("%v.%v.%v", model.CurrentVersion, model.BuildNumber, utils.CfgHash)) + if einterfaces.GetClusterInterface() != nil { + w.Header().Set(model.HEADER_CLUSTER_ID, einterfaces.GetClusterInterface().GetClusterId()) + } + + w.Header().Set("Content-Type", "application/json") + + if r.Method == "GET" { + w.Header().Set("Expires", "0") + } + + if len(token) != 0 { + session, err := app.GetSession(token) + + if err != nil { + l4g.Error(utils.T("api.context.invalid_session.error"), err.Error()) + c.RemoveSessionCookie(w, r) + if h.requireSession { + c.Err = model.NewLocAppError("ServeHTTP", "api.context.session_expired.app_error", nil, "token="+token) + c.Err.StatusCode = http.StatusUnauthorized + } + } else if !session.IsOAuth && isTokenFromQueryString { + c.Err = model.NewLocAppError("ServeHTTP", "api.context.token_provided.app_error", nil, "token="+token) + c.Err.StatusCode = http.StatusUnauthorized + } else { + c.Session = *session + } + } + + c.Path = r.URL.Path + + if c.Err == nil && h.requireSession { + c.SessionRequired() + } + + if c.Err == nil && h.requireMfa { + c.MfaRequired() + } + + if c.Err == nil { + h.handleFunc(c, w, r) + } + + // Handle errors that have occured + if c.Err != nil { + c.Err.Translate(c.T) + c.Err.RequestId = c.RequestId + c.LogError(c.Err) + c.Err.Where = r.URL.Path + + // Block out detailed error when not in developer mode + if !*utils.Cfg.ServiceSettings.EnableDeveloper { + c.Err.DetailedError = "" + } + + w.WriteHeader(c.Err.StatusCode) + w.Write([]byte(c.Err.ToJson())) + + if einterfaces.GetMetricsInterface() != nil { + einterfaces.GetMetricsInterface().IncrementHttpError() + } + } + + if einterfaces.GetMetricsInterface() != nil { + einterfaces.GetMetricsInterface().IncrementHttpRequest() + + if r.URL.Path != model.API_URL_SUFFIX+"/users/websocket" { + elapsed := float64(time.Since(now)) / float64(time.Second) + einterfaces.GetMetricsInterface().ObserveHttpRequestDuration(elapsed) + } + } +} + +func (c *Context) LogAudit(extraInfo string) { + audit := &model.Audit{UserId: c.Session.UserId, IpAddress: c.IpAddress, Action: c.Path, ExtraInfo: extraInfo, SessionId: c.Session.Id} + if r := <-app.Srv.Store.Audit().Save(audit); r.Err != nil { + c.LogError(r.Err) + } +} + +func (c *Context) LogAuditWithUserId(userId, extraInfo string) { + + if len(c.Session.UserId) > 0 { + extraInfo = strings.TrimSpace(extraInfo + " session_user=" + c.Session.UserId) + } + + audit := &model.Audit{UserId: userId, IpAddress: c.IpAddress, Action: c.Path, ExtraInfo: extraInfo, SessionId: c.Session.Id} + if r := <-app.Srv.Store.Audit().Save(audit); r.Err != nil { + c.LogError(r.Err) + } +} + +func (c *Context) LogError(err *model.AppError) { + + // filter out endless reconnects + if c.Path == "/api/v3/users/websocket" && err.StatusCode == 401 || err.Id == "web.check_browser_compatibility.app_error" { + c.LogDebug(err) + } else { + l4g.Error(utils.T("api.context.log.error"), c.Path, err.Where, err.StatusCode, + c.RequestId, c.Session.UserId, c.IpAddress, err.SystemMessage(utils.T), err.DetailedError) + } +} + +func (c *Context) LogDebug(err *model.AppError) { + l4g.Debug(utils.T("api.context.log.error"), c.Path, err.Where, err.StatusCode, + c.RequestId, c.Session.UserId, c.IpAddress, err.SystemMessage(utils.T), err.DetailedError) +} + +func (c *Context) IsSystemAdmin() bool { + return app.SessionHasPermissionTo(c.Session, model.PERMISSION_MANAGE_SYSTEM) +} + +func (c *Context) SessionRequired() { + if len(c.Session.UserId) == 0 { + c.Err = model.NewLocAppError("", "api.context.session_expired.app_error", nil, "UserRequired") + c.Err.StatusCode = http.StatusUnauthorized + return + } +} + +func (c *Context) MfaRequired() { + // Must be licensed for MFA and have it configured for enforcement + if !utils.IsLicensed || !*utils.License.Features.MFA || !*utils.Cfg.ServiceSettings.EnableMultifactorAuthentication || !*utils.Cfg.ServiceSettings.EnforceMultifactorAuthentication { + return + } + + // OAuth integrations are excepted + if c.Session.IsOAuth { + return + } + + if user, err := app.GetUser(c.Session.UserId); err != nil { + c.Err = model.NewLocAppError("", "api.context.session_expired.app_error", nil, "MfaRequired") + c.Err.StatusCode = http.StatusUnauthorized + return + } else { + // Only required for email and ldap accounts + if user.AuthService != "" && + user.AuthService != model.USER_AUTH_SERVICE_EMAIL && + user.AuthService != model.USER_AUTH_SERVICE_LDAP { + return + } + + if !user.MfaActive { + c.Err = model.NewLocAppError("", "api.context.mfa_required.app_error", nil, "MfaRequired") + c.Err.StatusCode = http.StatusUnauthorized + return + } + } +} + +func (c *Context) RemoveSessionCookie(w http.ResponseWriter, r *http.Request) { + cookie := &http.Cookie{ + Name: model.SESSION_COOKIE_TOKEN, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + } + + http.SetCookie(w, cookie) +} + +func (c *Context) SetInvalidParam(parameter string) { + c.Err = NewInvalidParamError(parameter) +} + +func (c *Context) SetInvalidUrlParam(parameter string) { + c.Err = NewInvalidUrlParamError(parameter) +} + +func NewInvalidParamError(parameter string) *model.AppError { + err := model.NewLocAppError("Context", "api.context.invalid_body_param.app_error", map[string]interface{}{"Name": parameter}, "") + err.StatusCode = http.StatusBadRequest + return err +} +func NewInvalidUrlParamError(parameter string) *model.AppError { + err := model.NewLocAppError("Context", "api.context.invalid_url_param.app_error", map[string]interface{}{"Name": parameter}, "") + err.StatusCode = http.StatusBadRequest + return err +} + +func (c *Context) SetPermissionError(permission *model.Permission) { + c.Err = model.NewLocAppError("Permissions", "api.context.permissions.app_error", nil, "userId="+c.Session.UserId+", "+"permission="+permission.Id) + c.Err.StatusCode = http.StatusForbidden +} + +func (c *Context) SetSiteURL(url string) { + c.siteURL = strings.TrimRight(url, "/") +} + +func (c *Context) GetSiteURL() string { + return c.siteURL +} + +func (c *Context) RequireUserId() *Context { + if c.Err != nil { + return c + } + + if len(c.Params.UserId) != 26 { + c.SetInvalidUrlParam("user_id") + } + return c +} + +func (c *Context) RequireTeamId() *Context { + if c.Err != nil { + return c + } + + if len(c.Params.TeamId) != 26 { + c.SetInvalidUrlParam("team_id") + } + return c +} + +func (c *Context) RequireChannelId() *Context { + if c.Err != nil { + return c + } + + if len(c.Params.ChannelId) != 26 { + c.SetInvalidUrlParam("channel_id") + } + return c +} diff --git a/api4/params.go b/api4/params.go new file mode 100644 index 000000000..452b9ba21 --- /dev/null +++ b/api4/params.go @@ -0,0 +1,61 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +type ApiParams struct { + UserId string + TeamId string + ChannelId string + PostId string + FileId string + CommandId string + HookId string + EmojiId string +} + +func ApiParamsFromRequest(r *http.Request) *ApiParams { + params := &ApiParams{} + + props := mux.Vars(r) + + if val, ok := props["user_id"]; ok { + params.UserId = val + } + + if val, ok := props["team_id"]; ok { + params.TeamId = val + } + + if val, ok := props["channel_id"]; ok { + params.ChannelId = val + } + + if val, ok := props["post_id"]; ok { + params.PostId = val + } + + if val, ok := props["file_id"]; ok { + params.FileId = val + } + + if val, ok := props["command_id"]; ok { + params.CommandId = val + } + + if val, ok := props["hook_id"]; ok { + params.HookId = val + } + + if val, ok := props["emoji_id"]; ok { + params.EmojiId = val + } + + return params +} diff --git a/api4/user.go b/api4/user.go new file mode 100644 index 000000000..9d38df1a1 --- /dev/null +++ b/api4/user.go @@ -0,0 +1,169 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "net/http" + + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/app" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +func InitUser() { + l4g.Debug(utils.T("api.user.init.debug")) + + BaseRoutes.Users.Handle("", ApiHandler(createUser)).Methods("POST") + BaseRoutes.User.Handle("", ApiSessionRequired(getUser)).Methods("GET") + BaseRoutes.User.Handle("", ApiSessionRequired(updateUser)).Methods("PUT") + + BaseRoutes.Users.Handle("/login", ApiHandler(login)).Methods("POST") + BaseRoutes.Users.Handle("/logout", ApiHandler(logout)).Methods("POST") + +} + +func createUser(c *Context, w http.ResponseWriter, r *http.Request) { + user := model.UserFromJson(r.Body) + if user == nil { + c.SetInvalidParam("user") + return + } + + hash := r.URL.Query().Get("h") + inviteId := r.URL.Query().Get("iid") + + // No permission check required + + var ruser *model.User + var err *model.AppError + if len(hash) > 0 { + ruser, err = app.CreateUserWithHash(user, hash, r.URL.Query().Get("d")) + } else if len(inviteId) > 0 { + ruser, err = app.CreateUserWithInviteId(user, inviteId, c.GetSiteURL()) + } else { + ruser, err = app.CreateUserFromSignup(user, c.GetSiteURL()) + } + + if err != nil { + c.Err = err + return + } + + w.WriteHeader(http.StatusCreated) + w.Write([]byte(ruser.ToJson())) +} + +func getUser(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId() + if c.Err != nil { + return + } + + // No permission check required + + var user *model.User + var err *model.AppError + + if user, err = app.GetUser(c.Params.UserId); err != nil { + c.Err = err + return + } + + etag := user.Etag(utils.Cfg.PrivacySettings.ShowFullName, utils.Cfg.PrivacySettings.ShowEmailAddress) + + if HandleEtag(etag, "Get User", w, r) { + return + } else { + app.SanitizeProfile(user, c.IsSystemAdmin()) + w.Header().Set(model.HEADER_ETAG_SERVER, etag) + w.Write([]byte(user.ToJson())) + return + } +} + +func updateUser(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId() + if c.Err != nil { + return + } + + user := model.UserFromJson(r.Body) + if user == nil { + c.SetInvalidParam("user") + return + } + + if !app.SessionHasPermissionToUser(c.Session, user.Id) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + + if ruser, err := app.UpdateUserAsUser(user, c.GetSiteURL(), c.IsSystemAdmin()); err != nil { + c.Err = err + return + } else { + c.LogAudit("") + w.Write([]byte(ruser.ToJson())) + } +} + +func login(c *Context, w http.ResponseWriter, r *http.Request) { + props := model.MapFromJson(r.Body) + + id := props["id"] + loginId := props["login_id"] + password := props["password"] + mfaToken := props["token"] + deviceId := props["device_id"] + ldapOnly := props["ldap_only"] == "true" + + c.LogAuditWithUserId(id, "attempt - login_id="+loginId) + user, err := app.AuthenticateUserForLogin(id, loginId, password, mfaToken, deviceId, ldapOnly) + if err != nil { + c.LogAuditWithUserId(id, "failure - login_id="+loginId) + c.Err = err + return + } + + c.LogAuditWithUserId(user.Id, "authenticated") + + var session *model.Session + session, err = app.DoLogin(w, r, user, deviceId) + if err != nil { + c.Err = err + return + } + + c.LogAuditWithUserId(user.Id, "success") + + c.Session = *session + + user.Sanitize(map[string]bool{}) + + w.Write([]byte(user.ToJson())) +} + +func logout(c *Context, w http.ResponseWriter, r *http.Request) { + data := make(map[string]string) + data["user_id"] = c.Session.UserId + + Logout(c, w, r) + if c.Err == nil { + w.Write([]byte(model.MapToJson(data))) + } +} + +func Logout(c *Context, w http.ResponseWriter, r *http.Request) { + c.LogAudit("") + c.RemoveSessionCookie(w, r) + if c.Session.Id != "" { + if err := app.RevokeSessionById(c.Session.Id); err != nil { + c.Err = err + return + } + } + + ReturnStatusOK(w) +} diff --git a/api4/user_test.go b/api4/user_test.go new file mode 100644 index 000000000..d643f4e3a --- /dev/null +++ b/api4/user_test.go @@ -0,0 +1,186 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "net/http" + "strconv" + "testing" + + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" +) + +func TestCreateUser(t *testing.T) { + th := Setup() + Client := th.Client + + user := model.User{Email: GenerateTestEmail(), Nickname: "Corey Hulen", Password: "hello1", Username: GenerateTestUsername(), Roles: model.ROLE_SYSTEM_ADMIN.Id + " " + model.ROLE_SYSTEM_USER.Id} + + ruser, resp := Client.CreateUser(&user) + CheckNoError(t, resp) + + Client.Login(user.Email, user.Password) + + if ruser.Nickname != user.Nickname { + t.Fatal("nickname didn't match") + } + + if ruser.Roles != model.ROLE_SYSTEM_USER.Id { + t.Fatal("did not clear roles") + } + + CheckUserSanitization(t, ruser) + + _, resp = Client.CreateUser(ruser) + CheckBadRequestStatus(t, resp) + + ruser.Id = "" + ruser.Username = GenerateTestUsername() + ruser.Password = "passwd1" + _, resp = Client.CreateUser(ruser) + CheckErrorMessage(t, resp, "An account with that email already exists.") + CheckBadRequestStatus(t, resp) + + ruser.Email = GenerateTestEmail() + ruser.Username = user.Username + _, resp = Client.CreateUser(ruser) + CheckErrorMessage(t, resp, "An account with that username already exists.") + CheckBadRequestStatus(t, resp) + + ruser.Email = "" + _, resp = Client.CreateUser(ruser) + CheckErrorMessage(t, resp, "Invalid email") + CheckBadRequestStatus(t, resp) + + if r, err := Client.DoApiPost("/users", "garbage"); err == nil { + t.Fatal("should have errored") + } else { + if r.StatusCode != http.StatusBadRequest { + t.Log("actual: " + strconv.Itoa(r.StatusCode)) + t.Log("expected: " + strconv.Itoa(http.StatusBadRequest)) + t.Fatal("wrong status code") + } + } +} + +func TestGetUser(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + Client := th.Client + + user := th.CreateUser() + + ruser, resp := Client.GetUser(user.Id, "") + CheckNoError(t, resp) + CheckUserSanitization(t, ruser) + + if ruser.Email != user.Email { + t.Fatal("emails did not match") + } + + ruser, resp = Client.GetUser(user.Id, resp.Etag) + CheckEtag(t, ruser, resp) + + _, resp = Client.GetUser("junk", "") + CheckBadRequestStatus(t, resp) + + _, resp = Client.GetUser(model.NewId(), "") + CheckNotFoundStatus(t, resp) + + // Check against privacy config settings + emailPrivacy := utils.Cfg.PrivacySettings.ShowEmailAddress + namePrivacy := utils.Cfg.PrivacySettings.ShowFullName + defer func() { + utils.Cfg.PrivacySettings.ShowEmailAddress = emailPrivacy + utils.Cfg.PrivacySettings.ShowFullName = namePrivacy + }() + utils.Cfg.PrivacySettings.ShowEmailAddress = false + utils.Cfg.PrivacySettings.ShowFullName = false + + ruser, resp = Client.GetUser(user.Id, "") + CheckNoError(t, resp) + + if ruser.Email != "" { + t.Fatal("email should be blank") + } + if ruser.FirstName != "" { + t.Fatal("first name should be blank") + } + if ruser.LastName != "" { + t.Fatal("last name should be blank") + } + + Client.Logout() + _, resp = Client.GetUser(user.Id, "") + CheckUnauthorizedStatus(t, resp) + + // System admins should ignore privacy settings + th.LoginSystemAdmin() + ruser, resp = Client.GetUser(user.Id, resp.Etag) + if ruser.Email == "" { + t.Fatal("email should not be blank") + } + if ruser.FirstName == "" { + t.Fatal("first name should not be blank") + } + if ruser.LastName == "" { + t.Fatal("last name should not be blank") + } +} + +func TestUpdateUser(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + Client := th.Client + + user := th.CreateUser() + Client.Login(user.Email, user.Password) + + user.Nickname = "Joram Wilander" + user.Roles = model.ROLE_SYSTEM_ADMIN.Id + user.LastPasswordUpdate = 123 + + ruser, resp := Client.UpdateUser(user) + CheckNoError(t, resp) + CheckUserSanitization(t, ruser) + + if ruser.Nickname != "Joram Wilander" { + t.Fatal("Nickname did not update properly") + } + if ruser.Roles != model.ROLE_SYSTEM_USER.Id { + t.Fatal("Roles should not have updated") + } + if ruser.LastPasswordUpdate == 123 { + t.Fatal("LastPasswordUpdate should not have updated") + } + + ruser.Id = "junk" + _, resp = Client.UpdateUser(ruser) + CheckBadRequestStatus(t, resp) + + ruser.Id = model.NewId() + _, resp = Client.UpdateUser(ruser) + CheckForbiddenStatus(t, resp) + + if r, err := Client.DoApiPut("/users/"+ruser.Id, "garbage"); err == nil { + t.Fatal("should have errored") + } else { + if r.StatusCode != http.StatusBadRequest { + t.Log("actual: " + strconv.Itoa(r.StatusCode)) + t.Log("expected: " + strconv.Itoa(http.StatusBadRequest)) + t.Fatal("wrong status code") + } + } + + Client.Logout() + _, resp = Client.UpdateUser(user) + CheckUnauthorizedStatus(t, resp) + + th.LoginBasic() + _, resp = Client.UpdateUser(user) + CheckForbiddenStatus(t, resp) + + th.LoginSystemAdmin() + _, resp = Client.UpdateUser(user) + CheckNoError(t, resp) +} -- cgit v1.2.3-1-g7c22