From 3099128bbd4faa1cdcf716182e2cc43c294badfd Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Mon, 23 Nov 2020 14:34:10 -0500 Subject: [PATCH] [MM-29845] Add a new handler to allow authentication via CWS API Key (#16319) * Add a new handler to allow authentication via CWS API Key * Make error better * Add tests and cases for new handler functions * Move some code around * Add test for GetCloudSession function * unset the env after test completion * Remove white space * Change Info to Warn Co-authored-by: Mattermod --- api4/handlers.go | 20 +++++++++++++++++ app/app_iface.go | 1 + app/authentication.go | 7 ++++++ app/authentication_test.go | 9 +++++--- app/opentracing/opentracing_layer.go | 22 +++++++++++++++++++ app/session.go | 16 ++++++++++++++ app/session_test.go | 33 ++++++++++++++++++++++++++++ model/client4.go | 1 + model/session.go | 1 + web/context.go | 7 ++++++ web/context_test.go | 15 +++++++++++++ web/handlers.go | 16 +++++++++++++- 12 files changed, 144 insertions(+), 4 deletions(-) diff --git a/api4/handlers.go b/api4/handlers.go index 736c00ffde..f42ed676c0 100644 --- a/api4/handlers.go +++ b/api4/handlers.go @@ -51,6 +51,26 @@ func (api *API) ApiSessionRequired(h func(*Context, http.ResponseWriter, *http.R } +// CloudApiKeyRequired provides a handler for webhook endpoints to access Cloud installations from CWS +func (api *API) CloudApiKeyRequired(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { + handler := &web.Handler{ + GetGlobalAppOptions: api.GetGlobalAppOptions, + HandleFunc: h, + HandlerName: web.GetHandlerName(h), + RequireSession: false, + RequireCloudKey: true, + TrustRequester: false, + RequireMfa: false, + IsStatic: false, + IsLocal: false, + } + if *api.ConfigService.Config().ServiceSettings.WebserverMode == "gzip" { + return gziphandler.GzipHandler(handler) + } + return handler + +} + // ApiSessionRequiredMfa provides a handler for API endpoints which require a logged-in user session but when accessed, // if MFA is enabled, the MFA process is not yet complete, and therefore the requirement to have completed the MFA // authentication must be waived. diff --git a/app/app_iface.go b/app/app_iface.go index 617d8bd1e9..eba57937ea 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -540,6 +540,7 @@ type AppIface interface { GetChannelsForSchemePage(scheme *model.Scheme, page int, perPage int) (model.ChannelList, *model.AppError) GetChannelsForUser(teamId string, userId string, includeDeleted bool, lastDeleteAt int) (*model.ChannelList, *model.AppError) GetChannelsUserNotIn(teamId string, userId string, offset int, limit int) (*model.ChannelList, *model.AppError) + GetCloudSession(token string) (*model.Session, *model.AppError) GetClusterId() string GetClusterStatus() []*model.ClusterInfo GetCommand(commandId string) (*model.Command, *model.AppError) diff --git a/app/authentication.go b/app/authentication.go index 0f1c933cbd..a4bb62627a 100644 --- a/app/authentication.go +++ b/app/authentication.go @@ -19,6 +19,7 @@ const ( TokenLocationHeader TokenLocationCookie TokenLocationQueryString + TokenLocationCloudHeader ) func (tl TokenLocation) String() string { @@ -31,6 +32,8 @@ func (tl TokenLocation) String() string { return "Cookie" case TokenLocationQueryString: return "QueryString" + case TokenLocationCloudHeader: + return "CloudHeader" default: return "Unknown" } @@ -281,5 +284,9 @@ func ParseAuthTokenFromRequest(r *http.Request) (string, TokenLocation) { return token, TokenLocationQueryString } + if token := r.Header.Get(model.HEADER_CLOUD_TOKEN); token != "" { + return token, TokenLocationCloudHeader + } + return "", TokenLocationNotFound } diff --git a/app/authentication_test.go b/app/authentication_test.go index 459ec18f96..a3aa8eac81 100644 --- a/app/authentication_test.go +++ b/app/authentication_test.go @@ -26,6 +26,7 @@ func TestParseAuthTokenFromRequest(t *testing.T) { {"BEARER mytoken", "", "", "mytoken", TokenLocationHeader}, {"", "mytoken", "", "mytoken", TokenLocationCookie}, {"", "", "mytoken", "mytoken", TokenLocationQueryString}, + {"mytoken", "", "", "mytoken", TokenLocationCloudHeader}, } for testnum, tc := range cases { @@ -34,10 +35,12 @@ func TestParseAuthTokenFromRequest(t *testing.T) { pathname += "?access_token=" + tc.query } req := httptest.NewRequest("GET", pathname, nil) - if tc.header != "" { + switch tc.expectedLocation { + case TokenLocationHeader: req.Header.Add(model.HEADER_AUTH, tc.header) - } - if tc.cookie != "" { + case TokenLocationCloudHeader: + req.Header.Add(model.HEADER_CLOUD_TOKEN, tc.header) + case TokenLocationCookie: req.AddCookie(&http.Cookie{ Name: model.SESSION_COOKIE_TOKEN, Value: tc.cookie, diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index cfa37f3d93..5607e23a25 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -4862,6 +4862,28 @@ func (a *OpenTracingAppLayer) GetChannelsUserNotIn(teamId string, userId string, return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetCloudSession(token string) (*model.Session, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetCloudSession") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetCloudSession(token) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetClusterId() string { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetClusterId") diff --git a/app/session.go b/app/session.go index 48e127172f..fdd55e4927 100644 --- a/app/session.go +++ b/app/session.go @@ -7,6 +7,7 @@ import ( "errors" "math" "net/http" + "os" "time" "github.com/mattermost/mattermost-server/v5/audit" @@ -34,6 +35,21 @@ func (a *App) CreateSession(session *model.Session) (*model.Session, *model.AppE return session, nil } +func (a *App) GetCloudSession(token string) (*model.Session, *model.AppError) { + apiKey := os.Getenv("MM_CLOUD_API_KEY") + if apiKey != "" && apiKey == token { + // Need a bare-bones session object for later checks + session := &model.Session{ + Token: token, + IsOAuth: false, + } + + session.AddProp(model.SESSION_PROP_TYPE, model.SESSION_TYPE_CLOUD_KEY) + return session, nil + } + return nil, model.NewAppError("GetCloudSession", "api.context.invalid_token.error", map[string]interface{}{"Token": token, "Error": ""}, "The provided token is invalid", http.StatusUnauthorized) +} + func (a *App) GetSession(token string) (*model.Session, *model.AppError) { metrics := a.Metrics() diff --git a/app/session_test.go b/app/session_test.go index d4d819fbac..b174e39b19 100644 --- a/app/session_test.go +++ b/app/session_test.go @@ -5,6 +5,7 @@ package app import ( "fmt" + "os" "testing" "time" @@ -404,3 +405,35 @@ func TestApp_SetSessionExpireInDays(t *testing.T) { }) } } + +func TestGetCloudSession(t *testing.T) { + th := Setup(t) + defer func() { + os.Unsetenv("MM_CLOUD_API_KEY") + th.TearDown() + }() + + t.Run("Matching environment variable and token should return non-nil session", func(t *testing.T) { + os.Setenv("MM_CLOUD_API_KEY", "mytoken") + session, err := th.App.GetCloudSession("mytoken") + require.Nil(t, err) + require.NotNil(t, session) + require.Equal(t, "mytoken", session.Token) + }) + + t.Run("Empty environment variable should return error", func(t *testing.T) { + os.Setenv("MM_CLOUD_API_KEY", "") + session, err := th.App.GetCloudSession("mytoken") + require.Nil(t, session) + require.NotNil(t, err) + require.Equal(t, "api.context.invalid_token.error", err.Id) + }) + + t.Run("Mismatched env variable and token should return error", func(t *testing.T) { + os.Setenv("MM_CLOUD_API_KEY", "mytoken") + session, err := th.App.GetCloudSession("myincorrecttoken") + require.Nil(t, session) + require.NotNil(t, err) + require.Equal(t, "api.context.invalid_token.error", err.Id) + }) +} diff --git a/model/client4.go b/model/client4.go index 8ebf0da162..a6b1f52eb8 100644 --- a/model/client4.go +++ b/model/client4.go @@ -31,6 +31,7 @@ const ( HEADER_CSRF_TOKEN = "X-CSRF-Token" HEADER_BEARER = "BEARER" HEADER_AUTH = "Authorization" + HEADER_CLOUD_TOKEN = "X-Cloud-Token" HEADER_REQUESTED_WITH = "X-Requested-With" HEADER_REQUESTED_WITH_XML = "XMLHttpRequest" STATUS = "status" diff --git a/model/session.go b/model/session.go index d288e66664..a0e8b443a8 100644 --- a/model/session.go +++ b/model/session.go @@ -25,6 +25,7 @@ const ( SESSION_PROP_IS_BOT = "is_bot" SESSION_PROP_IS_BOT_VALUE = "true" SESSION_TYPE_USER_ACCESS_TOKEN = "UserAccessToken" + SESSION_TYPE_CLOUD_KEY = "CloudKey" SESSION_PROP_IS_GUEST = "is_guest" SESSION_ACTIVITY_TIMEOUT = 1000 * 60 * 5 // 5 minutes SESSION_USER_ACCESS_TOKEN_EXPIRY = 100 * 365 // 100 years diff --git a/web/context.go b/web/context.go index 28108e8b8c..74d6907bb1 100644 --- a/web/context.go +++ b/web/context.go @@ -143,6 +143,13 @@ func (c *Context) SessionRequired() { } } +func (c *Context) CloudKeyRequired() { + if license := c.App.Srv().License(); license == nil || !*license.Features.Cloud || c.App.Session().Props[model.SESSION_PROP_TYPE] != model.SESSION_TYPE_CLOUD_KEY { + c.Err = model.NewAppError("", "api.context.session_expired.app_error", nil, "TokenRequired", http.StatusUnauthorized) + return + } +} + func (c *Context) MfaRequired() { // Must be licensed for MFA and have it configured for enforcement if license := c.App.Srv().License(); license == nil || !*license.Features.MFA || !*c.App.Config().ServiceSettings.EnableMultifactorAuthentication || !*c.App.Config().ServiceSettings.EnforceMultifactorAuthentication { diff --git a/web/context_test.go b/web/context_test.go index 6c27fc1b60..3262f4dc0f 100644 --- a/web/context_test.go +++ b/web/context_test.go @@ -32,6 +32,21 @@ func TestRequireHookId(t *testing.T) { }) } +func TestCloudKeyRequired(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + c := &Context{ + App: th.App, + } + + c.CloudKeyRequired() + + assert.Equal(t, c.Err.Id, "api.context.session_expired.app_error") +} + func TestMfaRequired(t *testing.T) { th := SetupWithStoreMock(t) defer th.TearDown() diff --git a/web/handlers.go b/web/handlers.go index e18a777ead..af8fa69e76 100644 --- a/web/handlers.go +++ b/web/handlers.go @@ -72,6 +72,7 @@ type Handler struct { HandleFunc func(*Context, http.ResponseWriter, *http.Request) HandlerName string RequireSession bool + RequireCloudKey bool TrustRequester bool RequireMfa bool IsStatic bool @@ -187,7 +188,7 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { token, tokenLocation := app.ParseAuthTokenFromRequest(r) - if len(token) != 0 { + if len(token) != 0 && tokenLocation != app.TokenLocationCloudHeader { session, err := c.App.GetSession(token) if err != nil { c.Log.Info("Invalid session", mlog.Err(err)) @@ -209,6 +210,15 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } h.checkCSRFToken(c, r, token, tokenLocation, session) + } else if len(token) != 0 && c.App.Srv().License() != nil && *c.App.Srv().License().Features.Cloud && tokenLocation == app.TokenLocationCloudHeader { + // Check to see if this provided token matches our CWS Token + session, err := c.App.GetCloudSession(token) + if err != nil { + c.Log.Warn("Invalid CWS token", mlog.Err(err)) + c.Err = err + } else { + c.App.SetSession(session) + } } c.Log = c.App.Log().With( @@ -231,6 +241,10 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.SetServerBusyError() } + if c.Err == nil && h.RequireCloudKey { + c.CloudKeyRequired() + } + if c.Err == nil && h.IsLocal { // if the connection is local, RemoteAddr shouldn't have the // shape IP:PORT (it will be "@" in Linux, for example)