diff options
Diffstat (limited to 'web')
-rw-r--r-- | web/react/components/authorize.jsx | 72 | ||||
-rw-r--r-- | web/react/components/popover_list_members.jsx | 2 | ||||
-rw-r--r-- | web/react/components/register_app_modal.jsx | 249 | ||||
-rw-r--r-- | web/react/components/user_settings.jsx | 10 | ||||
-rw-r--r-- | web/react/components/user_settings_developer.jsx | 93 | ||||
-rw-r--r-- | web/react/components/user_settings_modal.jsx | 11 | ||||
-rw-r--r-- | web/react/pages/authorize.jsx | 21 | ||||
-rw-r--r-- | web/react/pages/channel.jsx | 6 | ||||
-rw-r--r-- | web/react/utils/client.jsx | 33 | ||||
-rw-r--r-- | web/sass-files/sass/partials/_signup.scss | 15 | ||||
-rw-r--r-- | web/templates/authorize.html | 26 | ||||
-rw-r--r-- | web/templates/channel.html | 1 | ||||
-rw-r--r-- | web/web.go | 204 | ||||
-rw-r--r-- | web/web_test.go | 134 |
14 files changed, 864 insertions, 13 deletions
diff --git a/web/react/components/authorize.jsx b/web/react/components/authorize.jsx new file mode 100644 index 000000000..dd4479ad4 --- /dev/null +++ b/web/react/components/authorize.jsx @@ -0,0 +1,72 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Client = require('../utils/client.jsx'); + +export default class Authorize extends React.Component { + constructor(props) { + super(props); + + this.handleAllow = this.handleAllow.bind(this); + this.handleDeny = this.handleDeny.bind(this); + + this.state = {}; + } + 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; + + Client.allowOAuth2(responseType, clientId, redirectUri, state, scope, + (data) => { + if (data.redirect) { + window.location.replace(data.redirect); + } + }, + () => {} + ); + } + handleDeny() { + window.location.replace(this.props.redirectUri + '?error=access_denied'); + } + render() { + return ( + <div className='authorize-box'> + <div className='authorize-inner'> + <h3>{'An application would like to connect to your '}{this.props.teamName}{' account'}</h3> + <label>{'The app '}{this.props.appName}{' would like the ability to access and modify your basic information.'}</label> + <br/> + <br/> + <label>{'Allow '}{this.props.appName}{' access?'}</label> + <br/> + <button + type='submit' + className='btn authorize-btn' + onClick={this.handleDeny} + > + {'Deny'} + </button> + <button + type='submit' + className='btn btn-primary authorize-btn' + onClick={this.handleAllow} + > + {'Allow'} + </button> + </div> + </div> + ); + } +} + +Authorize.propTypes = { + appName: React.PropTypes.string, + teamName: React.PropTypes.string, + responseType: React.PropTypes.string, + clientId: React.PropTypes.string, + redirectUri: React.PropTypes.string, + state: React.PropTypes.string, + scope: React.PropTypes.string +}; diff --git a/web/react/components/popover_list_members.jsx b/web/react/components/popover_list_members.jsx index fb9522afb..ec873dd00 100644 --- a/web/react/components/popover_list_members.jsx +++ b/web/react/components/popover_list_members.jsx @@ -25,7 +25,7 @@ export default class PopoverListMembers extends React.Component { $('#member_popover').popover({placement: 'bottom', trigger: 'click', html: true}); $('body').on('click', function onClick(e) { - if ($(e.target.parentNode.parentNode)[0] !== $('#member_popover')[0] && $(e.target).parents('.popover.in').length === 0) { + if (e.target.parentNode && $(e.target.parentNode.parentNode)[0] !== $('#member_popover')[0] && $(e.target).parents('.popover.in').length === 0) { $('#member_popover').popover('hide'); } }); diff --git a/web/react/components/register_app_modal.jsx b/web/react/components/register_app_modal.jsx new file mode 100644 index 000000000..3dd5c094e --- /dev/null +++ b/web/react/components/register_app_modal.jsx @@ -0,0 +1,249 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Client = require('../utils/client.jsx'); + +export default class RegisterAppModal extends React.Component { + constructor() { + super(); + + this.register = this.register.bind(this); + this.onHide = this.onHide.bind(this); + this.save = this.save.bind(this); + + this.state = {clientId: '', clientSecret: '', saved: false}; + } + componentDidMount() { + $(React.findDOMNode(this)).on('hide.bs.modal', this.onHide); + } + register() { + var state = this.state; + state.serverError = null; + + var app = {}; + + var name = this.refs.name.getDOMNode().value; + if (!name || name.length === 0) { + state.nameError = 'Application name must be filled in.'; + this.setState(state); + return; + } + state.nameError = null; + app.name = name; + + var homepage = this.refs.homepage.getDOMNode().value; + if (!homepage || homepage.length === 0) { + state.homepageError = 'Homepage must be filled in.'; + this.setState(state); + return; + } + state.homepageError = null; + app.homepage = homepage; + + var desc = this.refs.desc.getDOMNode().value; + app.description = desc; + + var rawCallbacks = this.refs.callback.getDOMNode().value.trim(); + if (!rawCallbacks || rawCallbacks.length === 0) { + state.callbackError = 'At least one callback URL must be filled in.'; + this.setState(state); + return; + } + state.callbackError = null; + app.callback_urls = rawCallbacks.split('\n'); + + Client.registerOAuthApp(app, + (data) => { + state.clientId = data.id; + state.clientSecret = data.client_secret; + this.setState(state); + }, + (err) => { + state.serverError = err.message; + this.setState(state); + } + ); + } + onHide(e) { + if (!this.state.saved && this.state.clientId !== '') { + e.preventDefault(); + return; + } + + this.setState({clientId: '', clientSecret: '', saved: false}); + } + save() { + this.setState({saved: this.refs.save.getDOMNode().checked}); + } + render() { + var nameError; + if (this.state.nameError) { + nameError = <div className='form-group has-error'><label className='control-label'>{this.state.nameError}</label></div>; + } + var homepageError; + if (this.state.homepageError) { + homepageError = <div className='form-group has-error'><label className='control-label'>{this.state.homepageError}</label></div>; + } + var callbackError; + if (this.state.callbackError) { + callbackError = <div className='form-group has-error'><label className='control-label'>{this.state.callbackError}</label></div>; + } + var serverError; + if (this.state.serverError) { + serverError = <div className='form-group has-error'><label className='control-label'>{this.state.serverError}</label></div>; + } + + var body = ''; + if (this.state.clientId === '') { + body = ( + <div className='form-group user-settings'> + <h3>{'Register a New Application'}</h3> + <br/> + <label className='col-sm-4 control-label'>{'Application Name'}</label> + <div className='col-sm-7'> + <input + ref='name' + className='form-control' + type='text' + placeholder='Required' + /> + {nameError} + </div> + <br/> + <br/> + <label className='col-sm-4 control-label'>{'Homepage URL'}</label> + <div className='col-sm-7'> + <input + ref='homepage' + className='form-control' + type='text' + placeholder='Required' + /> + {homepageError} + </div> + <br/> + <br/> + <label className='col-sm-4 control-label'>{'Description'}</label> + <div className='col-sm-7'> + <input + ref='desc' + className='form-control' + type='text' + placeholder='Optional' + /> + </div> + <br/> + <br/> + <label className='col-sm-4 control-label'>{'Callback URL'}</label> + <div className='col-sm-7'> + <textarea + ref='callback' + className='form-control' + type='text' + placeholder='Required' + rows='5' + /> + {callbackError} + </div> + <br/> + <br/> + <br/> + <br/> + <br/> + {serverError} + <a + className='btn btn-sm theme pull-right' + href='#' + data-dismiss='modal' + aria-label='Close' + > + {'Cancel'} + </a> + <a + className='btn btn-sm btn-primary pull-right' + onClick={this.register} + > + {'Register'} + </a> + </div> + ); + } else { + var btnClass = ' disabled'; + if (this.state.saved) { + btnClass = ''; + } + + body = ( + <div className='form-group user-settings'> + <h3>{'Your Application Credentials'}</h3> + <br/> + <br/> + <label className='col-sm-12 control-label'>{'Client ID: '}{this.state.clientId}</label> + <label className='col-sm-12 control-label'>{'Client Secret: '}{this.state.clientSecret}</label> + <br/> + <br/> + <br/> + <br/> + <strong>{'Save these somewhere SAFE and SECURE. We can retrieve your Client Id if you lose it, but your Client Secret will be lost forever if you were to lose it.'}</strong> + <br/> + <br/> + <div className='checkbox'> + <label> + <input + ref='save' + type='checkbox' + checked={this.state.saved} + onClick={this.save} + > + {'I have saved both my Client Id and Client Secret somewhere safe'} + </input> + </label> + </div> + <a + className={'btn btn-sm btn-primary pull-right' + btnClass} + href='#' + data-dismiss='modal' + aria-label='Close' + > + {'Close'} + </a> + </div> + ); + } + + return ( + <div + className='modal fade' + ref='modal' + id='register_app' + role='dialog' + aria-hidden='true' + > + <div className='modal-dialog'> + <div className='modal-content'> + <div className='modal-header'> + <button + type='button' + className='close' + data-dismiss='modal' + aria-label='Close' + > + <span aria-hidden='true'>{'x'}</span> + </button> + <h4 + className='modal-title' + ref='title' + > + {'Developer Applications'} + </h4> + </div> + <div className='modal-body'> + {body} + </div> + </div> + </div> + </div> + ); + } +} + diff --git a/web/react/components/user_settings.jsx b/web/react/components/user_settings.jsx index 2a607b3e0..48b499068 100644 --- a/web/react/components/user_settings.jsx +++ b/web/react/components/user_settings.jsx @@ -7,6 +7,7 @@ var NotificationsTab = require('./user_settings_notifications.jsx'); var SecurityTab = require('./user_settings_security.jsx'); var GeneralTab = require('./user_settings_general.jsx'); var AppearanceTab = require('./user_settings_appearance.jsx'); +var DeveloperTab = require('./user_settings_developer.jsx'); export default class UserSettings extends React.Component { constructor(props) { @@ -76,6 +77,15 @@ export default class UserSettings extends React.Component { /> </div> ); + } else if (this.props.activeTab === 'developer') { + return ( + <div> + <DeveloperTab + activeSection={this.props.activeSection} + updateSection={this.props.updateSection} + /> + </div> + ); } return <div/>; diff --git a/web/react/components/user_settings_developer.jsx b/web/react/components/user_settings_developer.jsx new file mode 100644 index 000000000..1b04149dc --- /dev/null +++ b/web/react/components/user_settings_developer.jsx @@ -0,0 +1,93 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var SettingItemMin = require('./setting_item_min.jsx'); +var SettingItemMax = require('./setting_item_max.jsx'); + +export default class DeveloperTab extends React.Component { + constructor(props) { + super(props); + + this.state = {}; + } + register() { + $('#user_settings1').modal('hide'); + $('#register_app').modal('show'); + } + render() { + var appSection; + var self = this; + if (this.props.activeSection === 'app') { + var inputs = []; + + inputs.push( + <div className='form-group'> + <div className='col-sm-7'> + <a + className='btn btn-sm btn-primary' + onClick={this.register} + > + {'Register New Application'} + </a> + </div> + </div> + ); + + appSection = ( + <SettingItemMax + title='Applications (Preview)' + inputs={inputs} + updateSection={function updateSection(e) { + self.props.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + appSection = ( + <SettingItemMin + title='Applications (Preview)' + describe='Open to register a new third-party application' + updateSection={function updateSection() { + self.props.updateSection('app'); + }} + /> + ); + } + + return ( + <div> + <div className='modal-header'> + <button + type='button' + className='close' + data-dismiss='modal' + aria-label='Close' + > + <span aria-hidden='true'>{'x'}</span> + </button> + <h4 + className='modal-title' + ref='title' + > + <i className='modal-back'></i>{'Developer Settings'} + </h4> + </div> + <div className='user-settings'> + <h3 className='tab-header'>{'Developer Settings'}</h3> + <div className='divider-dark first'/> + {appSection} + <div className='divider-dark'/> + </div> + </div> + ); + } +} + +DeveloperTab.defaultProps = { + activeSection: '' +}; +DeveloperTab.propTypes = { + activeSection: React.PropTypes.string, + updateSection: React.PropTypes.func +}; diff --git a/web/react/components/user_settings_modal.jsx b/web/react/components/user_settings_modal.jsx index 7ec75e000..1daf6ebb9 100644 --- a/web/react/components/user_settings_modal.jsx +++ b/web/react/components/user_settings_modal.jsx @@ -17,8 +17,8 @@ export default class UserSettingsModal extends React.Component { $('body').on('click', '.modal-back', function changeDisplay() { $(this).closest('.modal-dialog').removeClass('display--content'); }); - $('body').on('click', '.modal-header .close', function closeModal() { - setTimeout(function finishClose() { + $('body').on('click', '.modal-header .close', () => { + setTimeout(() => { $('.modal-dialog.display--content').removeClass('display--content'); }, 500); }); @@ -35,6 +35,9 @@ export default class UserSettingsModal extends React.Component { tabs.push({name: 'security', uiName: 'Security', icon: 'glyphicon glyphicon-lock'}); tabs.push({name: 'notifications', uiName: 'Notifications', icon: 'glyphicon glyphicon-exclamation-sign'}); tabs.push({name: 'appearance', uiName: 'Appearance', icon: 'glyphicon glyphicon-wrench'}); + if (global.window.config.EnableOAuthServiceProvider) { + tabs.push({name: 'developer', uiName: 'Developer', icon: 'glyphicon glyphicon-th'}); + } return ( <div @@ -54,13 +57,13 @@ export default class UserSettingsModal extends React.Component { data-dismiss='modal' aria-label='Close' > - <span aria-hidden='true'>×</span> + <span aria-hidden='true'>{'x'}</span> </button> <h4 className='modal-title' ref='title' > - Account Settings + {'Account Settings'} </h4> </div> <div className='modal-body'> diff --git a/web/react/pages/authorize.jsx b/web/react/pages/authorize.jsx new file mode 100644 index 000000000..db42c8266 --- /dev/null +++ b/web/react/pages/authorize.jsx @@ -0,0 +1,21 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Authorize = require('../components/authorize.jsx'); + +function setupAuthorizePage(teamName, appName, responseType, clientId, redirectUri, scope, state) { + React.render( + <Authorize + teamName={teamName} + appName={appName} + responseType={responseType} + clientId={clientId} + redirectUri={redirectUri} + scope={scope} + state={state} + />, + document.getElementById('authorize') + ); +} + +global.window.setup_authorize_page = setupAuthorizePage; diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx index fa5710d6b..43493de45 100644 --- a/web/react/pages/channel.jsx +++ b/web/react/pages/channel.jsx @@ -33,6 +33,7 @@ var AccessHistoryModal = require('../components/access_history_modal.jsx'); var ActivityLogModal = require('../components/activity_log_modal.jsx'); var RemovedFromChannelModal = require('../components/removed_from_channel_modal.jsx'); var FileUploadOverlay = require('../components/file_upload_overlay.jsx'); +var RegisterAppModal = require('../components/register_app_modal.jsx'); var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; @@ -222,6 +223,11 @@ function setupChannelPage(props) { />, document.getElementById('file_upload_overlay') ); + + React.render( + <RegisterAppModal />, + document.getElementById('register_app_modal') + ); } global.window.setup_channel_page = setupChannelPage; diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index 902eb1642..ba3042d78 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -987,3 +987,36 @@ export function updateValetFeature(data, success, error) { track('api', 'api_teams_update_valet_feature'); } + +export function registerOAuthApp(app, success, error) { + $.ajax({ + url: '/api/v1/oauth/register', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(app), + success: success, + error: (xhr, status, err) => { + const e = handleError('registerApp', xhr, status, err); + error(e); + } + }); + + module.exports.track('api', 'api_apps_register'); +} + +export function allowOAuth2(responseType, clientId, redirectUri, state, scope, success, error) { + $.ajax({ + url: '/api/v1/oauth/allow?response_type=' + responseType + '&client_id=' + clientId + '&redirect_uri=' + redirectUri + '&scope=' + scope + '&state=' + state, + dataType: 'json', + contentType: 'application/json', + type: 'GET', + success: success, + error: (xhr, status, err) => { + const e = handleError('allowOAuth2', xhr, status, err); + error(e); + } + }); + + module.exports.track('api', 'api_users_allow_oauth2'); +} diff --git a/web/sass-files/sass/partials/_signup.scss b/web/sass-files/sass/partials/_signup.scss index 2fb56e537..924f0718a 100644 --- a/web/sass-files/sass/partials/_signup.scss +++ b/web/sass-files/sass/partials/_signup.scss @@ -315,3 +315,18 @@ } } + +.authorize-box { + margin: 100px auto; + width:500px; + height:280px; + border: 1px solid black; +} + +.authorize-inner { + padding: 20px; +} + +.authorize-btn { + margin-right: 6px; +} diff --git a/web/templates/authorize.html b/web/templates/authorize.html new file mode 100644 index 000000000..3392c1b1e --- /dev/null +++ b/web/templates/authorize.html @@ -0,0 +1,26 @@ +{{define "authorize"}} +<html> +{{template "head" . }} +<body class="white"> + <div class="container-fluid"> + <div class="inner__wrap"> + <div class="row content"> + <div class="signup-header"> + {{.Props.TeamName}} + </div> + <div class="col-sm-12"> + <div id="authorize"></div> + </div> + <div class="footer-push"></div> + </div> + <div class="row footer"> + {{template "footer" . }} + </div> + </div> + </div> + <script> + window.setup_authorize_page('{{ .Props.TeamName }}', '{{ .Props.AppName }}', '{{ .Props.ResponseType }}', '{{ .Props.ClientId }}', '{{ .Props.RedirectUri }}', '{{ .Props.Scope }}', '{{ .Props.State }}' ); + </script> +</body> +</html> +{{end}} diff --git a/web/templates/channel.html b/web/templates/channel.html index 1885db2f6..92aaaf02f 100644 --- a/web/templates/channel.html +++ b/web/templates/channel.html @@ -49,6 +49,7 @@ <div id="access_history_modal"></div> <div id="activity_log_modal"></div> <div id="removed_from_channel_modal"></div> + <div id="register_app_modal"></div> <script> window.setup_channel_page({{ .Props }}); $('body').tooltip( {selector: '[data-toggle=tooltip]'} ); diff --git a/web/web.go b/web/web.go index 484dbe21b..305e4f199 100644 --- a/web/web.go +++ b/web/web.go @@ -4,19 +4,18 @@ package web import ( - "fmt" - "html/template" - "net/http" - "strconv" - "strings" - l4g "code.google.com/p/log4go" + "fmt" "github.com/gorilla/mux" "github.com/mattermost/platform/api" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" "github.com/mssola/user_agent" "gopkg.in/fsnotify.v1" + "html/template" + "net/http" + "strconv" + "strings" ) var Templates *template.Template @@ -50,6 +49,8 @@ func InitWeb() { mainrouter.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))) mainrouter.Handle("/", api.AppHandlerIndependent(root)).Methods("GET") + mainrouter.Handle("/oauth/authorize", api.UserRequired(authorizeOAuth)).Methods("GET") + mainrouter.Handle("/oauth/access_token", api.ApiAppHandler(getAccessToken)).Methods("POST") mainrouter.Handle("/signup_team_complete/", api.AppHandlerIndependent(signupTeamComplete)).Methods("GET") mainrouter.Handle("/signup_user_complete/", api.AppHandlerIndependent(signupUserComplete)).Methods("GET") @@ -63,7 +64,7 @@ func InitWeb() { mainrouter.Handle("/admin_console", api.UserRequired(adminConsole)).Methods("GET") // ---------------------------------------------------------------------------------------------- - // *ANYTHING* team spefic should go below this line + // *ANYTHING* team specific should go below this line // ---------------------------------------------------------------------------------------------- mainrouter.Handle("/{team:[A-Za-z0-9-]+(__)?[A-Za-z0-9-]+}", api.AppHandler(login)).Methods("GET") @@ -648,3 +649,192 @@ func adminConsole(c *api.Context, w http.ResponseWriter, r *http.Request) { page := NewHtmlTemplatePage("admin_console", "Admin Console") page.Render(c, w) } + +func authorizeOAuth(c *api.Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { + c.Err = model.NewAppError("authorizeOAuth", "The system admin has turned off OAuth service providing.", "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + if !CheckBrowserCompatability(c, r) { + return + } + + responseType := r.URL.Query().Get("response_type") + clientId := r.URL.Query().Get("client_id") + redirect := r.URL.Query().Get("redirect_uri") + scope := r.URL.Query().Get("scope") + state := r.URL.Query().Get("state") + + if len(responseType) == 0 || len(clientId) == 0 || len(redirect) == 0 { + c.Err = model.NewAppError("authorizeOAuth", "Missing one or more of response_type, client_id, or redirect_uri", "") + return + } + + var app *model.OAuthApp + if result := <-api.Srv.Store.OAuth().GetApp(clientId); result.Err != nil { + c.Err = result.Err + return + } else { + app = result.Data.(*model.OAuthApp) + } + + var team *model.Team + if result := <-api.Srv.Store.Team().Get(c.Session.TeamId); result.Err != nil { + c.Err = result.Err + return + } else { + team = result.Data.(*model.Team) + } + + page := NewHtmlTemplatePage("authorize", "Authorize Application") + 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 + page.Render(c, w) +} + +func getAccessToken(c *api.Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { + c.Err = model.NewAppError("getAccessToken", "The system admin has turned off OAuth service providing.", "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + c.LogAudit("attempt") + + r.ParseForm() + + grantType := r.FormValue("grant_type") + if grantType != model.ACCESS_TOKEN_GRANT_TYPE { + c.Err = model.NewAppError("getAccessToken", "invalid_request: Bad grant_type", "") + return + } + + clientId := r.FormValue("client_id") + if len(clientId) != 26 { + c.Err = model.NewAppError("getAccessToken", "invalid_request: Bad client_id", "") + return + } + + secret := r.FormValue("client_secret") + if len(secret) == 0 { + c.Err = model.NewAppError("getAccessToken", "invalid_request: Missing client_secret", "") + return + } + + code := r.FormValue("code") + if len(code) == 0 { + c.Err = model.NewAppError("getAccessToken", "invalid_request: Missing code", "") + return + } + + redirectUri := r.FormValue("redirect_uri") + + achan := api.Srv.Store.OAuth().GetApp(clientId) + tchan := api.Srv.Store.OAuth().GetAccessDataByAuthCode(code) + + authData := api.GetAuthData(code) + + if authData == nil { + c.LogAudit("fail - invalid auth code") + c.Err = model.NewAppError("getAccessToken", "invalid_grant: Invalid or expired authorization code", "") + return + } + + uchan := api.Srv.Store.User().Get(authData.UserId) + + if authData.IsExpired() { + c.LogAudit("fail - auth code expired") + c.Err = model.NewAppError("getAccessToken", "invalid_grant: Invalid or expired authorization code", "") + return + } + + if authData.RedirectUri != redirectUri { + c.LogAudit("fail - redirect uri provided did not match previous redirect uri") + c.Err = model.NewAppError("getAccessToken", "invalid_request: Supplied redirect_uri does not match authorization code redirect_uri", "") + 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.NewAppError("getAccessToken", "invalid_grant: Invalid or expired authorization code", "") + return + } + + var app *model.OAuthApp + if result := <-achan; result.Err != nil { + c.Err = model.NewAppError("getAccessToken", "invalid_client: Invalid client credentials", "") + return + } else { + app = result.Data.(*model.OAuthApp) + } + + if !model.ComparePassword(app.ClientSecret, secret) { + c.LogAudit("fail - invalid client credentials") + c.Err = model.NewAppError("getAccessToken", "invalid_client: Invalid client credentials", "") + return + } + + callback := redirectUri + if len(callback) == 0 { + callback = app.CallbackUrls[0] + } + + if result := <-tchan; result.Err != nil { + c.Err = model.NewAppError("getAccessToken", "server_error: Encountered internal server error while accessing database", "") + return + } else if result.Data != nil { + c.LogAudit("fail - auth code has been used previously") + accessData := result.Data.(*model.AccessData) + + // Revoke access token, related auth code, and session from DB as well as from cache + if err := api.RevokeAccessToken(accessData.Token); err != nil { + l4g.Error("Encountered an error revoking an access token, err=" + err.Message) + } + + c.Err = model.NewAppError("getAccessToken", "invalid_grant: Authorization code already exchanged for an access token", "") + return + } + + var user *model.User + if result := <-uchan; result.Err != nil { + c.Err = model.NewAppError("getAccessToken", "server_error: Encountered internal server error while pulling user from database", "") + return + } else { + user = result.Data.(*model.User) + } + + session := &model.Session{UserId: user.Id, TeamId: user.TeamId, Roles: user.Roles, IsOAuth: true} + + if result := <-api.Srv.Store.Session().Save(session); result.Err != nil { + c.Err = model.NewAppError("getAccessToken", "server_error: Encountered internal server error while saving session to database", "") + return + } else { + session = result.Data.(*model.Session) + api.AddSessionToCache(session) + } + + accessData := &model.AccessData{AuthCode: authData.Code, Token: session.Token, RedirectUri: callback} + + if result := <-api.Srv.Store.OAuth().SaveAccessData(accessData); result.Err != nil { + l4g.Error(result.Err) + c.Err = model.NewAppError("getAccessToken", "server_error: Encountered internal server error while saving access token to database", "") + return + } + + accessRsp := &model.AccessResponse{AccessToken: session.Token, TokenType: model.ACCESS_TOKEN_TYPE, ExpiresIn: model.SESSION_TIME_OAUTH_IN_SECS} + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + + c.LogAuditWithUserId(user.Id, "success") + + w.Write([]byte(accessRsp.ToJson())) +} diff --git a/web/web_test.go b/web/web_test.go index ccd0bba56..3da7eb2dc 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -6,8 +6,11 @@ package web import ( "github.com/mattermost/platform/api" "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" "net/http" + "net/url" + "strings" "testing" "time" ) @@ -23,7 +26,7 @@ func Setup() { api.InitApi() InitWeb() URL = "http://localhost:" + utils.Cfg.ServiceSettings.Port - ApiClient = model.NewClient(URL + "/api/v1") + ApiClient = model.NewClient(URL) } } @@ -48,6 +51,135 @@ func TestStatic(t *testing.T) { } } +func TestGetAccessToken(t *testing.T) { + Setup() + + team := model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + rteam, _ := ApiClient.CreateTeam(&team) + + user := model.User{TeamId: rteam.Data.(*model.Team).Id, Email: strings.ToLower(model.NewId()) + "corey@test.com", Password: "pwd"} + ruser := ApiClient.Must(ApiClient.CreateUser(&user, "")).Data.(*model.User) + store.Must(api.Srv.Store.User().VerifyEmail(ruser.Id)) + + 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]}} + + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - oauth providing turned off") + } + } else { + + ApiClient.Must(ApiClient.LoginById(ruser.Id, "pwd")) + app = ApiClient.Must(ApiClient.RegisterApp(app)).Data.(*model.OAuthApp) + + 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() + + 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]}} + + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - bad grant type") + } + + 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_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", "junk") + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - bad 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("code", "junk") + if _, err := ApiClient.GetAccessToken(data); err == nil { + t.Fatal("should have failed - bad 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") + } + + // 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]) + + 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") + } + } + + if result, err := ApiClient.DoApiGet("/users/profiles?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") + } + } + + if _, err := ApiClient.DoApiGet("/users/profiles", "", ""); err == nil { + t.Fatal("should have failed - no access token provided") + } + + if _, err := ApiClient.DoApiGet("/users/profiles?access_token=junk", "", ""); err == nil { + t.Fatal("should have failed - bad access token provided") + } + + 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.GetAccessToken(data); err == nil { + t.Fatal("should have failed - tried to reuse auth code") + } + + ApiClient.ClearOAuthToken() + } +} + func TestZZWebTearDown(t *testing.T) { // *IMPORTANT* // This should be the last function in any test file |