Skip to content
This repository has been archived by the owner on Nov 28, 2022. It is now read-only.

Commit

Permalink
Ghost.org OAuth support (#278)
Browse files Browse the repository at this point in the history
issue TryGhost/Ghost#7452, requires TryGhost/Ghost#7451
- use a `ghostOAuth` config flag to switch between the old-style per-install auth and centralized OAuth auth based on config provided by the server
- add OAuth flows for:
  - setup
  - sign-in
  - sign-up
  - re-authenticate
- add custom `oauth-ghost` authenticator to support our custom data structure
- add test helpers to stub successful/failed oauth authentication
- hide change password form if using OAuth (temporary - a way to change password via oauth provider will be added later)
  • Loading branch information
kevinansfield authored and kirrg001 committed Sep 30, 2016
1 parent 7385c26 commit 0a163d7
Show file tree
Hide file tree
Showing 28 changed files with 1,003 additions and 237 deletions.
41 changes: 41 additions & 0 deletions app/authenticators/oauth2-ghost.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
import Oauth2Authenticator from './oauth2';
import computed from 'ember-computed';
import RSVP from 'rsvp';
import run from 'ember-runloop';
import {assign} from 'ember-platform';
import {isEmpty} from 'ember-utils';
import {wrap} from 'ember-array/utils';

export default Oauth2Authenticator.extend({
serverTokenEndpoint: computed('ghostPaths.apiRoot', function () {
return `${this.get('ghostPaths.apiRoot')}/authentication/ghost`;
}),

// TODO: all this is doing is changing the `data` structure, we should
// probably create our own token auth, maybe look at
// https://github.com/jpadilla/ember-simple-auth-token
authenticate(identification, password, scope = []) {
return new RSVP.Promise((resolve, reject) => {
// const data = { 'grant_type': 'password', username: identification, password };
let data = identification;
let serverTokenEndpoint = this.get('serverTokenEndpoint');
let scopesString = wrap(scope).join(' ');
if (!isEmpty(scopesString)) {
data.scope = scopesString;
}
this.makeRequest(serverTokenEndpoint, data).then((response) => {
run(() => {
let expiresAt = this._absolutizeExpirationTime(response.expires_in);
this._scheduleAccessTokenRefresh(response.expires_in, expiresAt, response.refresh_token);
if (!isEmpty(expiresAt)) {
response = assign(response, {'expires_at': expiresAt});
}
resolve(response);
});
}, (xhr) => {
run(null, reject, xhr.responseJSON || xhr.responseText);
});
});
}
});
84 changes: 60 additions & 24 deletions app/components/modals/re-authenticate.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ export default ModalComponent.extend(ValidationEngine, {
submitting: false,
authenticationError: null,

config: injectService(),
notifications: injectService(),
session: injectService(),
torii: injectService(),

identification: computed('session.user.email', function () {
return this.get('session.user.email');
Expand All @@ -35,35 +37,69 @@ export default ModalComponent.extend(ValidationEngine, {
});
},

actions: {
confirm() {
// Manually trigger events for input fields, ensuring legacy compatibility with
// browsers and password managers that don't send proper events on autofill
$('#login').find('input').trigger('change');
_passwordConfirm() {
// Manually trigger events for input fields, ensuring legacy compatibility with
// browsers and password managers that don't send proper events on autofill
$('#login').find('input').trigger('change');

this.set('authenticationError', null);

this.validate({property: 'signin'}).then(() => {
this._authenticate().then(() => {
this.get('notifications').closeAlerts();
this.send('closeModal');
}).catch((error) => {
if (error && error.errors) {
error.errors.forEach((err) => {
if (isVersionMismatchError(err)) {
return this.get('notifications').showAPIError(error);
}
err.message = htmlSafe(err.message);
});

this.get('errors').add('password', 'Incorrect password');
this.get('hasValidated').pushObject('password');
this.set('authenticationError', error.errors[0].message);
}
});
}, () => {
this.get('hasValidated').pushObject('password');
});
},

this.set('authenticationError', null);
_oauthConfirm() {
// TODO: remove duplication between signin/signup/re-auth
let authStrategy = 'authenticator:oauth2-ghost';

this.validate({property: 'signin'}).then(() => {
this._authenticate().then(() => {
this.get('notifications').closeAlerts('post.save');
this.toggleProperty('submitting');
this.set('authenticationError', '');

this.get('torii')
.open('ghost-oauth2', {type: 'signin'})
.then((authentication) => {
this.get('session').set('skipAuthSuccessHandler', true);

this.get('session').authenticate(authStrategy, authentication).finally(() => {
this.get('session').set('skipAuthSuccessHandler', undefined);

this.toggleProperty('submitting');
this.get('notifications').closeAlerts();
this.send('closeModal');
}).catch((error) => {
if (error && error.errors) {
error.errors.forEach((err) => {
if (isVersionMismatchError(err)) {
return this.get('notifications').showAPIError(error);
}
err.message = htmlSafe(err.message);
});

this.get('errors').add('password', 'Incorrect password');
this.get('hasValidated').pushObject('password');
this.set('authenticationError', error.errors[0].message);
}
});
}, () => {
this.get('hasValidated').pushObject('password');
})
.catch(() => {
this.toggleProperty('submitting');
this.set('authenticationError', 'Authentication with Ghost.org denied or failed');
});
},

actions: {
confirm() {
if (this.get('config.ghostOAuth')) {
return this._oauthConfirm();
} else {
return this._passwordConfirm();
}
}
}
});
134 changes: 89 additions & 45 deletions app/controllers/setup/two.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,90 @@ export default Controller.extend(ValidationEngine, {
}
},

_passwordSetup() {
let setupProperties = ['blogTitle', 'name', 'email', 'password'];
let data = this.getProperties(setupProperties);
let config = this.get('config');
let method = this.get('blogCreated') ? 'put' : 'post';

this.toggleProperty('submitting');
this.set('flowErrors', '');

this.get('hasValidated').addObjects(setupProperties);
this.validate().then(() => {
let authUrl = this.get('ghostPaths.url').api('authentication', 'setup');
this.get('ajax')[method](authUrl, {
data: {
setup: [{
name: data.name,
email: data.email,
password: data.password,
blogTitle: data.blogTitle
}]
}
}).then((result) => {
config.set('blogTitle', data.blogTitle);

// don't try to login again if we are already logged in
if (this.get('session.isAuthenticated')) {
return this.afterAuthentication(result);
}

// Don't call the success handler, otherwise we will be redirected to admin
this.set('session.skipAuthSuccessHandler', true);
this.get('session').authenticate('authenticator:oauth2', this.get('email'), this.get('password')).then(() => {
this.set('blogCreated', true);
return this.afterAuthentication(result);
}).catch((error) => {
this._handleAuthenticationError(error);
}).finally(() => {
this.set('session.skipAuthSuccessHandler', undefined);
});
}).catch((error) => {
this._handleSaveError(error);
});
}).catch(() => {
this.toggleProperty('submitting');
this.set('flowErrors', 'Please fill out the form to setup your blog.');
});
},

// TODO: for OAuth ghost is in the "setup completed" step as soon
// as a user has been authenticated so we need to use the standard settings
// update to set the blog title before redirecting
_oauthSetup() {
let blogTitle = this.get('blogTitle');
let config = this.get('config');

this.get('hasValidated').addObjects(['blogTitle', 'session']);

return this.validate().then(() => {
this.store.queryRecord('setting', {type: 'blog,theme,private'})
.then((settings) => {
settings.set('title', blogTitle);

return settings.save()
.then((settings) => {
// update the config so that the blog title shown in
// the nav bar is also updated
config.set('blogTitle', settings.get('title'));

// this.blogCreated is used by step 3 to check if step 2
// has been completed
this.set('blogCreated', true);
return this.afterAuthentication(settings);
})
.catch((error) => {
this._handleSaveError(error);
});
})
.finally(() => {
this.toggleProperty('submitting');
this.set('session.skipAuthSuccessHandler', undefined);
});
});
},

actions: {
preValidate(model) {
// Only triggers validation if a value has been entered, preventing empty errors on focusOut
Expand All @@ -98,51 +182,11 @@ export default Controller.extend(ValidationEngine, {
},

setup() {
let setupProperties = ['blogTitle', 'name', 'email', 'password'];
let data = this.getProperties(setupProperties);
let config = this.get('config');
let method = this.get('blogCreated') ? 'put' : 'post';

this.toggleProperty('submitting');
this.set('flowErrors', '');

this.get('hasValidated').addObjects(setupProperties);
this.validate().then(() => {
let authUrl = this.get('ghostPaths.url').api('authentication', 'setup');
this.get('ajax')[method](authUrl, {
data: {
setup: [{
name: data.name,
email: data.email,
password: data.password,
blogTitle: data.blogTitle
}]
}
}).then((result) => {
config.set('blogTitle', data.blogTitle);

// don't try to login again if we are already logged in
if (this.get('session.isAuthenticated')) {
return this.afterAuthentication(result);
}

// Don't call the success handler, otherwise we will be redirected to admin
this.set('session.skipAuthSuccessHandler', true);
this.get('session').authenticate('authenticator:oauth2', this.get('email'), this.get('password')).then(() => {
this.set('blogCreated', true);
return this.afterAuthentication(result);
}).catch((error) => {
this._handleAuthenticationError(error);
}).finally(() => {
this.set('session.skipAuthSuccessHandler', undefined);
});
}).catch((error) => {
this._handleSaveError(error);
});
}).catch(() => {
this.toggleProperty('submitting');
this.set('flowErrors', 'Please fill out the form to setup your blog.');
});
if (this.get('config.ghostOAuth')) {
return this._oauthSetup();
} else {
return this._passwordSetup();
}
},

setImage(image) {
Expand Down
45 changes: 6 additions & 39 deletions app/controllers/signin.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import injectController from 'ember-controller/inject';
import {isEmberArray} from 'ember-array/utils';

import {
VersionMismatchError,
isVersionMismatchError
} from 'ghost-admin/services/ajax';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
Expand All @@ -15,55 +14,23 @@ export default Controller.extend(ValidationEngine, {
loggingIn: false,
authProperties: ['identification', 'password'],

ajax: injectService(),
application: injectController(),
config: injectService(),
ghostPaths: injectService(),
notifications: injectService(),
session: injectService(),
application: injectController(),
ajax: injectService(),

flowErrors: '',

// ValidationEngine settings
validationType: 'signin',

actions: {
authenticate() {
validateAndAuthenticate() {
let model = this.get('model');
let authStrategy = 'authenticator:oauth2';

// Authentication transitions to posts.index, we can leave spinner running unless there is an error
this.get('session').authenticate(authStrategy, model.get('identification'), model.get('password')).catch((error) => {
this.toggleProperty('loggingIn');

if (error && error.errors) {
// we don't get back an ember-data/ember-ajax error object
// back so we need to pass in a null status in order to
// test against the payload
if (isVersionMismatchError(null, error)) {
let versionMismatchError = new VersionMismatchError(error);
return this.get('notifications').showAPIError(versionMismatchError);
}

error.errors.forEach((err) => {
err.message = err.message.htmlSafe();
});

this.set('flowErrors', error.errors[0].message.string);

if (error.errors[0].message.string.match(/user with that email/)) {
this.get('model.errors').add('identification', '');
}

if (error.errors[0].message.string.match(/password is incorrect/)) {
this.get('model.errors').add('password', '');
}
} else {
// Connection errors don't return proper status message, only req.body
this.get('notifications').showAlert('There was a problem on the server.', {type: 'error', key: 'session.authenticate.failed'});
}
});
},

validateAndAuthenticate() {
this.set('flowErrors', '');
// Manually trigger events for input fields, ensuring legacy compatibility with
// browsers and password managers that don't send proper events on autofill
Expand All @@ -73,7 +40,7 @@ export default Controller.extend(ValidationEngine, {
this.get('hasValidated').addObjects(this.authProperties);
this.validate({property: 'signin'}).then(() => {
this.toggleProperty('loggingIn');
this.send('authenticate');
this.send('authenticate', authStrategy, [model.get('identification'), model.get('password')]);
}).catch(() => {
this.set('flowErrors', 'Please fill out the form to sign in.');
});
Expand Down
5 changes: 5 additions & 0 deletions app/controllers/team/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default Controller.extend({
_scratchTwitter: null,

ajax: injectService(),
config: injectService(),
dropdown: injectService(),
ghostPaths: injectService(),
notifications: injectService(),
Expand Down Expand Up @@ -50,6 +51,10 @@ export default Controller.extend({
}
}),

canChangePassword: computed('config.ghostOAuth', 'isAdminUserOnOwnerProfile', function () {
return !this.get('config.ghostOAuth') && !this.get('isAdminUserOnOwnerProfile');
}),

// duplicated in gh-user-active -- find a better home and consolidate?
userDefault: computed('ghostPaths', function () {
return `${this.get('ghostPaths.subdir')}/ghost/img/user-image.png`;
Expand Down
Loading

0 comments on commit 0a163d7

Please sign in to comment.