diff --git a/Makefile b/Makefile
index 44598aef81..8eb0277075 100644
--- a/Makefile
+++ b/Makefile
@@ -159,7 +159,7 @@ test-server: start-docker prepare-enterprise
rm -f cover.out
echo "mode: count" > cover.out
- $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=440s -covermode=count -coverprofile=capi.out ./api || exit 1
+ $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=650s -covermode=count -coverprofile=capi.out ./api || exit 1
$(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=60s -covermode=count -coverprofile=cmodel.out ./model || exit 1
$(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=180s -covermode=count -coverprofile=cstore.out ./store || exit 1
$(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=120s -covermode=count -coverprofile=cutils.out ./utils || exit 1
diff --git a/api/oauth.go b/api/oauth.go
index 546b0bdcac..6e7649d8d4 100644
--- a/api/oauth.go
+++ b/api/oauth.go
@@ -29,7 +29,9 @@ func InitOAuth() {
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("/authorized", ApiUserRequired(getAuthorizedApps)).Methods("GET")
BaseRoutes.OAuth.Handle("/delete", ApiUserRequired(deleteOAuthApp)).Methods("POST")
+ BaseRoutes.OAuth.Handle("/{id:[A-Za-z0-9]+}/deauthorize", AppHandlerIndependent(deauthorizeOAuthApp)).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")
@@ -227,6 +229,28 @@ func allowOAuth(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write([]byte(model.MapToJson(responseData)))
}
+func getAuthorizedApps(c *Context, w http.ResponseWriter, r *http.Request) {
+ if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider {
+ c.Err = model.NewLocAppError("getAuthorizedApps", "api.oauth.allow_oauth.turn_off.app_error", nil, "")
+ c.Err.StatusCode = http.StatusNotImplemented
+ return
+ }
+
+ ochan := Srv.Store.OAuth().GetAuthorizedApps(c.Session.UserId)
+ if result := <-ochan; result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ apps := result.Data.([]*model.OAuthApp)
+ for k, a := range apps {
+ a.Sanitize()
+ apps[k] = a
+ }
+
+ w.Write([]byte(model.OAuthAppListToJson(apps)))
+ }
+}
+
func RevokeAccessToken(token string) *model.AppError {
schan := Srv.Store.Session().Remove(token)
@@ -879,6 +903,51 @@ func deleteOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) {
ReturnStatusOK(w)
}
+func deauthorizeOAuthApp(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
+ }
+
+ params := mux.Vars(r)
+ id := params["id"]
+
+ if len(id) == 0 {
+ c.SetInvalidParam("deauthorizeOAuthApp", "id")
+ return
+ }
+
+ // revoke app sessions
+ if result := <-Srv.Store.OAuth().GetAccessDataByUserForApp(c.Session.UserId, id); result.Err != nil {
+ c.Err = result.Err
+ return
+ } else {
+ accessData := result.Data.([]*model.AccessData)
+
+ for _, a := range accessData {
+ if err := RevokeAccessToken(a.Token); err != nil {
+ c.Err = err
+ return
+ }
+
+ if rad := <-Srv.Store.OAuth().RemoveAccessData(a.Token); rad.Err != nil {
+ c.Err = rad.Err
+ return
+ }
+ }
+ }
+
+ // Deauthorize the app
+ if err := (<-Srv.Store.Preference().Delete(c.Session.UserId, model.PREFERENCE_CATEGORY_AUTHORIZED_OAUTH_APP, 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}
diff --git a/api/oauth_test.go b/api/oauth_test.go
index 47fc342ce6..944b1a95b1 100644
--- a/api/oauth_test.go
+++ b/api/oauth_test.go
@@ -222,6 +222,62 @@ func TestGetOAuthAppInfo(t *testing.T) {
}
}
+func TestGetAuthorizedApps(t *testing.T) {
+ th := Setup().InitBasic().InitSystemAdmin()
+ Client := th.BasicClient
+ AdminClient := th.SystemAdminClient
+
+ 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.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, "https://nowhere.com", "user", ""); err != nil {
+ t.Fatal(err)
+ }
+
+ if result, err := Client.GetOAuthAuthorizedApps(); 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")
+ }
+ }
+}
+
+func TestDeauthorizeApp(t *testing.T) {
+ th := Setup().InitBasic().InitSystemAdmin()
+ Client := th.BasicClient
+ AdminClient := th.SystemAdminClient
+
+ 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.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, "https://nowhere.com", "user", ""); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Client.OAuthDeauthorizeApp(app.Id); err != nil {
+ t.Fatal(err)
+ }
+
+ if result, err := Client.GetOAuthAuthorizedApps(); 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")
+ }
+ }
+}
+
func TestOAuthDeleteApp(t *testing.T) {
th := Setup().InitBasic().InitSystemAdmin()
Client := th.BasicClient
diff --git a/i18n/en.json b/i18n/en.json
index a75909b95a..d026a60666 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -3931,6 +3931,10 @@
"id": "store.sql_oauth.get_access_data.app_error",
"translation": "We encountered an error finding the access token"
},
+ {
+ "id": "store.sql_oauth.get_access_data_by_user_for_app.app_error",
+ "translation": "We encountered an error finding all the access tokens"
+ },
{
"id": "store.sql_oauth.get_app.find.app_error",
"translation": "We couldn't find the requested app"
diff --git a/model/client.go b/model/client.go
index 2d154e49f9..7ef35ef6ff 100644
--- a/model/client.go
+++ b/model/client.go
@@ -1532,6 +1532,29 @@ func (c *Client) DeleteOAuthApp(id string) (*Result, *AppError) {
}
}
+// GetOAuthAuthorizedApps returns the OAuth2 Apps authorized by the user. On success
+// it returns a list of sanitized OAuth2 Authorized Apps by the user.
+func (c *Client) GetOAuthAuthorizedApps() (*Result, *AppError) {
+ if r, err := c.DoApiGet("/oauth/authorized", "", ""); 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
+ }
+}
+
+// OAuthDeauthorizeApp deauthorize a user an OAuth 2.0 app. On success
+// it returns status OK or an AppError on fail.
+func (c *Client) OAuthDeauthorizeApp(clientId string) *AppError {
+ if r, err := c.DoApiPost("/oauth/"+clientId+"/deauthorize", ""); err != nil {
+ return err
+ } else {
+ defer closeBody(r)
+ return nil
+ }
+}
+
func (c *Client) GetAccessToken(data url.Values) (*Result, *AppError) {
if r, err := c.DoPost("/oauth/access_token", data.Encode(), "application/x-www-form-urlencoded"); err != nil {
return nil, err
diff --git a/store/sql_oauth_store.go b/store/sql_oauth_store.go
index 6db54bd4a5..0ee9f1ad1d 100644
--- a/store/sql_oauth_store.go
+++ b/store/sql_oauth_store.go
@@ -211,6 +211,29 @@ func (as SqlOAuthStore) GetApps() StoreChannel {
return storeChannel
}
+func (as SqlOAuthStore) GetAuthorizedApps(userId string) StoreChannel {
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var apps []*model.OAuthApp
+
+ if _, err := as.GetReplica().Select(&apps,
+ `SELECT o.* FROM OAuthApps AS o INNER JOIN
+ Preferences AS p ON p.Name=o.Id AND p.UserId=:UserId`, map[string]interface{}{"UserId": userId}); err != nil {
+ result.Err = model.NewLocAppError("SqlOAuthStore.GetAuthorizedApps", "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)
@@ -294,6 +317,33 @@ func (as SqlOAuthStore) GetAccessData(token string) StoreChannel {
return storeChannel
}
+func (as SqlOAuthStore) GetAccessDataByUserForApp(userId, clientId string) StoreChannel {
+
+ storeChannel := make(StoreChannel)
+
+ go func() {
+ result := StoreResult{}
+
+ var accessData []*model.AccessData
+
+ if _, err := as.GetReplica().Select(&accessData,
+ "SELECT * FROM OAuthAccessData WHERE UserId = :UserId AND ClientId = :ClientId",
+ map[string]interface{}{"UserId": userId, "ClientId": clientId}); err != nil {
+ result.Err = model.NewLocAppError("SqlOAuthStore.GetAccessDataByUserForApp",
+ "store.sql_oauth.get_access_data_by_user_for_app.app_error", nil,
+ "user_id="+userId+" client_id="+clientId)
+ } else {
+ result.Data = accessData
+ }
+
+ storeChannel <- result
+ close(storeChannel)
+
+ }()
+
+ return storeChannel
+}
+
func (as SqlOAuthStore) GetAccessDataByRefreshToken(token string) StoreChannel {
storeChannel := make(StoreChannel)
diff --git a/store/sql_oauth_store_test.go b/store/sql_oauth_store_test.go
index a88b0ea48c..ebf9ad59b8 100644
--- a/store/sql_oauth_store_test.go
+++ b/store/sql_oauth_store_test.go
@@ -202,6 +202,82 @@ func TestOAuthStoreRemoveAuthDataByUser(t *testing.T) {
}
}
+func TestOAuthGetAuthorizedApps(t *testing.T) {
+ Setup()
+
+ 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))
+
+ // allow the app
+ p := model.Preference{}
+ p.UserId = a1.CreatorId
+ p.Category = model.PREFERENCE_CATEGORY_AUTHORIZED_OAUTH_APP
+ p.Name = a1.Id
+ p.Value = "true"
+ Must(store.Preference().Save(&model.Preferences{p}))
+
+ if result := <-store.OAuth().GetAuthorizedApps(a1.CreatorId); result.Err != nil {
+ t.Fatal(result.Err)
+ } else {
+ apps := result.Data.([]*model.OAuthApp)
+ if len(apps) == 0 {
+ t.Fatal("It should have return apps")
+ }
+ }
+}
+
+func TestOAuthGetAccessDataByUserForApp(t *testing.T) {
+ Setup()
+
+ 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))
+
+ // allow the app
+ p := model.Preference{}
+ p.UserId = a1.CreatorId
+ p.Category = model.PREFERENCE_CATEGORY_AUTHORIZED_OAUTH_APP
+ p.Name = a1.Id
+ p.Value = "true"
+ Must(store.Preference().Save(&model.Preferences{p}))
+
+ if result := <-store.OAuth().GetAuthorizedApps(a1.CreatorId); result.Err != nil {
+ t.Fatal(result.Err)
+ } else {
+ apps := result.Data.([]*model.OAuthApp)
+ if len(apps) == 0 {
+ t.Fatal("It should have return apps")
+ }
+ }
+
+ // save the token
+ ad1 := model.AccessData{}
+ ad1.ClientId = a1.Id
+ ad1.UserId = a1.CreatorId
+ ad1.Token = model.NewId()
+ ad1.RefreshToken = model.NewId()
+
+ if err := (<-store.OAuth().SaveAccessData(&ad1)).Err; err != nil {
+ t.Fatal(err)
+ }
+
+ if result := <-store.OAuth().GetAccessDataByUserForApp(a1.CreatorId, a1.Id); result.Err != nil {
+ t.Fatal(result.Err)
+ } else {
+ accessData := result.Data.([]*model.AccessData)
+ if len(accessData) == 0 {
+ t.Fatal("It should have return access data")
+ }
+ }
+}
+
func TestOAuthStoreDeleteApp(t *testing.T) {
a1 := model.OAuthApp{}
a1.CreatorId = model.NewId()
diff --git a/store/store.go b/store/store.go
index b9a55fa2eb..78db41e770 100644
--- a/store/store.go
+++ b/store/store.go
@@ -188,6 +188,7 @@ type OAuthStore interface {
GetApp(id string) StoreChannel
GetAppByUser(userId string) StoreChannel
GetApps() StoreChannel
+ GetAuthorizedApps(userId string) StoreChannel
DeleteApp(id string) StoreChannel
SaveAuthData(authData *model.AuthData) StoreChannel
GetAuthData(code string) StoreChannel
@@ -196,6 +197,7 @@ type OAuthStore interface {
SaveAccessData(accessData *model.AccessData) StoreChannel
UpdateAccessData(accessData *model.AccessData) StoreChannel
GetAccessData(token string) StoreChannel
+ GetAccessDataByUserForApp(userId, clientId string) StoreChannel
GetAccessDataByRefreshToken(token string) StoreChannel
GetPreviousAccessData(userId, clientId string) StoreChannel
RemoveAccessData(token string) StoreChannel
diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx
index 28d1210111..5dda975f6e 100644
--- a/webapp/client/client.jsx
+++ b/webapp/client/client.jsx
@@ -1553,6 +1553,26 @@ export default class Client {
end(this.handleResponse.bind(this, 'getOAuthAppInfo', success, error));
}
+ getAuthorizedApps(success, error) {
+ request.
+ get(`${this.getOAuthRoute()}/authorized`).
+ set(this.defaultHeaders).
+ type('application/json').
+ accept('application/json').
+ send().
+ end(this.handleResponse.bind(this, 'getAuthorizedApps', success, error));
+ }
+
+ deauthorizeOAuthApp(id, success, error) {
+ request.
+ post(`${this.getOAuthRoute()}/${id}/deauthorize`).
+ set(this.defaultHeaders).
+ type('application/json').
+ accept('application/json').
+ send().
+ end(this.handleResponse.bind(this, 'deauthorizeOAuthApp', success, error));
+ }
+
// Routes for Hooks
addIncomingHook(hook, success, error) {
diff --git a/webapp/components/user_settings/user_settings_security.jsx b/webapp/components/user_settings/user_settings_security.jsx
index 769959432a..bcffa157c9 100644
--- a/webapp/components/user_settings/user_settings_security.jsx
+++ b/webapp/components/user_settings/user_settings_security.jsx
@@ -16,33 +16,12 @@ import Constants from 'utils/constants.jsx';
import $ from 'jquery';
import React from 'react';
-import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedHTMLMessage, FormattedTime, FormattedDate} from 'react-intl';
+import {FormattedMessage, FormattedHTMLMessage, FormattedTime, FormattedDate} from 'react-intl';
import {Link} from 'react-router/es6';
-const holders = defineMessages({
- currentPasswordError: {
- id: 'user.settings.security.currentPasswordError',
- defaultMessage: 'Please enter your current password.'
- },
- passwordLengthError: {
- id: 'user.settings.security.passwordLengthError',
- defaultMessage: 'New passwords must be at least {min} characters and at most {max} characters.'
- },
- passwordMatchError: {
- id: 'user.settings.security.passwordMatchError',
- defaultMessage: 'The new passwords you entered do not match.'
- },
- method: {
- id: 'user.settings.security.method',
- defaultMessage: 'Sign-in Method'
- },
- close: {
- id: 'user.settings.security.close',
- defaultMessage: 'Close'
- }
-});
+import icon50 from 'images/icon50x50.png';
-class SecurityTab extends React.Component {
+export default class SecurityTab extends React.Component {
constructor(props) {
super(props);
@@ -56,7 +35,9 @@ class SecurityTab extends React.Component {
this.getDefaultState = this.getDefaultState.bind(this);
this.createPasswordSection = this.createPasswordSection.bind(this);
this.createSignInSection = this.createSignInSection.bind(this);
+ this.createOAuthAppsSection = this.createOAuthAppsSection.bind(this);
this.showQrCode = this.showQrCode.bind(this);
+ this.deauthorizeApp = this.deauthorizeApp.bind(this);
this.state = this.getDefaultState();
}
@@ -74,6 +55,16 @@ class SecurityTab extends React.Component {
};
}
+ componentDidMount() {
+ Client.getAuthorizedApps(
+ (authorizedApps) => {
+ this.setState({authorizedApps, serverError: null}); //eslint-disable-line react/no-did-mount-set-state
+ },
+ (err) => {
+ this.setState({serverError: err.message}); //eslint-disable-line react/no-did-mount-set-state
+ });
+ }
+
submitPassword(e) {
e.preventDefault();
@@ -82,9 +73,8 @@ class SecurityTab extends React.Component {
var newPassword = this.state.newPassword;
var confirmPassword = this.state.confirmPassword;
- const {formatMessage} = this.props.intl;
if (currentPassword === '') {
- this.setState({passwordError: formatMessage(holders.currentPasswordError), serverError: ''});
+ this.setState({passwordError: Utils.localizeMessage('user.settings.security.currentPasswordError', 'Please enter your current password.'), serverError: ''});
return;
}
@@ -98,7 +88,7 @@ class SecurityTab extends React.Component {
}
if (newPassword !== confirmPassword) {
- var defaultState = Object.assign(this.getDefaultState(), {passwordError: formatMessage(holders.passwordMatchError), serverError: ''});
+ var defaultState = Object.assign(this.getDefaultState(), {passwordError: Utils.localizeMessage('user.settings.security.passwordMatchError', 'The new passwords you entered do not match.'), serverError: ''});
this.setState(defaultState);
return;
}
@@ -190,6 +180,23 @@ class SecurityTab extends React.Component {
this.setState({mfaShowQr: true});
}
+ deauthorizeApp(e) {
+ e.preventDefault();
+ const appId = e.currentTarget.getAttribute('data-app');
+ Client.deauthorizeOAuthApp(
+ appId,
+ () => {
+ const authorizedApps = this.state.authorizedApps.filter((app) => {
+ return app.id !== appId;
+ });
+
+ this.setState({authorizedApps, serverError: null});
+ },
+ (err) => {
+ this.setState({serverError: err.message});
+ });
+ }
+
createMfaSection() {
let updateSectionStatus;
let submit;
@@ -686,7 +693,7 @@ class SecurityTab extends React.Component {
return (