From 5bc3cea6fe4a909735753692d0c4cd960e8ab516 Mon Sep 17 00:00:00 2001 From: enahum Date: Wed, 3 Aug 2016 12:19:27 -0500 Subject: PLT-3484 OAuth2 Service Provider (#3632) * PLT-3484 OAuth2 Service Provider * PM text review for OAuth 2.0 Service Provider * PLT-3484 OAuth2 Service Provider UI tweaks (#3668) * Tweaks to help text * Pushing OAuth improvements (#3680) * Re-arrange System Console for OAuth 2.0 Provider --- api/context.go | 11 +- api/oauth.go | 489 ++++++++++++++++----- api/oauth_test.go | 274 ++++++++---- api/user.go | 15 +- i18n/en.json | 212 +++++---- model/access.go | 25 +- model/access_test.go | 10 +- model/authorize.go | 5 + model/client.go | 46 +- model/oauth.go | 40 +- model/oauth_test.go | 8 + model/preference.go | 3 + store/sql_oauth_store.go | 168 ++++++- store/sql_oauth_store_test.go | 34 +- store/store.go | 6 +- templates/authorize.html | 12 - web/web_test.go | 185 ++++---- webapp/actions/global_actions.jsx | 7 - webapp/actions/oauth_actions.jsx | 60 +++ webapp/client/client.jsx | 30 ++ webapp/components/admin_console/admin_settings.jsx | 17 +- webapp/components/admin_console/admin_sidebar.jsx | 6 +- .../admin_console/custom_integrations_settings.jsx | 180 ++++++++ .../components/admin_console/webhook_settings.jsx | 161 ------- webapp/components/authorize.jsx | 75 ++-- .../backstage/components/backstage_sidebar.jsx | 32 +- .../integrations/components/add_oauth_app.jsx | 435 ++++++++++++++++++ .../components/installed_oauth_app.jsx | 219 +++++++++ .../components/installed_oauth_apps.jsx | 108 +++++ .../integrations/components/integrations.jsx | 30 +- webapp/components/login/login_controller.jsx | 18 +- webapp/components/navbar_dropdown.jsx | 26 +- webapp/components/needs_team.jsx | 2 - webapp/components/register_app_modal.jsx | 411 ----------------- webapp/components/signup_user_complete.jsx | 14 +- webapp/components/user_settings/user_settings.jsx | 12 - .../user_settings/user_settings_developer.jsx | 138 ------ .../user_settings/user_settings_modal.jsx | 8 - webapp/i18n/en.json | 67 +-- webapp/images/oauth_icon.png | Bin 0 -> 25529 bytes webapp/images/webhook_icon.jpg | Bin 68190 -> 20565 bytes webapp/root.html | 9 +- webapp/routes/route_admin_console.jsx | 8 +- webapp/routes/route_integrations.jsx | 16 + webapp/routes/route_root.jsx | 6 + webapp/sass/components/_oauth.scss | 11 +- webapp/sass/responsive/_mobile.scss | 17 + webapp/sass/routes/_backstage.scss | 20 +- webapp/stores/integration_store.jsx | 43 ++ webapp/stores/modal_store.jsx | 1 - webapp/utils/constants.jsx | 4 +- 51 files changed, 2455 insertions(+), 1279 deletions(-) delete mode 100644 templates/authorize.html create mode 100644 webapp/actions/oauth_actions.jsx create mode 100644 webapp/components/admin_console/custom_integrations_settings.jsx delete mode 100644 webapp/components/admin_console/webhook_settings.jsx create mode 100644 webapp/components/integrations/components/add_oauth_app.jsx create mode 100644 webapp/components/integrations/components/installed_oauth_app.jsx create mode 100644 webapp/components/integrations/components/installed_oauth_apps.jsx delete mode 100644 webapp/components/register_app_modal.jsx delete mode 100644 webapp/components/user_settings/user_settings_developer.jsx create mode 100644 webapp/images/oauth_icon.png diff --git a/api/context.go b/api/context.go index 6976feb8f..9a2f9b9ea 100644 --- a/api/context.go +++ b/api/context.go @@ -68,6 +68,10 @@ func UserRequired(h func(*Context, http.ResponseWriter, *http.Request)) http.Han return &handler{h, true, false, false, false, false, false} } +func AppHandlerTrustRequester(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { + return &handler{h, false, false, false, false, false, true} +} + func ApiAdminSystemRequired(h func(*Context, http.ResponseWriter, *http.Request)) http.Handler { return &handler{h, true, true, true, false, false, false} } @@ -102,7 +106,6 @@ func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.RequestId = model.NewId() c.IpAddress = GetIpAddress(r) c.TeamId = mux.Vars(r)["team_id"] - h.isApi = IsApiCall(r) token := "" isTokenFromQueryString := false @@ -147,10 +150,10 @@ func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set(model.HEADER_REQUEST_ID, c.RequestId) w.Header().Set(model.HEADER_VERSION_ID, fmt.Sprintf("%v.%v", model.CurrentVersion, utils.CfgLastModified)) - // Instruct the browser not to display us in an iframe for anti-clickjacking + // Instruct the browser not to display us in an iframe unless is the same origin for anti-clickjacking if !h.isApi { - w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'") + w.Header().Set("X-Frame-Options", "SAMEORIGIN") + w.Header().Set("Content-Security-Policy", "frame-ancestors 'self'") } else { // All api response bodies will be JSON formatted by default w.Header().Set("Content-Type", "application/json") diff --git a/api/oauth.go b/api/oauth.go index d41f526b4..e2ea001a0 100644 --- a/api/oauth.go +++ b/api/oauth.go @@ -18,6 +18,7 @@ import ( "github.com/gorilla/mux" "github.com/mattermost/platform/einterfaces" "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" ) @@ -25,14 +26,17 @@ func InitOAuth() { l4g.Debug(utils.T("api.oauth.init.debug")) BaseRoutes.OAuth.Handle("/register", ApiUserRequired(registerOAuthApp)).Methods("POST") + BaseRoutes.OAuth.Handle("/list", ApiUserRequired(getOAuthApps)).Methods("GET") + BaseRoutes.OAuth.Handle("/app/{client_id}", ApiUserRequired(getOAuthAppInfo)).Methods("GET") BaseRoutes.OAuth.Handle("/allow", ApiUserRequired(allowOAuth)).Methods("GET") + BaseRoutes.OAuth.Handle("/delete", ApiUserRequired(deleteOAuthApp)).Methods("POST") BaseRoutes.OAuth.Handle("/{service:[A-Za-z0-9]+}/complete", AppHandlerIndependent(completeOAuth)).Methods("GET") BaseRoutes.OAuth.Handle("/{service:[A-Za-z0-9]+}/login", AppHandlerIndependent(loginWithOAuth)).Methods("GET") BaseRoutes.OAuth.Handle("/{service:[A-Za-z0-9]+}/signup", AppHandlerIndependent(signupWithOAuth)).Methods("GET") BaseRoutes.OAuth.Handle("/authorize", ApiUserRequired(authorizeOAuth)).Methods("GET") BaseRoutes.OAuth.Handle("/access_token", ApiAppHandler(getAccessToken)).Methods("POST") - BaseRoutes.Root.Handle("/authorize", ApiUserRequired(authorizeOAuth)).Methods("GET") + BaseRoutes.Root.Handle("/authorize", AppHandlerTrustRequester(authorizeOAuth)).Methods("GET") BaseRoutes.Root.Handle("/access_token", ApiAppHandler(getAccessToken)).Methods("POST") // Handle all the old routes, to be later removed @@ -48,6 +52,14 @@ func registerOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) { return } + if *utils.Cfg.ServiceSettings.EnableOnlyAdminIntegrations { + if !c.IsSystemAdmin() { + c.Err = model.NewLocAppError("registerOAuthApp", "api.command.admin_only.app_error", nil, "") + c.Err.StatusCode = http.StatusForbidden + return + } + } + app := model.OAuthAppFromJson(r.Body) if app == nil { @@ -65,7 +77,6 @@ func registerOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) { return } else { app = result.Data.(*model.OAuthApp) - app.ClientSecret = secret c.LogAudit("client_id=" + app.Id) @@ -75,6 +86,62 @@ func registerOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) { } +func getOAuthApps(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { + c.Err = model.NewLocAppError("getOAuthAppsByUser", "api.oauth.allow_oauth.turn_off.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + isSystemAdmin := c.IsSystemAdmin() + + if *utils.Cfg.ServiceSettings.EnableOnlyAdminIntegrations { + if !isSystemAdmin { + c.Err = model.NewLocAppError("getOAuthAppsByUser", "api.command.admin_only.app_error", nil, "") + c.Err.StatusCode = http.StatusForbidden + return + } + } + + var ochan store.StoreChannel + if isSystemAdmin { + ochan = Srv.Store.OAuth().GetApps() + } else { + ochan = Srv.Store.OAuth().GetAppByUser(c.Session.UserId) + } + + if result := <-ochan; result.Err != nil { + c.Err = result.Err + return + } else { + apps := result.Data.([]*model.OAuthApp) + w.Write([]byte(model.OAuthAppListToJson(apps))) + } +} + +func getOAuthAppInfo(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { + c.Err = model.NewLocAppError("getOAuthAppInfo", "api.oauth.allow_oauth.turn_off.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + params := mux.Vars(r) + + clientId := params["client_id"] + + var app *model.OAuthApp + if result := <-Srv.Store.OAuth().GetApp(clientId); result.Err != nil { + c.Err = model.NewLocAppError("getOAuthAppInfo", "api.oauth.allow_oauth.database.app_error", nil, "") + return + } else { + app = result.Data.(*model.OAuthApp) + } + + app.Sanitize() + w.Write([]byte(app.ToJson())) +} + func allowOAuth(c *Context, w http.ResponseWriter, r *http.Request) { if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { c.Err = model.NewLocAppError("allowOAuth", "api.oauth.allow_oauth.turn_off.app_error", nil, "") @@ -108,6 +175,10 @@ func allowOAuth(c *Context, w http.ResponseWriter, r *http.Request) { scope := r.URL.Query().Get("scope") state := r.URL.Query().Get("state") + if len(scope) == 0 { + scope = model.DEFAULT_SCOPE + } + var app *model.OAuthApp if result := <-Srv.Store.OAuth().GetApp(clientId); result.Err != nil { c.Err = model.NewLocAppError("allowOAuth", "api.oauth.allow_oauth.database.app_error", nil, "") @@ -131,6 +202,20 @@ func allowOAuth(c *Context, w http.ResponseWriter, r *http.Request) { authData := &model.AuthData{UserId: c.Session.UserId, ClientId: clientId, CreateAt: model.GetMillis(), RedirectUri: redirectUri, State: state, Scope: scope} authData.Code = model.HashPassword(fmt.Sprintf("%v:%v:%v:%v", clientId, redirectUri, authData.CreateAt, c.Session.UserId)) + // this saves the OAuth2 app as authorized + authorizedApp := model.Preference{ + UserId: c.Session.UserId, + Category: model.PREFERENCE_CATEGORY_AUTHORIZED_OAUTH_APP, + Name: clientId, + Value: scope, + } + + if result := <-Srv.Store.Preference().Save(&model.Preferences{authorizedApp}); result.Err != nil { + responseData["redirect"] = redirectUri + "?error=server_error&state=" + state + w.Write([]byte(model.MapToJson(responseData))) + return + } + if result := <-Srv.Store.OAuth().SaveAuthData(authData); result.Err != nil { responseData["redirect"] = redirectUri + "?error=server_error&state=" + state w.Write([]byte(model.MapToJson(responseData))) @@ -140,7 +225,7 @@ func allowOAuth(c *Context, w http.ResponseWriter, r *http.Request) { c.LogAudit("success") responseData["redirect"] = redirectUri + "?code=" + url.QueryEscape(authData.Code) + "&state=" + url.QueryEscape(authData.State) - + w.Header().Set("Content-Type", "application/json") w.Write([]byte(model.MapToJson(responseData))) } @@ -149,24 +234,16 @@ func RevokeAccessToken(token string) *model.AppError { schan := Srv.Store.Session().Remove(token) sessionCache.Remove(token) - var accessData *model.AccessData if result := <-Srv.Store.OAuth().GetAccessData(token); result.Err != nil { return model.NewLocAppError("RevokeAccessToken", "api.oauth.revoke_access_token.get.app_error", nil, "") - } else { - accessData = result.Data.(*model.AccessData) } tchan := Srv.Store.OAuth().RemoveAccessData(token) - cchan := Srv.Store.OAuth().RemoveAuthData(accessData.AuthCode) if result := <-tchan; result.Err != nil { return model.NewLocAppError("RevokeAccessToken", "api.oauth.revoke_access_token.del_token.app_error", nil, "") } - if result := <-cchan; result.Err != nil { - return model.NewLocAppError("RevokeAccessToken", "api.oauth.revoke_access_token.del_code.app_error", nil, "") - } - if result := <-schan; result.Err != nil { return model.NewLocAppError("RevokeAccessToken", "api.oauth.revoke_access_token.del_session.app_error", nil, "") } @@ -215,6 +292,10 @@ func completeOAuth(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = JoinUserToTeamById(teamId, user) } if c.Err == nil { + if val, ok := props["redirect_to"]; ok { + http.Redirect(w, r, c.GetSiteURL()+val, http.StatusTemporaryRedirect) + return + } http.Redirect(w, r, GetProtocol(r)+"://"+r.Host, http.StatusTemporaryRedirect) } break @@ -242,7 +323,7 @@ func completeOAuth(c *Context, w http.ResponseWriter, r *http.Request) { func authorizeOAuth(c *Context, w http.ResponseWriter, r *http.Request) { if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { - c.Err = model.NewLocAppError("authorizeOAuth", "web.authorize_oauth.disabled.app_error", nil, "") + c.Err = model.NewLocAppError("authorizeOAuth", "api.oauth.authorize_oauth.disabled.app_error", nil, "") c.Err.StatusCode = http.StatusNotImplemented return } @@ -253,8 +334,12 @@ func authorizeOAuth(c *Context, w http.ResponseWriter, r *http.Request) { scope := r.URL.Query().Get("scope") state := r.URL.Query().Get("state") + if len(scope) == 0 { + scope = model.DEFAULT_SCOPE + } + if len(responseType) == 0 || len(clientId) == 0 || len(redirect) == 0 { - c.Err = model.NewLocAppError("authorizeOAuth", "web.authorize_oauth.missing.app_error", nil, "") + c.Err = model.NewLocAppError("authorizeOAuth", "api.oauth.authorize_oauth.missing.app_error", nil, "") return } @@ -266,31 +351,67 @@ func authorizeOAuth(c *Context, w http.ResponseWriter, r *http.Request) { app = result.Data.(*model.OAuthApp) } - var team *model.Team - if result := <-Srv.Store.Team().Get(c.TeamId); result.Err != nil { - c.Err = result.Err + // here we should check if the user is logged in + if len(c.Session.UserId) == 0 { + http.Redirect(w, r, c.GetSiteURL()+"/login?redirect_to="+url.QueryEscape(r.RequestURI), http.StatusFound) return - } else { - team = result.Data.(*model.Team) } - page := utils.NewHTMLTemplate("authorize", c.Locale) - page.Props["Title"] = c.T("web.authorize_oauth.title") - page.Props["TeamName"] = team.Name - page.Props["AppName"] = app.Name - page.Props["ResponseType"] = responseType - page.Props["ClientId"] = clientId - page.Props["RedirectUri"] = redirect - page.Props["Scope"] = scope - page.Props["State"] = state - if err := page.RenderToWriter(w); err != nil { - c.SetUnknownError(page.TemplateName, err.Error()) + isAuthorized := false + if result := <-Srv.Store.Preference().Get(c.Session.UserId, model.PREFERENCE_CATEGORY_AUTHORIZED_OAUTH_APP, clientId); result.Err == nil { + // when we support scopes we should check if the scopes match + isAuthorized = true } + + // Automatically allow if the app is trusted + if app.IsTrusted || isAuthorized { + closeBody := func(r *http.Response) { + if r.Body != nil { + ioutil.ReadAll(r.Body) + r.Body.Close() + } + } + + doAllow := func() (*http.Response, *model.AppError) { + HttpClient := &http.Client{} + url := c.GetSiteURL() + "/api/v3/oauth/allow?response_type=" + model.AUTHCODE_RESPONSE_TYPE + "&client_id=" + clientId + "&redirect_uri=" + url.QueryEscape(redirect) + "&scope=" + scope + "&state=" + url.QueryEscape(state) + rq, _ := http.NewRequest("GET", url, strings.NewReader("")) + + rq.Header.Set(model.HEADER_AUTH, model.HEADER_BEARER+" "+c.Session.Token) + + if rp, err := HttpClient.Do(rq); err != nil { + return nil, model.NewLocAppError(url, "model.client.connecting.app_error", nil, err.Error()) + } else if rp.StatusCode == 304 { + return rp, nil + } else if rp.StatusCode >= 300 { + defer closeBody(rp) + return rp, model.AppErrorFromJson(rp.Body) + } else { + return rp, nil + } + } + + if result, err := doAllow(); err != nil { + c.Err = err + return + } else { + //defer closeBody(result) + data := model.MapFromJson(result.Body) + redirectTo := data["redirect"] + http.Redirect(w, r, redirectTo, http.StatusFound) + return + } + } + + w.Header().Set("Content-Type", "text/html") + + w.Header().Set("Cache-Control", "no-cache, max-age=31556926, public") + http.ServeFile(w, r, utils.FindDir("webapp/dist")+"root.html") } func getAccessToken(c *Context, w http.ResponseWriter, r *http.Request) { if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.disabled.app_error", nil, "") + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.disabled.app_error", nil, "") c.Err.StatusCode = http.StatusNotImplemented return } @@ -299,126 +420,167 @@ func getAccessToken(c *Context, w http.ResponseWriter, r *http.Request) { r.ParseForm() + code := r.FormValue("code") + refreshToken := r.FormValue("refresh_token") + grantType := r.FormValue("grant_type") - if grantType != model.ACCESS_TOKEN_GRANT_TYPE { - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.bad_grant.app_error", nil, "") + switch grantType { + case model.ACCESS_TOKEN_GRANT_TYPE: + if len(code) == 0 { + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.missing_code.app_error", nil, "") + return + } + case model.REFRESH_TOKEN_GRANT_TYPE: + if len(refreshToken) == 0 { + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.missing_refresh_token.app_error", nil, "") + return + } + default: + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.bad_grant.app_error", nil, "") return } clientId := r.FormValue("client_id") if len(clientId) != 26 { - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.bad_client_id.app_error", nil, "") + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.bad_client_id.app_error", nil, "") return } secret := r.FormValue("client_secret") if len(secret) == 0 { - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.bad_client_secret.app_error", nil, "") - return - } - - code := r.FormValue("code") - if len(code) == 0 { - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.missing_code.app_error", nil, "") - return - } - - redirectUri := r.FormValue("redirect_uri") - - achan := Srv.Store.OAuth().GetApp(clientId) - tchan := Srv.Store.OAuth().GetAccessDataByAuthCode(code) - - authData := GetAuthData(code) - - if authData == nil { - c.LogAudit("fail - invalid auth code") - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.expired_code.app_error", nil, "") - return - } - - uchan := Srv.Store.User().Get(authData.UserId) - - if authData.IsExpired() { - c.LogAudit("fail - auth code expired") - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.expired_code.app_error", nil, "") - return - } - - if authData.RedirectUri != redirectUri { - c.LogAudit("fail - redirect uri provided did not match previous redirect uri") - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.redirect_uri.app_error", nil, "") - return - } - - if !model.ComparePassword(code, fmt.Sprintf("%v:%v:%v:%v", clientId, redirectUri, authData.CreateAt, authData.UserId)) { - c.LogAudit("fail - auth code is invalid") - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.expired_code.app_error", nil, "") + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.bad_client_secret.app_error", nil, "") return } var app *model.OAuthApp + achan := Srv.Store.OAuth().GetApp(clientId) if result := <-achan; result.Err != nil { - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.credentials.app_error", nil, "") + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.credentials.app_error", nil, "") return } else { app = result.Data.(*model.OAuthApp) } - if !model.ComparePassword(app.ClientSecret, secret) { + if app.ClientSecret != secret { c.LogAudit("fail - invalid client credentials") - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.credentials.app_error", nil, "") + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.credentials.app_error", nil, "") return } - callback := redirectUri - if len(callback) == 0 { - callback = app.CallbackUrls[0] - } + var user *model.User + var accessData *model.AccessData + var accessRsp *model.AccessResponse + if grantType == model.ACCESS_TOKEN_GRANT_TYPE { + redirectUri := r.FormValue("redirect_uri") + authData := GetAuthData(code) + + if authData == nil { + c.LogAudit("fail - invalid auth code") + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.expired_code.app_error", nil, "") + return + } - if result := <-tchan; result.Err != nil { - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.internal.app_error", nil, "") - return - } else if result.Data != nil { - c.LogAudit("fail - auth code has been used previously") - accessData := result.Data.(*model.AccessData) + if authData.IsExpired() { + <-Srv.Store.OAuth().RemoveAuthData(authData.Code) + c.LogAudit("fail - auth code expired") + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.expired_code.app_error", nil, "") + return + } - // Revoke access token, related auth code, and session from DB as well as from cache - if err := RevokeAccessToken(accessData.Token); err != nil { - l4g.Error(utils.T("web.get_access_token.revoking.error") + err.Message) + if authData.RedirectUri != redirectUri { + c.LogAudit("fail - redirect uri provided did not match previous redirect uri") + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.redirect_uri.app_error", nil, "") + return } - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.exchanged.app_error", nil, "") - return - } + if !model.ComparePassword(code, fmt.Sprintf("%v:%v:%v:%v", clientId, redirectUri, authData.CreateAt, authData.UserId)) { + c.LogAudit("fail - auth code is invalid") + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.expired_code.app_error", nil, "") + return + } - var user *model.User - if result := <-uchan; result.Err != nil { - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.internal_user.app_error", nil, "") - return - } else { - user = result.Data.(*model.User) - } + uchan := Srv.Store.User().Get(authData.UserId) + if result := <-uchan; result.Err != nil { + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.internal_user.app_error", nil, "") + return + } else { + user = result.Data.(*model.User) + } - session := &model.Session{UserId: user.Id, Roles: user.Roles, IsOAuth: true} + tchan := Srv.Store.OAuth().GetPreviousAccessData(user.Id, clientId) + if result := <-tchan; result.Err != nil { + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.internal.app_error", nil, "") + return + } else if result.Data != nil { + accessData := result.Data.(*model.AccessData) + if accessData.IsExpired() { + if access, err := newSessionUpdateToken(app.Name, accessData, user); err != nil { + c.Err = err + return + } else { + accessRsp = access + } + } else { + //return the same token and no need to create a new session + accessRsp = &model.AccessResponse{ + AccessToken: accessData.Token, + TokenType: model.ACCESS_TOKEN_TYPE, + ExpiresIn: int32((accessData.ExpiresAt - model.GetMillis()) / 1000), + } + } + } else { + // create a new session and return new access token + var session *model.Session + if result, err := newSession(app.Name, user); err != nil { + c.Err = err + return + } else { + session = result + } - if result := <-Srv.Store.Session().Save(session); result.Err != nil { - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.internal_session.app_error", nil, "") - return + accessData = &model.AccessData{ClientId: clientId, UserId: user.Id, Token: session.Token, RefreshToken: model.NewId(), RedirectUri: redirectUri, ExpiresAt: session.ExpiresAt} + + if result := <-Srv.Store.OAuth().SaveAccessData(accessData); result.Err != nil { + l4g.Error(result.Err) + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.internal_saving.app_error", nil, "") + return + } + + accessRsp = &model.AccessResponse{ + AccessToken: session.Token, + TokenType: model.ACCESS_TOKEN_TYPE, + RefreshToken: accessData.RefreshToken, + ExpiresIn: int32(*utils.Cfg.ServiceSettings.SessionLengthSSOInDays * 60 * 60 * 24), + } + } + + <-Srv.Store.OAuth().RemoveAuthData(authData.Code) } else { - session = result.Data.(*model.Session) - AddSessionToCache(session) - } + // when grantType is refresh_token + if result := <-Srv.Store.OAuth().GetAccessDataByRefreshToken(refreshToken); result.Err != nil { + c.LogAudit("fail - refresh token is invalid") + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.refresh_token.app_error", nil, "") + return + } else { + accessData = result.Data.(*model.AccessData) + } - accessData := &model.AccessData{AuthCode: authData.Code, Token: session.Token, RedirectUri: callback} + uchan := Srv.Store.User().Get(accessData.UserId) + if result := <-uchan; result.Err != nil { + c.Err = model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.internal_user.app_error", nil, "") + return + } else { + user = result.Data.(*model.User) + } - if result := <-Srv.Store.OAuth().SaveAccessData(accessData); result.Err != nil { - l4g.Error(result.Err) - c.Err = model.NewLocAppError("getAccessToken", "web.get_access_token.internal_saving.app_error", nil, "") - return + if access, err := newSessionUpdateToken(app.Name, accessData, user); err != nil { + c.Err = err + return + } else { + accessRsp = access + } } - accessRsp := &model.AccessResponse{AccessToken: session.Token, TokenType: model.ACCESS_TOKEN_TYPE, ExpiresIn: int32(*utils.Cfg.ServiceSettings.SessionLengthSSOInDays * 60 * 60 * 24)} - w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") @@ -432,6 +594,7 @@ func loginWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) service := params["service"] loginHint := r.URL.Query().Get("login_hint") + redirectTo := r.URL.Query().Get("redirect_to") teamId, err := getTeamIdFromQuery(r.URL.Query()) if err != nil { @@ -445,6 +608,10 @@ func loginWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) { stateProps["team_id"] = teamId } + if len(redirectTo) != 0 { + stateProps["redirect_to"] = redirectTo + } + if authUrl, err := GetAuthorizationCode(c, service, stateProps, loginHint); err != nil { c.Err = err return @@ -462,12 +629,12 @@ func getTeamIdFromQuery(query url.Values) (string, *model.AppError) { props := model.MapFromJson(strings.NewReader(data)) if !model.ComparePassword(hash, fmt.Sprintf("%v:%v", data, utils.Cfg.EmailSettings.InviteSalt)) { - return "", model.NewLocAppError("getTeamIdFromQuery", "web.singup_with_oauth.invalid_link.app_error", nil, "") + return "", model.NewLocAppError("getTeamIdFromQuery", "api.oauth.singup_with_oauth.invalid_link.app_error", nil, "") } t, err := strconv.ParseInt(props["time"], 10, 64) if err != nil || model.GetMillis()-t > 1000*60*60*48 { // 48 hours - return "", model.NewLocAppError("getTeamIdFromQuery", "web.singup_with_oauth.expired_link.app_error", nil, "") + return "", model.NewLocAppError("getTeamIdFromQuery", "api.oauth.singup_with_oauth.expired_link.app_error", nil, "") } return props["id"], nil @@ -488,7 +655,7 @@ func signupWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) { service := params["service"] if !utils.Cfg.TeamSettings.EnableUserCreation { - c.Err = model.NewLocAppError("signupWithOAuth", "web.singup_with_oauth.disabled.app_error", nil, "") + c.Err = model.NewLocAppError("signupWithOAuth", "api.oauth.singup_with_oauth.disabled.app_error", nil, "") c.Err.StatusCode = http.StatusNotImplemented return } @@ -666,3 +833,93 @@ func CompleteSwitchWithOAuth(c *Context, w http.ResponseWriter, r *http.Request, go sendSignInChangeEmail(c, user.Email, c.GetSiteURL(), strings.Title(service)+" SSO") } + +func deleteOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { + c.Err = model.NewLocAppError("deleteOAuthApp", "api.oauth.allow_oauth.turn_off.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + isSystemAdmin := c.IsSystemAdmin() + + if *utils.Cfg.ServiceSettings.EnableOnlyAdminIntegrations { + if !isSystemAdmin { + c.Err = model.NewLocAppError("deleteOAuthApp", "api.command.admin_only.app_error", nil, "") + c.Err.StatusCode = http.StatusForbidden + return + } + } + + c.LogAudit("attempt") + + props := model.MapFromJson(r.Body) + + id := props["id"] + if len(id) == 0 { + c.SetInvalidParam("deleteOAuthApp", "id") + return + } + + if result := <-Srv.Store.OAuth().GetApp(id); result.Err != nil { + c.Err = result.Err + return + } else { + if c.Session.UserId != result.Data.(*model.OAuthApp).CreatorId && !isSystemAdmin { + c.LogAudit("fail - inappropriate permissions") + c.Err = model.NewLocAppError("deleteOAuthApp", "api.oauth.delete.permissions.app_error", nil, "user_id="+c.Session.UserId) + return + } + } + + if err := (<-Srv.Store.OAuth().DeleteApp(id)).Err; err != nil { + c.Err = err + return + } + + c.LogAudit("success") + ReturnStatusOK(w) +} + +func newSession(appName string, user *model.User) (*model.Session, *model.AppError) { + // set new token an session + session := &model.Session{UserId: user.Id, Roles: user.Roles, IsOAuth: true} + session.SetExpireInDays(*utils.Cfg.ServiceSettings.SessionLengthSSOInDays) + session.AddProp(model.SESSION_PROP_PLATFORM, appName) + session.AddProp(model.SESSION_PROP_OS, "OAuth2") + session.AddProp(model.SESSION_PROP_BROWSER, "OAuth2") + + if result := <-Srv.Store.Session().Save(session); result.Err != nil { + return nil, model.NewLocAppError("getAccessToken", "api.oauth.get_access_token.internal_session.app_error", nil, "") + } else { + session = result.Data.(*model.Session) + AddSessionToCache(session) + } + + return session, nil +} + +func newSessionUpdateToken(appName string, accessData *model.AccessData, user *model.User) (*model.AccessResponse, *model.AppError) { + var session *model.Session + <-Srv.Store.Session().Remove(accessData.Token) //remove the previous session + + if result, err := newSession(appName, user); err != nil { + return nil, err + } else { + session = result + } + + accessData.Token = session.Token + accessData.ExpiresAt = session.ExpiresAt + if result := <-Srv.Store.OAuth().UpdateAccessData(accessData); result.Err != nil { + l4g.Error(result.Err) + return nil, model.NewLocAppError("getAccessToken", "web.get_access_token.internal_saving.app_error", nil, "") + } + accessRsp := &model.AccessResponse{ + AccessToken: session.Token, + TokenType: model.ACCESS_TOKEN_TYPE, + ExpiresIn: int32(*utils.Cfg.ServiceSettings.SessionLengthSSOInDays * 60 * 60 * 24), + } + + return accessRsp, nil +} diff --git a/api/oauth_test.go b/api/oauth_test.go index aa3c025a7..b719e17cc 100644 --- a/api/oauth_test.go +++ b/api/oauth_test.go @@ -11,131 +11,245 @@ import ( ) func TestRegisterApp(t *testing.T) { - th := Setup().InitBasic() - Client := th.BasicClient + th := Setup().InitBasic().InitSystemAdmin() + Client := th.SystemAdminClient app := &model.OAuthApp{Name: "TestApp" + model.NewId(), Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { - if _, err := Client.RegisterApp(app); err == nil { t.Fatal("should have failed - oauth providing turned off") } - } else { + } - Client.Logout() + utils.Cfg.ServiceSettings.EnableOAuthServiceProvider = true - if _, err := Client.RegisterApp(app); err == nil { - t.Fatal("not logged in - should have failed") - } + Client.Logout() - th.LoginBasic() + if _, err := Client.RegisterApp(app); err == nil { + t.Fatal("not logged in - should have failed") + } - if result, err := Client.RegisterApp(app); err != nil { - t.Fatal(err) - } else { - rapp := result.Data.(*model.OAuthApp) - if len(rapp.Id) != 26 { - t.Fatal("clientid didn't return properly") - } - if len(rapp.ClientSecret) != 26 { - t.Fatal("client secret didn't return properly") - } - } + th.LoginSystemAdmin() - app = &model.OAuthApp{Name: "", Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} - if _, err := Client.RegisterApp(app); err == nil { - t.Fatal("missing name - should have failed") + if result, err := Client.RegisterApp(app); err != nil { + t.Fatal(err) + } else { + rapp := result.Data.(*model.OAuthApp) + if len(rapp.Id) != 26 { + t.Fatal("clientid didn't return properly") } - - app = &model.OAuthApp{Name: "TestApp" + model.NewId(), Homepage: "", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} - if _, err := Client.RegisterApp(app); err == nil { - t.Fatal("missing homepage - should have failed") + if len(rapp.ClientSecret) != 26 { + t.Fatal("client secret didn't return properly") } + } - app = &model.OAuthApp{Name: "TestApp" + model.NewId(), Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{}} - if _, err := Client.RegisterApp(app); err == nil { - t.Fatal("missing callback url - should have failed") - } + app = &model.OAuthApp{Name: "", Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} + if _, err := Client.RegisterApp(app); err == nil { + t.Fatal("missing name - should have failed") + } + + app = &model.OAuthApp{Name: "TestApp" + model.NewId(), Homepage: "", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} + if _, err := Client.RegisterApp(app); err == nil { + t.Fatal("missing homepage - should have failed") + } + + app = &model.OAuthApp{Name: "TestApp" + model.NewId(), Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{}} + if _, err := Client.RegisterApp(app); err == nil { + t.Fatal("missing callback url - should have failed") } } func TestAllowOAuth(t *testing.T) { - th := Setup().InitBasic() + th := Setup().InitBasic().InitSystemAdmin() Client := th.BasicClient + AdminClient := th.SystemAdminClient + utils.Cfg.ServiceSettings.EnableOAuthServiceProvider = true app := &model.OAuthApp{Name: "TestApp" + model.NewId(), Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} + app = AdminClient.Must(AdminClient.RegisterApp(app)).Data.(*model.OAuthApp) state := "123" - if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { - if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, "12345678901234567890123456", app.CallbackUrls[0], "all", state); err == nil { - t.Fatal("should have failed - oauth service providing turned off") - } + utils.Cfg.ServiceSettings.EnableOAuthServiceProvider = false + if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, app.CallbackUrls[0], "all", state); err == nil { + t.Fatal("should have failed - oauth providing turned off") + } + + utils.Cfg.ServiceSettings.EnableOAuthServiceProvider = true + + if result, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, app.CallbackUrls[0], "all", state); err != nil { + t.Fatal(err) } else { - app = Client.Must(Client.RegisterApp(app)).Data.(*model.OAuthApp) + redirect := result.Data.(map[string]string)["redirect"] + if len(redirect) == 0 { + t.Fatal("redirect url should be set") + } - if result, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, app.CallbackUrls[0], "all", state); err != nil { - t.Fatal(err) + ru, _ := url.Parse(redirect) + if ru == nil { + t.Fatal("redirect url unparseable") } else { - redirect := result.Data.(map[string]string)["redirect"] - if len(redirect) == 0 { - t.Fatal("redirect url should be set") + if len(ru.Query().Get("code")) == 0 { + t.Fatal("authorization code not returned") } - - ru, _ := url.Parse(redirect) - if ru == nil { - t.Fatal("redirect url unparseable") - } else { - if len(ru.Query().Get("code")) == 0 { - t.Fatal("authorization code not returned") - } - if ru.Query().Get("state") != state { - t.Fatal("returned state doesn't match") - } + if ru.Query().Get("state") != state { + t.Fatal("returned state doesn't match") } } + } - if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, "", "all", state); err == nil { - t.Fatal("should have failed - no redirect_url given") - } + if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, "", "all", state); err == nil { + t.Fatal("should have failed - no redirect_url given") + } - if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, "", "", state); err == nil { - t.Fatal("should have failed - no redirect_url given") + if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, "", "", state); err == nil { + t.Fatal("should have failed - no redirect_url given") + } + + if result, err := Client.AllowOAuth("junk", app.Id, app.CallbackUrls[0], "all", state); err != nil { + t.Fatal(err) + } else { + redirect := result.Data.(map[string]string)["redirect"] + if len(redirect) == 0 { + t.Fatal("redirect url should be set") } - if result, err := Client.AllowOAuth("junk", app.Id, app.CallbackUrls[0], "all", state); err != nil { - t.Fatal(err) + ru, _ := url.Parse(redirect) + if ru == nil { + t.Fatal("redirect url unparseable") } else { - redirect := result.Data.(map[string]string)["redirect"] - if len(redirect) == 0 { - t.Fatal("redirect url should be set") + if ru.Query().Get("error") != "unsupported_response_type" { + t.Fatal("wrong error returned") } - - ru, _ := url.Parse(redirect) - if ru == nil { - t.Fatal("redirect url unparseable") - } else { - if ru.Query().Get("error") != "unsupported_response_type" { - t.Fatal("wrong error returned") - } - if ru.Query().Get("state") != state { - t.Fatal("returned state doesn't match") - } + if ru.Query().Get("state") != state { + t.Fatal("returned state doesn't match") } } + } - if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, "", app.CallbackUrls[0], "all", state); err == nil { - t.Fatal("should have failed - empty client id") + if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, "", app.CallbackUrls[0], "all", state); err == nil { + t.Fatal("should have failed - empty client id") + } + + if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, "junk", app.CallbackUrls[0], "all", state); err == nil { + t.Fatal("should have failed - bad client id") + } + + if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, "https://somewhereelse.com", "all", state); err == nil { + t.Fatal("should have failed - redirect uri host does not match app host") + } +} + +func TestGetOAuthAppsByUser(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + Client := th.BasicClient + AdminClient := th.SystemAdminClient + + if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { + if _, err := Client.GetOAuthAppsByUser(); err == nil { + t.Fatal("should have failed - oauth providing turned off") } - if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, "junk", app.CallbackUrls[0], "all", state); err == nil { - t.Fatal("should have failed - bad client id") + } + + utils.Cfg.ServiceSettings.EnableOAuthServiceProvider = true + + if _, err := Client.GetOAuthAppsByUser(); err == nil { + t.Fatal("Should have failed. only admin is permitted") + } + + *utils.Cfg.ServiceSettings.EnableOnlyAdminIntegrations = false + + if result, err := Client.GetOAuthAppsByUser(); err != nil { + t.Fatal(err) + } else { + apps := result.Data.([]*model.OAuthApp) + + if len(apps) != 0 { + t.Fatal("incorrect number of apps should have been 0") } + } - if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, "https://somewhereelse.com", "all", state); err == nil { - t.Fatal("should have failed - redirect uri host does not match app host") + app := &model.OAuthApp{Name: "TestApp" + model.NewId(), Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} + app = Client.Must(Client.RegisterApp(app)).Data.(*model.OAuthApp) + + if result, err := Client.GetOAuthAppsByUser(); err != nil { + t.Fatal(err) + } else { + apps := result.Data.([]*model.OAuthApp) + + if len(apps) != 1 { + t.Fatal("incorrect number of apps should have been 1") + } + } + + app = &model.OAuthApp{Name: "TestApp4" + model.NewId(), Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} + app = AdminClient.Must(Client.RegisterApp(app)).Data.(*model.OAuthApp) + + if result, err := AdminClient.GetOAuthAppsByUser(); err != nil { + t.Fatal(err) + } else { + apps := result.Data.([]*model.OAuthApp) + + if len(apps) != 4 { + t.Fatal("incorrect number of apps should have been 4") + } + } +} + +func TestGetOAuthAppInfo(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + Client := th.BasicClient + AdminClient := th.SystemAdminClient + + if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { + if _, err := Client.GetOAuthAppInfo("fakeId"); err == nil { + t.Fatal("should have failed - oauth providing turned off") } + + } + + utils.Cfg.ServiceSettings.EnableOAuthServiceProvider = true + + app := &model.OAuthApp{Name: "TestApp5" + model.NewId(), Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} + + app = AdminClient.Must(AdminClient.RegisterApp(app)).Data.(*model.OAuthApp) + + if _, err := Client.GetOAuthAppInfo(app.Id); err != nil { + t.Fatal(err) + } +} + +func TestOAuthDeleteApp(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + Client := th.BasicClient + AdminClient := th.SystemAdminClient + + if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { + if _, err := Client.DeleteOAuthApp("fakeId"); err == nil { + t.Fatal("should have failed - oauth providing turned off") + } + + } + + utils.Cfg.ServiceSettings.EnableOAuthServiceProvider = true + *utils.Cfg.ServiceSettings.EnableOnlyAdminIntegrations = false + + app := &model.OAuthApp{Name: "TestApp5" + model.NewId(), Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} + + app = Client.Must(Client.RegisterApp(app)).Data.(*model.OAuthApp) + + if _, err := Client.DeleteOAuthApp(app.Id); err != nil { + t.Fatal(err) + } + + app = &model.OAuthApp{Name: "TestApp5" + model.NewId(), Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} + + app = Client.Must(Client.RegisterApp(app)).Data.(*model.OAuthApp) + + if _, err := AdminClient.DeleteOAuthApp(app.Id); err != nil { + t.Fatal(err) } } diff --git a/api/user.go b/api/user.go index 506c0ba44..5f3060b1e 100644 --- a/api/user.go +++ b/api/user.go @@ -2484,15 +2484,23 @@ func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) { return } action := r.URL.Query().Get("action") + redirectTo := r.URL.Query().Get("redirect_to") + relayProps := map[string]string{} relayState := "" if len(action) != 0 { - relayProps := map[string]string{} relayProps["team_id"] = teamId relayProps["action"] = action if action == model.OAUTH_ACTION_EMAIL_TO_SSO { relayProps["email"] = r.URL.Query().Get("email") } + } + + if len(redirectTo) != 0 { + relayProps["redirect_to"] = redirectTo + } + + if len(relayProps) > 0 { relayState = b64.StdEncoding.EncodeToString([]byte(model.MapToJson(relayProps))) } @@ -2555,6 +2563,11 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { break } doLogin(c, w, r, user, "") + + if val, ok := relayProps["redirect_to"]; ok { + http.Redirect(w, r, c.GetSiteURL()+val, http.StatusFound) + return + } http.Redirect(w, r, GetProtocol(r)+"://"+r.Host, http.StatusFound) } } diff --git a/i18n/en.json b/i18n/en.json index 34c435b82..17c136f7f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -889,7 +889,75 @@ }, { "id": "api.oauth.allow_oauth.turn_off.app_error", - "translation": "The system admin has turned off OAuth service providing." + "translation": "The system admin has turned off OAuth2 Service Provider." + }, + { + "id": "api.oauth.authorize_oauth.disabled.app_error", + "translation": "The system admin has turned off OAuth2 Service Provider." + }, + { + "id": "api.oauth.authorize_oauth.missing.app_error", + "translation": "Missing one or more of response_type, client_id, or redirect_uri" + }, + { + "id": "api.oauth.delete.permissions.app_error", + "translation": "Inappropriate permissions to delete the OAuth2 App" + }, + { + "id": "api.oauth.get_access_token.bad_client_id.app_error", + "translation": "invalid_request: Bad client_id" + }, + { + "id": "api.oauth.get_access_token.bad_client_secret.app_error", + "translation": "invalid_request: Missing client_secret" + }, + { + "id": "api.oauth.get_access_token.bad_grant.app_error", + "translation": "invalid_request: Bad grant_type" + }, + { + "id": "api.oauth.get_access_token.credentials.app_error", + "translation": "invalid_client: Invalid client credentials" + }, + { + "id": "api.oauth.get_access_token.disabled.app_error", + "translation": "The system admin has turned off OAuth2 Service Provider." + }, + { + "id": "api.oauth.get_access_token.expired_code.app_error", + "translation": "invalid_grant: Invalid or expired authorization code" + }, + { + "id": "api.oauth.get_access_token.internal.app_error", + "translation": "server_error: Encountered internal server error while accessing database" + }, + { + "id": "api.oauth.get_access_token.internal_saving.app_error", + "translation": "server_error: Encountered internal server error while saving access token to database" + }, + { + "id": "api.oauth.get_access_token.internal_session.app_error", + "translation": "server_error: Encountered internal server error while saving session to database" + }, + { + "id": "api.oauth.get_access_token.internal_user.app_error", + "translation": "server_error: Encountered internal server error while pulling user from database" + }, + { + "id": "api.oauth.get_access_token.missing_code.app_error", + "translation": "invalid_request: Missing code" + }, + { + "id": "api.oauth.get_access_token.missing_refresh_token.app_error", + "translation": "invalid_request: Missing refresh_token" + }, + { + "id": "api.oauth.get_access_token.redirect_uri.app_error", + "translation": "invalid_request: Supplied redirect_uri does not match authorization code redirect_uri" + }, + { + "id": "api.oauth.get_access_token.refresh_token.app_error", + "translation": "invalid_grant: Invalid refresh token" }, { "id": "api.oauth.get_auth_data.find.error", @@ -901,11 +969,7 @@ }, { "id": "api.oauth.register_oauth_app.turn_off.app_error", - "translation": "The system admin has turned off OAuth service providing." - }, - { - "id": "api.oauth.revoke_access_token.del_code.app_error", - "translation": "Error deleting authorization code from DB" + "translation": "The system admin has turned off OAuth2 Service Provider." }, { "id": "api.oauth.revoke_access_token.del_session.app_error", @@ -919,6 +983,18 @@ "id": "api.oauth.revoke_access_token.get.app_error", "translation": "Error getting access token from DB before deletion" }, + { + "id": "api.oauth.singup_with_oauth.disabled.app_error", + "translation": "User sign-up is disabled." + }, + { + "id": "api.oauth.singup_with_oauth.expired_link.app_error", + "translation": "The signup link has expired" + }, + { + "id": "api.oauth.singup_with_oauth.invalid_link.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.post.check_for_out_of_channel_mentions.message.multiple", "translation": "{{.Usernames}} and {{.LastUsername}} were mentioned, but they did not receive notifications because they do not belong to this channel." @@ -2440,8 +2516,8 @@ "translation": "Invalid access token" }, { - "id": "model.access.is_valid.auth_code.app_error", - "translation": "Invalid auth code" + "id": "model.access.is_valid.client_id.app_error", + "translation": "Invalid client id" }, { "id": "model.access.is_valid.redirect_uri.app_error", @@ -2451,6 +2527,10 @@ "id": "model.access.is_valid.refresh_token.app_error", "translation": "Invalid refresh token" }, + { + "id": "model.access.is_valid.user_id.app_error", + "translation": "Invalid user id" + }, { "id": "model.authorize.is_valid.auth_code.app_error", "translation": "Invalid authorization code" @@ -2901,7 +2981,7 @@ }, { "id": "model.oauth.is_valid.callback.app_error", - "translation": "Invalid callback urls" + "translation": "Callback URL must be a valid URL and start with http:// or https://." }, { "id": "model.oauth.is_valid.client_secret.app_error", @@ -2921,7 +3001,11 @@ }, { "id": "model.oauth.is_valid.homepage.app_error", - "translation": "Invalid homepage" + "translation": "Homepage must be a valid URL and start with http:// or https://." + }, + { + "id": "model.oauth.is_valid.icon_url.app_error", + "translation": "Icon URL must be a valid URL and start with http:// or https://." }, { "id": "model.oauth.is_valid.name.app_error", @@ -3656,16 +3740,28 @@ "translation": "We encountered an error saving the license" }, { - "id": "store.sql_oauth.get_access_data.app_error", - "translation": "We encountered an error finding the access token" + "id": "store.sql_oauth.delete.commit_transaction.app_error", + "translation": "Unable to commit transaction" }, { - "id": "store.sql_oauth.get_access_data_by_code.app_error", + "id": "store.sql_oauth.delete.open_transaction.app_error", + "translation": "Unable to open transaction to delete the OAuth2 app" + }, + { + "id": "store.sql_oauth.delete.rollback_transaction.app_error", + "translation": "Unable to rollback transaction to delete the OAuth2 App" + }, + { + "id": "store.sql_oauth.delete_app.app_error", + "translation": "An error occurred while deleting the OAuth2 App" + }, + { + "id": "store.sql_oauth.get_access_data.app_error", "translation": "We encountered an error finding the access token" }, { "id": "store.sql_oauth.get_app.find.app_error", - "translation": "We couldn't find the existing app" + "translation": "We couldn't find the requested app" }, { "id": "store.sql_oauth.get_app.finding.app_error", @@ -3675,6 +3771,10 @@ "id": "store.sql_oauth.get_app_by_user.find.app_error", "translation": "We couldn't find any existing apps" }, + { + "id": "store.sql_oauth.get_apps.find.app_error", + "translation": "An error occurred while finding the OAuth2 Apps" + }, { "id": "store.sql_oauth.get_auth_data.find.app_error", "translation": "We couldn't find the existing authorization code" @@ -3683,6 +3783,10 @@ "id": "store.sql_oauth.get_auth_data.finding.app_error", "translation": "We encountered an error finding the authorization code" }, + { + "id": "store.sql_oauth.get_previous_access_data.app_error", + "translation": "We encountered an error finding the access token" + }, { "id": "store.sql_oauth.permanent_delete_auth_data_by_user.app_error", "translation": "We couldn't remove the authorization code" @@ -3711,6 +3815,10 @@ "id": "store.sql_oauth.save_auth_data.app_error", "translation": "We couldn't save the authorization code." }, + { + "id": "store.sql_oauth.update_access_data.app_error", + "translation": "We encountered an error updating the access token" + }, { "id": "store.sql_oauth.update_app.find.app_error", "translation": "We couldn't find the existing app to update" @@ -4407,14 +4515,6 @@ "id": "web.admin_console.title", "translation": "Admin Console" }, - { - "id": "web.authorize_oauth.disabled.app_error", - "translation": "The system admin has turned off OAuth service providing." - }, - { - "id": "web.authorize_oauth.missing.app_error", - "translation": "Missing one or more of response_type, client_id, or redirect_uri" - }, { "id": "web.authorize_oauth.title", "translation": "Authorize Application" @@ -4459,62 +4559,6 @@ "id": "web.find_team.title", "translation": "Find Team" }, - { - "id": "web.get_access_token.bad_client_id.app_error", - "translation": "invalid_request: Bad client_id" - }, - { - "id": "web.get_access_token.bad_client_secret.app_error", - "translation": "invalid_request: Missing client_secret" - }, - { - "id": "web.get_access_token.bad_grant.app_error", - "translation": "invalid_request: Bad grant_type" - }, - { - "id": "web.get_access_token.credentials.app_error", - "translation": "invalid_client: Invalid client credentials" - }, - { - "id": "web.get_access_token.disabled.app_error", - "translation": "The system admin has turned off OAuth service providing." - }, - { - "id": "web.get_access_token.exchanged.app_error", - "translation": "invalid_grant: Authorization code already exchanged for an access token" - }, - { - "id": "web.get_access_token.expired_code.app_error", - "translation": "invalid_grant: Invalid or expired authorization code" - }, - { - "id": "web.get_access_token.internal.app_error", - "translation": "server_error: Encountered internal server error while accessing database" - }, - { - "id": "web.get_access_token.internal_saving.app_error", - "translation": "server_error: Encountered internal server error while saving access token to database" - }, - { - "id": "web.get_access_token.internal_session.app_error", - "translation": "server_error: Encountered internal server error while saving session to database" - }, - { - "id": "web.get_access_token.internal_user.app_error", - "translation": "server_error: Encountered internal server error while pulling user from database" - }, - { - "id": "web.get_access_token.missing_code.app_error", - "translation": "invalid_request: Missing code" - }, - { - "id": "web.get_access_token.redirect_uri.app_error", - "translation": "invalid_request: Supplied redirect_uri does not match authorization code redirect_uri" - }, - { - "id": "web.get_access_token.revoking.error", - "translation": "Encountered an error revoking an access token, err=" - }, { "id": "web.header.back", "translation": "Back" @@ -4627,18 +4671,6 @@ "id": "web.signup_user_complete.title", "translation": "Complete User Sign Up" }, - { - "id": "web.singup_with_oauth.disabled.app_error", - "translation": "User sign-up is disabled." - }, - { - "id": "web.singup_with_oauth.expired_link.app_error", - "translation": "The signup link has expired" - }, - { - "id": "web.singup_with_oauth.invalid_link.app_error", - "translation": "The signup link does not appear to be valid" - }, { "id": "web.singup_with_oauth.invalid_team.app_error", "translation": "Invalid team name" diff --git a/model/access.go b/model/access.go index 877b3c4f0..85417fce9 100644 --- a/model/access.go +++ b/model/access.go @@ -15,10 +15,12 @@ const ( ) type AccessData struct { - AuthCode string `json:"auth_code"` + ClientId string `json:"client_id"` + UserId string `json:"user_id"` Token string `json:"token"` RefreshToken string `json:"refresh_token"` RedirectUri string `json:"redirect_uri"` + ExpiresAt int64 `json:"expires_at"` } type AccessResponse struct { @@ -33,8 +35,12 @@ type AccessResponse struct { // correctly. func (ad *AccessData) IsValid() *AppError { - if len(ad.AuthCode) == 0 || len(ad.AuthCode) > 128 { - return NewLocAppError("AccessData.IsValid", "model.access.is_valid.auth_code.app_error", nil, "") + if len(ad.ClientId) == 0 || len(ad.ClientId) > 26 { + return NewLocAppError("AccessData.IsValid", "model.access.is_valid.client_id.app_error", nil, "") + } + + if len(ad.UserId) == 0 || len(ad.UserId) > 26 { + return NewLocAppError("AccessData.IsValid", "model.access.is_valid.user_id.app_error", nil, "") } if len(ad.Token) != 26 { @@ -52,6 +58,19 @@ func (ad *AccessData) IsValid() *AppError { return nil } +func (me *AccessData) IsExpired() bool { + + if me.ExpiresAt <= 0 { + return false + } + + if GetMillis() > me.ExpiresAt { + return true + } + + return false +} + func (ad *AccessData) ToJson() string { b, err := json.Marshal(ad) if err != nil { diff --git a/model/access_test.go b/model/access_test.go index a018a2919..0eca302ba 100644 --- a/model/access_test.go +++ b/model/access_test.go @@ -10,7 +10,8 @@ import ( func TestAccessJson(t *testing.T) { a1 := AccessData{} - a1.AuthCode = NewId() + a1.ClientId = NewId() + a1.UserId = NewId() a1.Token = NewId() a1.RefreshToken = NewId() @@ -29,7 +30,12 @@ func TestAccessIsValid(t *testing.T) { t.Fatal("should have failed") } - ad.AuthCode = NewId() + ad.ClientId = NewId() + if err := ad.IsValid(); err == nil { + t.Fatal("should have failed") + } + + ad.UserId = NewId() if err := ad.IsValid(); err == nil { t.Fatal("should have failed") } diff --git a/model/authorize.go b/model/authorize.go index e0d665bae..2b4017e9c 100644 --- a/model/authorize.go +++ b/model/authorize.go @@ -11,6 +11,7 @@ import ( const ( AUTHCODE_EXPIRE_TIME = 60 * 10 // 10 minutes AUTHCODE_RESPONSE_TYPE = "code" + DEFAULT_SCOPE = "user" ) type AuthData struct { @@ -71,6 +72,10 @@ func (ad *AuthData) PreSave() { if ad.CreateAt == 0 { ad.CreateAt = GetMillis() } + + if len(ad.Scope) == 0 { + ad.Scope = DEFAULT_SCOPE + } } func (ad *AuthData) ToJson() string { diff --git a/model/client.go b/model/client.go index 23648050f..cad551613 100644 --- a/model/client.go +++ b/model/client.go @@ -1446,6 +1446,8 @@ func (c *Client) GetTeamMembers(teamId string) (*Result, *AppError) { } } +// RegisterApp creates a new OAuth2 app to be used with the OAuth2 Provider. On success +// it returns the created app. Must be authenticated as a user. func (c *Client) RegisterApp(app *OAuthApp) (*Result, *AppError) { if r, err := c.DoApiPost("/oauth/register", app.ToJson()); err != nil { return nil, err @@ -1456,6 +1458,9 @@ func (c *Client) RegisterApp(app *OAuthApp) (*Result, *AppError) { } } +// AllowOAuth allows a new session by an OAuth2 App. On success +// it returns the url to be redirected back to the app which initiated the oauth2 flow. +// Must be authenticated as a user. func (c *Client) AllowOAuth(rspType, clientId, redirect, scope, state string) (*Result, *AppError) { if r, err := c.DoApiGet("/oauth/allow?response_type="+rspType+"&client_id="+clientId+"&redirect_uri="+url.QueryEscape(redirect)+"&scope="+scope+"&state="+url.QueryEscape(state), "", ""); err != nil { return nil, err @@ -1466,8 +1471,47 @@ func (c *Client) AllowOAuth(rspType, clientId, redirect, scope, state string) (* } } +// GetOAuthAppsByUser returns the OAuth2 Apps registered by the user. On success +// it returns a list of OAuth2 Apps from the same user or all the registered apps if the user +// is a System Administrator. Must be authenticated as a user. +func (c *Client) GetOAuthAppsByUser() (*Result, *AppError) { + if r, err := c.DoApiGet("/oauth/list", "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), OAuthAppListFromJson(r.Body)}, nil + } +} + +// GetOAuthAppInfo lookup an OAuth2 App using the client_id. On success +// it returns a Sanitized OAuth2 App. Must be authenticated as a user. +func (c *Client) GetOAuthAppInfo(clientId string) (*Result, *AppError) { + if r, err := c.DoApiGet("/oauth/app/"+clientId, "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), OAuthAppFromJson(r.Body)}, nil + } +} + +// DeleteOAuthApp deletes an OAuth2 app, the app must be deleted by the same user who created it or +// a System Administrator. On success returs Status OK. Must be authenticated as a user. +func (c *Client) DeleteOAuthApp(id string) (*Result, *AppError) { + data := make(map[string]string) + data["id"] = id + if r, err := c.DoApiPost("/oauth/delete", MapToJson(data)); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), MapFromJson(r.Body)}, nil + } +} + func (c *Client) GetAccessToken(data url.Values) (*Result, *AppError) { - if r, err := c.DoApiPost("/oauth/access_token", data.Encode()); err != nil { + if r, err := c.DoPost(API_URL_SUFFIX+"/oauth/access_token", data.Encode(), "application/x-www-form-urlencoded"); err != nil { return nil, err } else { defer closeBody(r) diff --git a/model/oauth.go b/model/oauth.go index c54df107c..cfe643c9a 100644 --- a/model/oauth.go +++ b/model/oauth.go @@ -25,8 +25,10 @@ type OAuthApp struct { ClientSecret string `json:"client_secret"` Name string `json:"name"` Description string `json:"description"` + IconURL string `json:"icon_url"` CallbackUrls StringArray `json:"callback_urls"` Homepage string `json:"homepage"` + IsTrusted bool `json:"is_trusted"` } // IsValid validates the app and returns an error if it isn't configured @@ -61,7 +63,13 @@ func (a *OAuthApp) IsValid() *AppError { return NewLocAppError("OAuthApp.IsValid", "model.oauth.is_valid.callback.app_error", nil, "app_id="+a.Id) } - if len(a.Homepage) == 0 || len(a.Homepage) > 256 { + for _, callback := range a.CallbackUrls { + if !IsValidHttpUrl(callback) { + return NewLocAppError("OAuthApp.IsValid", "model.oauth.is_valid.callback.app_error", nil, "") + } + } + + if len(a.Homepage) == 0 || len(a.Homepage) > 256 || !IsValidHttpUrl(a.Homepage) { return NewLocAppError("OAuthApp.IsValid", "model.oauth.is_valid.homepage.app_error", nil, "app_id="+a.Id) } @@ -69,6 +77,12 @@ func (a *OAuthApp) IsValid() *AppError { return NewLocAppError("OAuthApp.IsValid", "model.oauth.is_valid.description.app_error", nil, "app_id="+a.Id) } + if len(a.IconURL) > 0 { + if len(a.IconURL) > 512 || !IsValidHttpUrl(a.IconURL) { + return NewLocAppError("OAuthApp.IsValid", "model.oauth.is_valid.icon_url.app_error", nil, "app_id="+a.Id) + } + } + return nil } @@ -85,10 +99,6 @@ func (a *OAuthApp) PreSave() { a.CreateAt = GetMillis() a.UpdateAt = a.CreateAt - - if len(a.ClientSecret) > 0 { - a.ClientSecret = HashPassword(a.ClientSecret) - } } // PreUpdate should be run before updating the app in the db. @@ -157,3 +167,23 @@ func OAuthAppMapFromJson(data io.Reader) map[string]*OAuthApp { return nil } } + +func OAuthAppListToJson(l []*OAuthApp) string { + b, err := json.Marshal(l) + if err != nil { + return "" + } else { + return string(b) + } +} + +func OAuthAppListFromJson(data io.Reader) []*OAuthApp { + decoder := json.NewDecoder(data) + var o []*OAuthApp + err := decoder.Decode(&o) + if err == nil { + return o + } else { + return nil + } +} diff --git a/model/oauth_test.go b/model/oauth_test.go index 2ba36666c..e1f88a993 100644 --- a/model/oauth_test.go +++ b/model/oauth_test.go @@ -14,6 +14,7 @@ func TestOAuthAppJson(t *testing.T) { a1.Name = "TestOAuthApp" + NewId() a1.CallbackUrls = []string{"https://nowhere.com"} a1.Homepage = "https://nowhere.com" + a1.IconURL = "https://nowhere.com/icon_image.png" a1.ClientSecret = NewId() json := a1.ToJson() @@ -30,6 +31,7 @@ func TestOAuthAppPreSave(t *testing.T) { a1.Name = "TestOAuthApp" + NewId() a1.CallbackUrls = []string{"https://nowhere.com"} a1.Homepage = "https://nowhere.com" + a1.IconURL = "https://nowhere.com/icon_image.png" a1.ClientSecret = NewId() a1.PreSave() a1.Etag() @@ -42,6 +44,7 @@ func TestOAuthAppPreUpdate(t *testing.T) { a1.Name = "TestOAuthApp" + NewId() a1.CallbackUrls = []string{"https://nowhere.com"} a1.Homepage = "https://nowhere.com" + a1.IconURL = "https://nowhere.com/icon_image.png" a1.ClientSecret = NewId() a1.PreUpdate() } @@ -92,4 +95,9 @@ func TestOAuthAppIsValid(t *testing.T) { if err := app.IsValid(); err != nil { t.Fatal() } + + app.IconURL = "https://nowhere.com/icon_image.png" + if err := app.IsValid(); err != nil { + t.Fatal() + } } diff --git a/model/preference.go b/model/preference.go index 779c41e50..b74e25d81 100644 --- a/model/preference.go +++ b/model/preference.go @@ -22,6 +22,9 @@ const ( PREFERENCE_CATEGORY_THEME = "theme" // the name for theme props is the team id + PREFERENCE_CATEGORY_AUTHORIZED_OAUTH_APP = "oauth_app" + // the name for oauth_app is the client_id and value is the current scope + PREFERENCE_CATEGORY_LAST = "last" PREFERENCE_NAME_LAST_CHANNEL = "channel" ) diff --git a/store/sql_oauth_store.go b/store/sql_oauth_store.go index e41f584a6..6db54bd4a 100644 --- a/store/sql_oauth_store.go +++ b/store/sql_oauth_store.go @@ -4,6 +4,7 @@ package store import ( + "github.com/go-gorp/gorp" "github.com/mattermost/platform/model" "strings" ) @@ -24,6 +25,7 @@ func NewSqlOAuthStore(sqlStore *SqlStore) OAuthStore { table.ColMap("Description").SetMaxSize(512) table.ColMap("CallbackUrls").SetMaxSize(1024) table.ColMap("Homepage").SetMaxSize(256) + table.ColMap("IconURL").SetMaxSize(512) tableAuth := db.AddTableWithName(model.AuthData{}, "OAuthAuthData").SetKeys(false, "Code") tableAuth.ColMap("UserId").SetMaxSize(26) @@ -34,21 +36,36 @@ func NewSqlOAuthStore(sqlStore *SqlStore) OAuthStore { tableAuth.ColMap("Scope").SetMaxSize(128) tableAccess := db.AddTableWithName(model.AccessData{}, "OAuthAccessData").SetKeys(false, "Token") - tableAccess.ColMap("AuthCode").SetMaxSize(128) + tableAccess.ColMap("ClientId").SetMaxSize(26) + tableAccess.ColMap("UserId").SetMaxSize(26) tableAccess.ColMap("Token").SetMaxSize(26) tableAccess.ColMap("RefreshToken").SetMaxSize(26) tableAccess.ColMap("RedirectUri").SetMaxSize(256) + tableAccess.SetUniqueTogether("ClientId", "UserId") } return as } func (as SqlOAuthStore) UpgradeSchemaIfNeeded() { + as.CreateColumnIfNotExists("OAuthApps", "IsTrusted", "tinyint(1)", "boolean", "0") + as.CreateColumnIfNotExists("OAuthApps", "IconURL", "varchar(512)", "varchar(512)", "") + as.CreateColumnIfNotExists("OAuthAccessData", "ClientId", "varchar(26)", "varchar(26)", "") + as.CreateColumnIfNotExists("OAuthAccessData", "UserId", "varchar(26)", "varchar(26)", "") + as.CreateColumnIfNotExists("OAuthAccessData", "ExpiresAt", "bigint", "bigint", "0") + + // ADDED for 3.3 REMOVE for 3.7 + if as.DoesColumnExist("OAuthAccessData", "AuthCode") { + as.RemoveIndexIfExists("idx_oauthaccessdata_auth_code", "OAuthAccessData") + as.RemoveColumnIfExists("OAuthAccessData", "AuthCode") + } } func (as SqlOAuthStore) CreateIndexesIfNotExists() { as.CreateIndexIfNotExists("idx_oauthapps_creator_id", "OAuthApps", "CreatorId") - as.CreateIndexIfNotExists("idx_oauthaccessdata_auth_code", "OAuthAccessData", "AuthCode") + as.CreateIndexIfNotExists("idx_oauthaccessdata_client_id", "OAuthAccessData", "ClientId") + as.CreateIndexIfNotExists("idx_oauthaccessdata_user_id", "OAuthAccessData", "UserId") + as.CreateIndexIfNotExists("idx_oauthaccessdata_refresh_token", "OAuthAccessData", "RefreshToken") as.CreateIndexIfNotExists("idx_oauthauthdata_client_id", "OAuthAuthData", "Code") } @@ -172,6 +189,62 @@ func (as SqlOAuthStore) GetAppByUser(userId string) StoreChannel { return storeChannel } +func (as SqlOAuthStore) GetApps() StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var apps []*model.OAuthApp + + if _, err := as.GetReplica().Select(&apps, "SELECT * FROM OAuthApps"); err != nil { + result.Err = model.NewLocAppError("SqlOAuthStore.GetAppByUser", "store.sql_oauth.get_apps.find.app_error", nil, "err="+err.Error()) + } + + result.Data = apps + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (as SqlOAuthStore) DeleteApp(id string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + // wrap in a transaction so that if one fails, everything fails + transaction, err := as.GetMaster().Begin() + if err != nil { + result.Err = model.NewLocAppError("SqlOAuthStore.DeleteApp", "store.sql_oauth.delete.open_transaction.app_error", nil, err.Error()) + } else { + if extrasResult := as.deleteApp(transaction, id); 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.NewLocAppError("SqlOAuthStore.DeleteApp", "store.sql_oauth.delete.commit_transaction.app_error", nil, err.Error()) + } + } else { + if err := transaction.Rollback(); err != nil { + result.Err = model.NewLocAppError("SqlOAuthStore.DeleteApp", "store.sql_oauth.delete.rollback_transaction.app_error", nil, err.Error()) + } + } + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (as SqlOAuthStore) SaveAccessData(accessData *model.AccessData) StoreChannel { storeChannel := make(StoreChannel) @@ -221,7 +294,7 @@ func (as SqlOAuthStore) GetAccessData(token string) StoreChannel { return storeChannel } -func (as SqlOAuthStore) GetAccessDataByAuthCode(authCode string) StoreChannel { +func (as SqlOAuthStore) GetAccessDataByRefreshToken(token string) StoreChannel { storeChannel := make(StoreChannel) @@ -230,11 +303,35 @@ func (as SqlOAuthStore) GetAccessDataByAuthCode(authCode string) StoreChannel { accessData := model.AccessData{} - if err := as.GetReplica().SelectOne(&accessData, "SELECT * FROM OAuthAccessData WHERE AuthCode = :AuthCode", map[string]interface{}{"AuthCode": authCode}); err != nil { + if err := as.GetReplica().SelectOne(&accessData, "SELECT * FROM OAuthAccessData WHERE RefreshToken = :Token", map[string]interface{}{"Token": token}); err != nil { + result.Err = model.NewLocAppError("SqlOAuthStore.GetAccessData", "store.sql_oauth.get_access_data.app_error", nil, err.Error()) + } else { + result.Data = &accessData + } + + storeChannel <- result + close(storeChannel) + + }() + + return storeChannel +} + +func (as SqlOAuthStore) GetPreviousAccessData(userId, clientId string) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + accessData := model.AccessData{} + + if err := as.GetReplica().SelectOne(&accessData, "SELECT * FROM OAuthAccessData WHERE ClientId = :ClientId AND UserId = :UserId", + map[string]interface{}{"ClientId": clientId, "UserId": userId}); err != nil { if strings.Contains(err.Error(), "no rows") { result.Data = nil } else { - result.Err = model.NewLocAppError("SqlOAuthStore.GetAccessDataByAuthCode", "store.sql_oauth.get_access_data_by_code.app_error", nil, err.Error()) + result.Err = model.NewLocAppError("SqlOAuthStore.GetPreviousAccessData", "store.sql_oauth.get_previous_access_data.app_error", nil, err.Error()) } } else { result.Data = &accessData @@ -248,6 +345,27 @@ func (as SqlOAuthStore) GetAccessDataByAuthCode(authCode string) StoreChannel { return storeChannel } +func (as SqlOAuthStore) UpdateAccessData(accessData *model.AccessData) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if _, err := as.GetMaster().Exec("UPDATE OAuthAccessData SET Token = :Token, ExpiresAt = :ExpiresAt WHERE ClientId = :ClientId AND UserID = :UserId", + map[string]interface{}{"Token": accessData.Token, "ExpiresAt": accessData.ExpiresAt, "ClientId": accessData.ClientId, "UserId": accessData.UserId}); err != nil { + result.Err = model.NewLocAppError("SqlOAuthStore.Update", "store.sql_oauth.update_access_data.app_error", nil, + "clientId="+accessData.ClientId+",userId="+accessData.UserId+", "+err.Error()) + } else { + result.Data = accessData + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (as SqlOAuthStore) RemoveAccessData(token string) StoreChannel { storeChannel := make(StoreChannel) @@ -339,7 +457,7 @@ func (as SqlOAuthStore) PermanentDeleteAuthDataByUser(userId string) StoreChanne go func() { result := StoreResult{} - _, err := as.GetMaster().Exec("DELETE FROM OAuthAuthData WHERE UserId = :UserId", map[string]interface{}{"UserId": userId}) + _, err := as.GetMaster().Exec("DELETE FROM OAuthAccessData WHERE UserId = :UserId", map[string]interface{}{"UserId": userId}) if err != nil { result.Err = model.NewLocAppError("SqlOAuthStore.RemoveAuthDataByUserId", "store.sql_oauth.permanent_delete_auth_data_by_user.app_error", nil, "err="+err.Error()) } @@ -350,3 +468,41 @@ func (as SqlOAuthStore) PermanentDeleteAuthDataByUser(userId string) StoreChanne return storeChannel } + +func (as SqlOAuthStore) deleteApp(transaction *gorp.Transaction, clientId string) StoreResult { + result := StoreResult{} + + if _, err := transaction.Exec("DELETE FROM OAuthApps WHERE Id = :Id", map[string]interface{}{"Id": clientId}); err != nil { + result.Err = model.NewLocAppError("SqlOAuthStore.DeleteApp", "store.sql_oauth.delete_app.app_error", nil, "id="+clientId+", err="+err.Error()) + return result + } + + return as.deleteOAuthTokens(transaction, clientId) +} + +func (as SqlOAuthStore) deleteOAuthTokens(transaction *gorp.Transaction, clientId string) StoreResult { + result := StoreResult{} + + if _, err := transaction.Exec("DELETE FROM OAuthAccessData WHERE ClientId = :Id", map[string]interface{}{"Id": clientId}); err != nil { + result.Err = model.NewLocAppError("SqlOAuthStore.DeleteApp", "store.sql_oauth.delete_app.app_error", nil, "id="+clientId+", err="+err.Error()) + return result + } + + return as.deleteAppExtras(transaction, clientId) +} + +func (as SqlOAuthStore) deleteAppExtras(transaction *gorp.Transaction, clientId string) StoreResult { + result := StoreResult{} + + if _, err := transaction.Exec( + `DELETE FROM + Preferences + WHERE + Category = :Category + AND Name = :Name`, map[string]interface{}{"Category": model.PREFERENCE_CATEGORY_AUTHORIZED_OAUTH_APP, "Name": clientId}); err != nil { + result.Err = model.NewLocAppError("SqlOAuthStore.DeleteApp", "store.sql_preference.delete.app_error", nil, err.Error()) + return result + } + + return result +} diff --git a/store/sql_oauth_store_test.go b/store/sql_oauth_store_test.go index c3f6ea7ac..a88b0ea48 100644 --- a/store/sql_oauth_store_test.go +++ b/store/sql_oauth_store_test.go @@ -39,6 +39,10 @@ func TestOAuthStoreGetApp(t *testing.T) { if err := (<-store.OAuth().GetAppByUser(a1.CreatorId)).Err; err != nil { t.Fatal(err) } + + if err := (<-store.OAuth().GetApps()).Err; err != nil { + t.Fatal(err) + } } func TestOAuthStoreUpdateApp(t *testing.T) { @@ -78,7 +82,8 @@ func TestOAuthStoreSaveAccessData(t *testing.T) { Setup() a1 := model.AccessData{} - a1.AuthCode = model.NewId() + a1.ClientId = model.NewId() + a1.UserId = model.NewId() a1.Token = model.NewId() a1.RefreshToken = model.NewId() @@ -91,9 +96,11 @@ func TestOAuthStoreGetAccessData(t *testing.T) { Setup() a1 := model.AccessData{} - a1.AuthCode = model.NewId() + a1.ClientId = model.NewId() + a1.UserId = model.NewId() a1.Token = model.NewId() a1.RefreshToken = model.NewId() + a1.ExpiresAt = model.GetMillis() Must(store.OAuth().SaveAccessData(&a1)) if result := <-store.OAuth().GetAccessData(a1.Token); result.Err != nil { @@ -105,11 +112,11 @@ func TestOAuthStoreGetAccessData(t *testing.T) { } } - if err := (<-store.OAuth().GetAccessDataByAuthCode(a1.AuthCode)).Err; err != nil { + if err := (<-store.OAuth().GetPreviousAccessData(a1.UserId, a1.ClientId)).Err; err != nil { t.Fatal(err) } - if err := (<-store.OAuth().GetAccessDataByAuthCode("junk")).Err; err != nil { + if err := (<-store.OAuth().GetPreviousAccessData("user", "junk")).Err; err != nil { t.Fatal(err) } } @@ -118,7 +125,8 @@ func TestOAuthStoreRemoveAccessData(t *testing.T) { Setup() a1 := model.AccessData{} - a1.AuthCode = model.NewId() + a1.ClientId = model.NewId() + a1.UserId = model.NewId() a1.Token = model.NewId() a1.RefreshToken = model.NewId() Must(store.OAuth().SaveAccessData(&a1)) @@ -127,8 +135,7 @@ func TestOAuthStoreRemoveAccessData(t *testing.T) { t.Fatal(err) } - if result := <-store.OAuth().GetAccessDataByAuthCode(a1.AuthCode); result.Err != nil { - t.Fatal(result.Err) + if result := (<-store.OAuth().GetPreviousAccessData(a1.UserId, a1.ClientId)); result.Err != nil { } else { if result.Data != nil { t.Fatal("did not delete access token") @@ -194,3 +201,16 @@ func TestOAuthStoreRemoveAuthDataByUser(t *testing.T) { t.Fatal(err) } } + +func TestOAuthStoreDeleteApp(t *testing.T) { + a1 := model.OAuthApp{} + a1.CreatorId = model.NewId() + a1.Name = "TestApp" + model.NewId() + a1.CallbackUrls = []string{"https://nowhere.com"} + a1.Homepage = "https://nowhere.com" + Must(store.OAuth().SaveApp(&a1)) + + if err := (<-store.OAuth().DeleteApp(a1.Id)).Err; err != nil { + t.Fatal(err) + } +} diff --git a/store/store.go b/store/store.go index ac424a944..66cc05214 100644 --- a/store/store.go +++ b/store/store.go @@ -186,13 +186,17 @@ type OAuthStore interface { UpdateApp(app *model.OAuthApp) StoreChannel GetApp(id string) StoreChannel GetAppByUser(userId string) StoreChannel + GetApps() StoreChannel + DeleteApp(id string) StoreChannel SaveAuthData(authData *model.AuthData) StoreChannel GetAuthData(code string) StoreChannel RemoveAuthData(code string) StoreChannel PermanentDeleteAuthDataByUser(userId string) StoreChannel SaveAccessData(accessData *model.AccessData) StoreChannel + UpdateAccessData(accessData *model.AccessData) StoreChannel GetAccessData(token string) StoreChannel - GetAccessDataByAuthCode(authCode string) StoreChannel + GetAccessDataByRefreshToken(token string) StoreChannel + GetPreviousAccessData(userId, clientId string) StoreChannel RemoveAccessData(token string) StoreChannel } diff --git a/templates/authorize.html b/templates/authorize.html deleted file mode 100644 index 0fa36b0ab..000000000 --- a/templates/authorize.html +++ /dev/null @@ -1,12 +0,0 @@ -{{define "authorize"}} - -{{template "head" . }} - -
-
- - - -{{end}} diff --git a/web/web_test.go b/web/web_test.go index 40eba5ff2..5f74430fa 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -72,122 +72,125 @@ func TestGetAccessToken(t *testing.T) { app := &model.OAuthApp{Name: "TestApp" + model.NewId(), Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} - if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { - data := url.Values{"grant_type": []string{"junk"}, "client_id": []string{"12345678901234567890123456"}, "client_secret": []string{"12345678901234567890123456"}, "code": []string{"junk"}, "redirect_uri": []string{app.CallbackUrls[0]}} + utils.Cfg.ServiceSettings.EnableOAuthServiceProvider = false + data := url.Values{"grant_type": []string{"junk"}, "client_id": []string{"12345678901234567890123456"}, "client_secret": []string{"12345678901234567890123456"}, "code": []string{"junk"}, "redirect_uri": []string{app.CallbackUrls[0]}} - if _, err := ApiClient.GetAccessToken(data); err == nil { - t.Fatal("should have failed - oauth providing turned off") - } - } else { + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - oauth providing turned off") + } + utils.Cfg.ServiceSettings.EnableOAuthServiceProvider = true - ApiClient.Must(ApiClient.LoginById(ruser.Id, "passwd1")) - ApiClient.SetTeamId(rteam.Data.(*model.Team).Id) - app = ApiClient.Must(ApiClient.RegisterApp(app)).Data.(*model.OAuthApp) + ApiClient.Must(ApiClient.LoginById(ruser.Id, "passwd1")) + ApiClient.SetTeamId(rteam.Data.(*model.Team).Id) + *utils.Cfg.ServiceSettings.EnableOnlyAdminIntegrations = false + app = ApiClient.Must(ApiClient.RegisterApp(app)).Data.(*model.OAuthApp) + *utils.Cfg.ServiceSettings.EnableOnlyAdminIntegrations = true - redirect := ApiClient.Must(ApiClient.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, app.CallbackUrls[0], "all", "123")).Data.(map[string]string)["redirect"] - rurl, _ := url.Parse(redirect) + redirect := ApiClient.Must(ApiClient.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, app.CallbackUrls[0], "all", "123")).Data.(map[string]string)["redirect"] + rurl, _ := url.Parse(redirect) - ApiClient.Logout() + teamId := rteam.Data.(*model.Team).Id - data := url.Values{"grant_type": []string{"junk"}, "client_id": []string{app.Id}, "client_secret": []string{app.ClientSecret}, "code": []string{rurl.Query().Get("code")}, "redirect_uri": []string{app.CallbackUrls[0]}} + ApiClient.Logout() - if _, err := ApiClient.GetAccessToken(data); err == nil { - t.Fatal("should have failed - bad grant type") - } + data = url.Values{"grant_type": []string{"junk"}, "client_id": []string{app.Id}, "client_secret": []string{app.ClientSecret}, "code": []string{rurl.Query().Get("code")}, "redirect_uri": []string{app.CallbackUrls[0]}} - data.Set("grant_type", model.ACCESS_TOKEN_GRANT_TYPE) - data.Set("client_id", "") - if _, err := ApiClient.GetAccessToken(data); err == nil { - t.Fatal("should have failed - missing client id") - } - data.Set("client_id", "junk") - if _, err := ApiClient.GetAccessToken(data); err == nil { - t.Fatal("should have failed - bad client id") - } + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - bad grant type") + } - data.Set("client_id", app.Id) - data.Set("client_secret", "") - if _, err := ApiClient.GetAccessToken(data); err == nil { - t.Fatal("should have failed - missing client secret") - } + data.Set("grant_type", model.ACCESS_TOKEN_GRANT_TYPE) + data.Set("client_id", "") + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - missing client id") + } + data.Set("client_id", "junk") + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - bad client id") + } - data.Set("client_secret", "junk") - if _, err := ApiClient.GetAccessToken(data); err == nil { - t.Fatal("should have failed - bad client secret") - } + data.Set("client_id", app.Id) + data.Set("client_secret", "") + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - missing client secret") + } - data.Set("client_secret", app.ClientSecret) - data.Set("code", "") - if _, err := ApiClient.GetAccessToken(data); err == nil { - t.Fatal("should have failed - missing code") - } + data.Set("client_secret", "junk") + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - bad client secret") + } - data.Set("code", "junk") - if _, err := ApiClient.GetAccessToken(data); err == nil { - t.Fatal("should have failed - bad code") - } + data.Set("client_secret", app.ClientSecret) + data.Set("code", "") + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - missing code") + } - data.Set("code", rurl.Query().Get("code")) - data.Set("redirect_uri", "junk") - if _, err := ApiClient.GetAccessToken(data); err == nil { - t.Fatal("should have failed - non-matching redirect uri") - } + data.Set("code", "junk") + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - bad code") + } - // reset data for successful request - data.Set("grant_type", model.ACCESS_TOKEN_GRANT_TYPE) - data.Set("client_id", app.Id) - data.Set("client_secret", app.ClientSecret) - data.Set("code", rurl.Query().Get("code")) - data.Set("redirect_uri", app.CallbackUrls[0]) + data.Set("code", rurl.Query().Get("code")) + data.Set("redirect_uri", "junk") + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - non-matching redirect uri") + } - token := "" - if result, err := ApiClient.GetAccessToken(data); err != nil { - t.Fatal(err) - } else { - rsp := result.Data.(*model.AccessResponse) - if len(rsp.AccessToken) == 0 { - t.Fatal("access token not returned") - } else { - token = rsp.AccessToken - } - if rsp.TokenType != model.ACCESS_TOKEN_TYPE { - t.Fatal("access token type incorrect") - } - } + // reset data for successful request + data.Set("grant_type", model.ACCESS_TOKEN_GRANT_TYPE) + data.Set("client_id", app.Id) + data.Set("client_secret", app.ClientSecret) + data.Set("code", rurl.Query().Get("code")) + data.Set("redirect_uri", app.CallbackUrls[0]) - if result, err := ApiClient.DoApiGet("/users/profiles?access_token="+token, "", ""); err != nil { - t.Fatal(err) + token := "" + if result, err := ApiClient.GetAccessToken(data); err != nil { + t.Fatal(err) + } else { + rsp := result.Data.(*model.AccessResponse) + if len(rsp.AccessToken) == 0 { + t.Fatal("access token not returned") } else { - userMap := model.UserMapFromJson(result.Body) - if len(userMap) == 0 { - t.Fatal("user map empty - did not get results correctly") - } + token = rsp.AccessToken } - - if _, err := ApiClient.DoApiGet("/users/profiles", "", ""); err == nil { - t.Fatal("should have failed - no access token provided") + if rsp.TokenType != model.ACCESS_TOKEN_TYPE { + t.Fatal("access token type incorrect") } + } - if _, err := ApiClient.DoApiGet("/users/profiles?access_token=junk", "", ""); err == nil { - t.Fatal("should have failed - bad access token provided") + if result, err := ApiClient.DoApiGet("/users/profiles/"+teamId+"?access_token="+token, "", ""); err != nil { + t.Fatal(err) + } else { + userMap := model.UserMapFromJson(result.Body) + if len(userMap) == 0 { + t.Fatal("user map empty - did not get results correctly") } + } - ApiClient.SetOAuthToken(token) - if result, err := ApiClient.DoApiGet("/users/profiles", "", ""); err != nil { - t.Fatal(err) - } else { - userMap := model.UserMapFromJson(result.Body) - if len(userMap) == 0 { - t.Fatal("user map empty - did not get results correctly") - } - } + if _, err := ApiClient.DoApiGet("/users/profiles/"+teamId, "", ""); err == nil { + t.Fatal("should have failed - no access token provided") + } - if _, err := ApiClient.GetAccessToken(data); err == nil { - t.Fatal("should have failed - tried to reuse auth code") + if _, err := ApiClient.DoApiGet("/users/profiles/"+teamId+"?access_token=junk", "", ""); err == nil { + t.Fatal("should have failed - bad access token provided") + } + + ApiClient.SetOAuthToken(token) + if result, err := ApiClient.DoApiGet("/users/profiles/"+teamId, "", ""); err != nil { + t.Fatal(err) + } else { + userMap := model.UserMapFromJson(result.Body) + if len(userMap) == 0 { + t.Fatal("user map empty - did not get results correctly") } + } - ApiClient.ClearOAuthToken() + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - tried to reuse auth code") } + + ApiClient.ClearOAuthToken() } func TestIncomingWebhook(t *testing.T) { diff --git a/webapp/actions/global_actions.jsx b/webapp/actions/global_actions.jsx index ba92255ce..829424c1f 100644 --- a/webapp/actions/global_actions.jsx +++ b/webapp/actions/global_actions.jsx @@ -308,13 +308,6 @@ export function showLeaveTeamModal() { }); } -export function showRegisterAppModal() { - AppDispatcher.handleViewAction({ - type: ActionTypes.TOGGLE_REGISTER_APP_MODAL, - value: true - }); -} - export function emitSuggestionPretextChanged(suggestionId, pretext) { AppDispatcher.handleViewAction({ type: ActionTypes.SUGGESTION_PRETEXT_CHANGED, diff --git a/webapp/actions/oauth_actions.jsx b/webapp/actions/oauth_actions.jsx new file mode 100644 index 000000000..d2e5b0c98 --- /dev/null +++ b/webapp/actions/oauth_actions.jsx @@ -0,0 +1,60 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Client from 'client/web_client.jsx'; +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import Constants from 'utils/constants.jsx'; + +const ActionTypes = Constants.ActionTypes; + +export function listOAuthApps(userId, onSuccess, onError) { + Client.listOAuthApps( + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_OAUTHAPPS, + userId, + oauthApps: data + }); + + if (onSuccess) { + onSuccess(data); + } + }, + onError + ); +} + +export function deleteOAuthApp(id, userId, onSuccess, onError) { + Client.deleteOAuthApp( + id, + () => { + AppDispatcher.handleServerAction({ + type: ActionTypes.REMOVED_OAUTHAPP, + userId, + id + }); + + if (onSuccess) { + onSuccess(); + } + }, + onError + ); +} + +export function registerOAuthApp(app, onSuccess, onError) { + Client.registerOAuthApp( + app, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_OAUTHAPP, + oauthApp: data + }); + + if (onSuccess) { + onSuccess(); + } + }, + onError + ); +} \ No newline at end of file diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx index cf015bc84..b200b2379 100644 --- a/webapp/client/client.jsx +++ b/webapp/client/client.jsx @@ -1498,6 +1498,36 @@ export default class Client { end(this.handleResponse.bind(this, 'allowOAuth2', success, error)); } + listOAuthApps(success, error) { + request. + get(`${this.getOAuthRoute()}/list`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send(). + end(this.handleResponse.bind(this, 'getOAuthApps', success, error)); + } + + deleteOAuthApp(id, success, error) { + request. + post(`${this.getOAuthRoute()}/delete`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send({id}). + end(this.handleResponse.bind(this, 'deleteOAuthApp', success, error)); + } + + getOAuthAppInfo(id, success, error) { + request. + get(`${this.getOAuthRoute()}/app/${id}`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send(). + end(this.handleResponse.bind(this, 'getOAuthAppInfo', success, error)); + } + // Routes for Hooks addIncomingHook(hook, success, error) { diff --git a/webapp/components/admin_console/admin_settings.jsx b/webapp/components/admin_console/admin_settings.jsx index d670d599d..8601722eb 100644 --- a/webapp/components/admin_console/admin_settings.jsx +++ b/webapp/components/admin_console/admin_settings.jsx @@ -8,7 +8,6 @@ import Client from 'client/web_client.jsx'; import FormError from 'components/form_error.jsx'; import SaveButton from 'components/admin_console/save_button.jsx'; -import Constants from 'utils/constants.jsx'; export default class AdminSettings extends React.Component { static get propTypes() { @@ -22,7 +21,6 @@ export default class AdminSettings extends React.Component { this.handleChange = this.handleChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); - this.onKeyDown = this.onKeyDown.bind(this); this.state = Object.assign(this.getStateFromConfig(props.config), { saveNeeded: false, @@ -38,20 +36,6 @@ export default class AdminSettings extends React.Component { }); } - componentDidMount() { - document.addEventListener('keydown', this.onKeyDown); - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.onKeyDown); - } - - onKeyDown(e) { - if (e.keyCode === Constants.KeyCodes.ENTER) { - this.handleSubmit(e); - } - } - handleSubmit(e) { e.preventDefault(); @@ -118,6 +102,7 @@ export default class AdminSettings extends React.Component {
{this.renderSettings()}
diff --git a/webapp/components/admin_console/admin_sidebar.jsx b/webapp/components/admin_console/admin_sidebar.jsx index d812b83fd..6634d4ac6 100644 --- a/webapp/components/admin_console/admin_sidebar.jsx +++ b/webapp/components/admin_console/admin_sidebar.jsx @@ -521,11 +521,11 @@ export default class AdminSidebar extends React.Component { } > } /> diff --git a/webapp/components/admin_console/custom_integrations_settings.jsx b/webapp/components/admin_console/custom_integrations_settings.jsx new file mode 100644 index 000000000..cfa1a30ae --- /dev/null +++ b/webapp/components/admin_console/custom_integrations_settings.jsx @@ -0,0 +1,180 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import AdminSettings from './admin_settings.jsx'; +import BooleanSetting from './boolean_setting.jsx'; +import {FormattedHTMLMessage, FormattedMessage} from 'react-intl'; +import SettingsGroup from './settings_group.jsx'; + +export default class WebhookSettings extends AdminSettings { + constructor(props) { + super(props); + + this.getConfigFromState = this.getConfigFromState.bind(this); + + this.renderSettings = this.renderSettings.bind(this); + } + + getConfigFromState(config) { + config.ServiceSettings.EnableIncomingWebhooks = this.state.enableIncomingWebhooks; + config.ServiceSettings.EnableOutgoingWebhooks = this.state.enableOutgoingWebhooks; + config.ServiceSettings.EnableCommands = this.state.enableCommands; + config.ServiceSettings.EnableOnlyAdminIntegrations = this.state.enableOnlyAdminIntegrations; + config.ServiceSettings.EnablePostUsernameOverride = this.state.enablePostUsernameOverride; + config.ServiceSettings.EnablePostIconOverride = this.state.enablePostIconOverride; + config.ServiceSettings.EnableOAuthServiceProvider = this.state.enableOAuthServiceProvider; + + return config; + } + + getStateFromConfig(config) { + return { + enableIncomingWebhooks: config.ServiceSettings.EnableIncomingWebhooks, + enableOutgoingWebhooks: config.ServiceSettings.EnableOutgoingWebhooks, + enableCommands: config.ServiceSettings.EnableCommands, + enableOnlyAdminIntegrations: config.ServiceSettings.EnableOnlyAdminIntegrations, + enablePostUsernameOverride: config.ServiceSettings.EnablePostUsernameOverride, + enablePostIconOverride: config.ServiceSettings.EnablePostIconOverride, + enableOAuthServiceProvider: config.ServiceSettings.EnableOAuthServiceProvider + }; + } + + renderTitle() { + return ( +

+ +

+ ); + } + + renderSettings() { + return ( + + + } + helpText={ + + } + value={this.state.enableIncomingWebhooks} + onChange={this.handleChange} + /> + + } + helpText={ + + } + value={this.state.enableOutgoingWebhooks} + onChange={this.handleChange} + /> + + } + helpText={ + + } + value={this.state.enableCommands} + onChange={this.handleChange} + /> + + } + helpText={ + + } + value={this.state.enableOAuthServiceProvider} + onChange={this.handleChange} + /> + + } + helpText={ + + } + value={this.state.enableOnlyAdminIntegrations} + onChange={this.handleChange} + /> + + } + helpText={ + + } + value={this.state.enablePostUsernameOverride} + onChange={this.handleChange} + /> + + } + helpText={ + + } + value={this.state.enablePostIconOverride} + onChange={this.handleChange} + /> + + ); + } +} diff --git a/webapp/components/admin_console/webhook_settings.jsx b/webapp/components/admin_console/webhook_settings.jsx deleted file mode 100644 index ba2443442..000000000 --- a/webapp/components/admin_console/webhook_settings.jsx +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import React from 'react'; - -import AdminSettings from './admin_settings.jsx'; -import BooleanSetting from './boolean_setting.jsx'; -import {FormattedHTMLMessage, FormattedMessage} from 'react-intl'; -import SettingsGroup from './settings_group.jsx'; - -export default class WebhookSettings extends AdminSettings { - constructor(props) { - super(props); - - this.getConfigFromState = this.getConfigFromState.bind(this); - - this.renderSettings = this.renderSettings.bind(this); - } - - getConfigFromState(config) { - config.ServiceSettings.EnableIncomingWebhooks = this.state.enableIncomingWebhooks; - config.ServiceSettings.EnableOutgoingWebhooks = this.state.enableOutgoingWebhooks; - config.ServiceSettings.EnableCommands = this.state.enableCommands; - config.ServiceSettings.EnableOnlyAdminIntegrations = this.state.enableOnlyAdminIntegrations; - config.ServiceSettings.EnablePostUsernameOverride = this.state.enablePostUsernameOverride; - config.ServiceSettings.EnablePostIconOverride = this.state.enablePostIconOverride; - - return config; - } - - getStateFromConfig(config) { - return { - enableIncomingWebhooks: config.ServiceSettings.EnableIncomingWebhooks, - enableOutgoingWebhooks: config.ServiceSettings.EnableOutgoingWebhooks, - enableCommands: config.ServiceSettings.EnableCommands, - enableOnlyAdminIntegrations: config.ServiceSettings.EnableOnlyAdminIntegrations, - enablePostUsernameOverride: config.ServiceSettings.EnablePostUsernameOverride, - enablePostIconOverride: config.ServiceSettings.EnablePostIconOverride - }; - } - - renderTitle() { - return ( -

- -

- ); - } - - renderSettings() { - return ( - - - } - helpText={ - - } - value={this.state.enableIncomingWebhooks} - onChange={this.handleChange} - /> - - } - helpText={ - - } - value={this.state.enableOutgoingWebhooks} - onChange={this.handleChange} - /> - - } - helpText={ - - } - value={this.state.enableCommands} - onChange={this.handleChange} - /> - - } - helpText={ - - } - value={this.state.enableOnlyAdminIntegrations} - onChange={this.handleChange} - /> - - } - helpText={ - - } - value={this.state.enablePostUsernameOverride} - onChange={this.handleChange} - /> - - } - helpText={ - - } - value={this.state.enablePostIconOverride} - onChange={this.handleChange} - /> - - ); - } -} diff --git a/webapp/components/authorize.jsx b/webapp/components/authorize.jsx index 49ca0f36b..354b51ede 100644 --- a/webapp/components/authorize.jsx +++ b/webapp/components/authorize.jsx @@ -10,6 +10,13 @@ import React from 'react'; import icon50 from 'images/icon50x50.png'; export default class Authorize extends React.Component { + static get propTypes() { + return { + location: React.PropTypes.object.isRequired, + params: React.PropTypes.object.isRequired + }; + } + constructor(props) { super(props); @@ -18,17 +25,31 @@ export default class Authorize extends React.Component { this.state = {}; } + + componentWillMount() { + Client.getOAuthAppInfo( + this.props.location.query.client_id, + (app) => { + this.setState({app}); + } + ); + } + + componentDidMount() { + // if we get to this point remove the antiClickjack blocker + const blocker = document.getElementById('antiClickjack'); + if (blocker) { + blocker.parentNode.removeChild(blocker); + } + } + handleAllow() { - const responseType = this.props.responseType; - const clientId = this.props.clientId; - const redirectUri = this.props.redirectUri; - const state = this.props.state; - const scope = this.props.scope; + const params = this.props.location.query; - Client.allowOAuth2(responseType, clientId, redirectUri, state, scope, + Client.allowOAuth2(params.response_type, params.client_id, params.redirect_uri, params.state, params.scope, (data) => { if (data.redirect) { - window.location.replace(data.redirect); + window.location.href = data.redirect; } }, () => { @@ -36,28 +57,42 @@ export default class Authorize extends React.Component { } ); } + handleDeny() { - window.location.replace(this.props.redirectUri + '?error=access_denied'); + window.location.replace(this.props.location.query.redirect_uri + '?error=access_denied'); } + render() { + const app = this.state.app; + if (!app) { + return null; + } + + let icon; + if (app.icon_url) { + icon = app.icon_url; + } else { + icon = icon50; + } + return (
-
@@ -67,7 +102,7 @@ export default class Authorize extends React.Component { id='authorize.app' defaultMessage='The app {appName} would like the ability to access and modify your basic information.' values={{ - appName: this.props.appName + appName: app.name }} />

@@ -76,14 +111,14 @@ export default class Authorize extends React.Component { id='authorize.access' defaultMessage='Allow {appName} access?' values={{ - appName: this.props.appName + appName: app.name }} />