[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 <mattermod@users.noreply.github.com>
Этот коммит содержится в:
@@ -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,
|
// 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
|
// if MFA is enabled, the MFA process is not yet complete, and therefore the requirement to have completed the MFA
|
||||||
// authentication must be waived.
|
// authentication must be waived.
|
||||||
|
|||||||
@@ -540,6 +540,7 @@ type AppIface interface {
|
|||||||
GetChannelsForSchemePage(scheme *model.Scheme, page int, perPage int) (model.ChannelList, *model.AppError)
|
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)
|
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)
|
GetChannelsUserNotIn(teamId string, userId string, offset int, limit int) (*model.ChannelList, *model.AppError)
|
||||||
|
GetCloudSession(token string) (*model.Session, *model.AppError)
|
||||||
GetClusterId() string
|
GetClusterId() string
|
||||||
GetClusterStatus() []*model.ClusterInfo
|
GetClusterStatus() []*model.ClusterInfo
|
||||||
GetCommand(commandId string) (*model.Command, *model.AppError)
|
GetCommand(commandId string) (*model.Command, *model.AppError)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
TokenLocationHeader
|
TokenLocationHeader
|
||||||
TokenLocationCookie
|
TokenLocationCookie
|
||||||
TokenLocationQueryString
|
TokenLocationQueryString
|
||||||
|
TokenLocationCloudHeader
|
||||||
)
|
)
|
||||||
|
|
||||||
func (tl TokenLocation) String() string {
|
func (tl TokenLocation) String() string {
|
||||||
@@ -31,6 +32,8 @@ func (tl TokenLocation) String() string {
|
|||||||
return "Cookie"
|
return "Cookie"
|
||||||
case TokenLocationQueryString:
|
case TokenLocationQueryString:
|
||||||
return "QueryString"
|
return "QueryString"
|
||||||
|
case TokenLocationCloudHeader:
|
||||||
|
return "CloudHeader"
|
||||||
default:
|
default:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
}
|
}
|
||||||
@@ -281,5 +284,9 @@ func ParseAuthTokenFromRequest(r *http.Request) (string, TokenLocation) {
|
|||||||
return token, TokenLocationQueryString
|
return token, TokenLocationQueryString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if token := r.Header.Get(model.HEADER_CLOUD_TOKEN); token != "" {
|
||||||
|
return token, TokenLocationCloudHeader
|
||||||
|
}
|
||||||
|
|
||||||
return "", TokenLocationNotFound
|
return "", TokenLocationNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func TestParseAuthTokenFromRequest(t *testing.T) {
|
|||||||
{"BEARER mytoken", "", "", "mytoken", TokenLocationHeader},
|
{"BEARER mytoken", "", "", "mytoken", TokenLocationHeader},
|
||||||
{"", "mytoken", "", "mytoken", TokenLocationCookie},
|
{"", "mytoken", "", "mytoken", TokenLocationCookie},
|
||||||
{"", "", "mytoken", "mytoken", TokenLocationQueryString},
|
{"", "", "mytoken", "mytoken", TokenLocationQueryString},
|
||||||
|
{"mytoken", "", "", "mytoken", TokenLocationCloudHeader},
|
||||||
}
|
}
|
||||||
|
|
||||||
for testnum, tc := range cases {
|
for testnum, tc := range cases {
|
||||||
@@ -34,10 +35,12 @@ func TestParseAuthTokenFromRequest(t *testing.T) {
|
|||||||
pathname += "?access_token=" + tc.query
|
pathname += "?access_token=" + tc.query
|
||||||
}
|
}
|
||||||
req := httptest.NewRequest("GET", pathname, nil)
|
req := httptest.NewRequest("GET", pathname, nil)
|
||||||
if tc.header != "" {
|
switch tc.expectedLocation {
|
||||||
|
case TokenLocationHeader:
|
||||||
req.Header.Add(model.HEADER_AUTH, tc.header)
|
req.Header.Add(model.HEADER_AUTH, tc.header)
|
||||||
}
|
case TokenLocationCloudHeader:
|
||||||
if tc.cookie != "" {
|
req.Header.Add(model.HEADER_CLOUD_TOKEN, tc.header)
|
||||||
|
case TokenLocationCookie:
|
||||||
req.AddCookie(&http.Cookie{
|
req.AddCookie(&http.Cookie{
|
||||||
Name: model.SESSION_COOKIE_TOKEN,
|
Name: model.SESSION_COOKIE_TOKEN,
|
||||||
Value: tc.cookie,
|
Value: tc.cookie,
|
||||||
|
|||||||
@@ -4862,6 +4862,28 @@ func (a *OpenTracingAppLayer) GetChannelsUserNotIn(teamId string, userId string,
|
|||||||
return resultVar0, resultVar1
|
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 {
|
func (a *OpenTracingAppLayer) GetClusterId() string {
|
||||||
origCtx := a.ctx
|
origCtx := a.ctx
|
||||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetClusterId")
|
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetClusterId")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mattermost/mattermost-server/v5/audit"
|
"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
|
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) {
|
func (a *App) GetSession(token string) (*model.Session, *model.AppError) {
|
||||||
metrics := a.Metrics()
|
metrics := a.Metrics()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const (
|
|||||||
HEADER_CSRF_TOKEN = "X-CSRF-Token"
|
HEADER_CSRF_TOKEN = "X-CSRF-Token"
|
||||||
HEADER_BEARER = "BEARER"
|
HEADER_BEARER = "BEARER"
|
||||||
HEADER_AUTH = "Authorization"
|
HEADER_AUTH = "Authorization"
|
||||||
|
HEADER_CLOUD_TOKEN = "X-Cloud-Token"
|
||||||
HEADER_REQUESTED_WITH = "X-Requested-With"
|
HEADER_REQUESTED_WITH = "X-Requested-With"
|
||||||
HEADER_REQUESTED_WITH_XML = "XMLHttpRequest"
|
HEADER_REQUESTED_WITH_XML = "XMLHttpRequest"
|
||||||
STATUS = "status"
|
STATUS = "status"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const (
|
|||||||
SESSION_PROP_IS_BOT = "is_bot"
|
SESSION_PROP_IS_BOT = "is_bot"
|
||||||
SESSION_PROP_IS_BOT_VALUE = "true"
|
SESSION_PROP_IS_BOT_VALUE = "true"
|
||||||
SESSION_TYPE_USER_ACCESS_TOKEN = "UserAccessToken"
|
SESSION_TYPE_USER_ACCESS_TOKEN = "UserAccessToken"
|
||||||
|
SESSION_TYPE_CLOUD_KEY = "CloudKey"
|
||||||
SESSION_PROP_IS_GUEST = "is_guest"
|
SESSION_PROP_IS_GUEST = "is_guest"
|
||||||
SESSION_ACTIVITY_TIMEOUT = 1000 * 60 * 5 // 5 minutes
|
SESSION_ACTIVITY_TIMEOUT = 1000 * 60 * 5 // 5 minutes
|
||||||
SESSION_USER_ACCESS_TOKEN_EXPIRY = 100 * 365 // 100 years
|
SESSION_USER_ACCESS_TOKEN_EXPIRY = 100 * 365 // 100 years
|
||||||
|
|||||||
@@ -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() {
|
func (c *Context) MfaRequired() {
|
||||||
// Must be licensed for MFA and have it configured for enforcement
|
// 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 {
|
if license := c.App.Srv().License(); license == nil || !*license.Features.MFA || !*c.App.Config().ServiceSettings.EnableMultifactorAuthentication || !*c.App.Config().ServiceSettings.EnforceMultifactorAuthentication {
|
||||||
|
|||||||
@@ -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) {
|
func TestMfaRequired(t *testing.T) {
|
||||||
th := SetupWithStoreMock(t)
|
th := SetupWithStoreMock(t)
|
||||||
defer th.TearDown()
|
defer th.TearDown()
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ type Handler struct {
|
|||||||
HandleFunc func(*Context, http.ResponseWriter, *http.Request)
|
HandleFunc func(*Context, http.ResponseWriter, *http.Request)
|
||||||
HandlerName string
|
HandlerName string
|
||||||
RequireSession bool
|
RequireSession bool
|
||||||
|
RequireCloudKey bool
|
||||||
TrustRequester bool
|
TrustRequester bool
|
||||||
RequireMfa bool
|
RequireMfa bool
|
||||||
IsStatic bool
|
IsStatic bool
|
||||||
@@ -187,7 +188,7 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
token, tokenLocation := app.ParseAuthTokenFromRequest(r)
|
token, tokenLocation := app.ParseAuthTokenFromRequest(r)
|
||||||
|
|
||||||
if len(token) != 0 {
|
if len(token) != 0 && tokenLocation != app.TokenLocationCloudHeader {
|
||||||
session, err := c.App.GetSession(token)
|
session, err := c.App.GetSession(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Log.Info("Invalid session", mlog.Err(err))
|
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)
|
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(
|
c.Log = c.App.Log().With(
|
||||||
@@ -231,6 +241,10 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
c.SetServerBusyError()
|
c.SetServerBusyError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Err == nil && h.RequireCloudKey {
|
||||||
|
c.CloudKeyRequired()
|
||||||
|
}
|
||||||
|
|
||||||
if c.Err == nil && h.IsLocal {
|
if c.Err == nil && h.IsLocal {
|
||||||
// if the connection is local, RemoteAddr shouldn't have the
|
// if the connection is local, RemoteAddr shouldn't have the
|
||||||
// shape IP:PORT (it will be "@" in Linux, for example)
|
// shape IP:PORT (it will be "@" in Linux, for example)
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user