diff --git a/ghost/admin/app/authenticators/cookie.js b/ghost/admin/app/authenticators/cookie.js index 32b9370809..bdc05c8b00 100644 --- a/ghost/admin/app/authenticators/cookie.js +++ b/ghost/admin/app/authenticators/cookie.js @@ -11,11 +11,27 @@ export default Authenticator.extend({ return `${this.ghostPaths.apiRoot}/session`; }), + sessionVerifyEndpoint: computed('ghostPaths.apiRoot', function () { + return `${this.ghostPaths.apiRoot}/session/verify`; + }), + restore: function () { return RSVP.resolve(); }, - authenticate(identification, password) { + authenticate({identification, password, token}) { + if (token) { + const data = {token}; + const options = { + data, + contentType: 'application/json;charset=utf-8', + // ember-ajax will try and parse the response as JSON if not explicitly set + dataType: 'text' + }; + + return this.ajax.put(this.sessionVerifyEndpoint, options); + } + const data = {username: identification, password}; const options = { data, diff --git a/ghost/admin/app/components/editor/modals/re-authenticate.js b/ghost/admin/app/components/editor/modals/re-authenticate.js index a0d33d5e69..db68c63047 100644 --- a/ghost/admin/app/components/editor/modals/re-authenticate.js +++ b/ghost/admin/app/components/editor/modals/re-authenticate.js @@ -87,13 +87,12 @@ export default class ReAuthenticateModal extends Component { } async _authenticate() { - const authStrategy = 'authenticator:cookie'; const {identification, password} = this.signin; this.session.skipAuthSuccessHandler = true; try { - await this.session.authenticate(authStrategy, identification, password); + await this.session.authenticate('authenticator:cookie', {identification, password}); } finally { this.session.skipAuthSuccessHandler = undefined; } diff --git a/ghost/admin/app/controllers/reset.js b/ghost/admin/app/controllers/reset.js index 2ce6090ee8..40f7609c1f 100644 --- a/ghost/admin/app/controllers/reset.js +++ b/ghost/admin/app/controllers/reset.js @@ -66,7 +66,7 @@ export default class ResetController extends Controller.extend(ValidationEngine) resp.password_reset[0].message, {type: 'info', delayed: true, key: 'password.reset'} ); - this.session.authenticate('authenticator:cookie', email, newPassword); + this.session.authenticate('authenticator:cookie', {identification: email, password: newPassword}); return true; } catch (error) { this.notifications.showAPIError(error, {key: 'password.reset'}); diff --git a/ghost/admin/app/controllers/setup.js b/ghost/admin/app/controllers/setup.js index 7a63fe71a8..eec7e6e84b 100644 --- a/ghost/admin/app/controllers/setup.js +++ b/ghost/admin/app/controllers/setup.js @@ -50,12 +50,12 @@ export default class SetupController extends Controller.extend(ValidationEngine) }) setupTask; - @task(function* (authStrategy, authentication) { + @task(function* (authStrategy, {identification, password}) { // we don't want to redirect after sign-in during setup this.session.skipAuthSuccessHandler = true; try { - yield this.session.authenticate(authStrategy, ...authentication); + yield this.session.authenticate(authStrategy, {identification, password}); this.errors.remove('session'); @@ -118,7 +118,7 @@ export default class SetupController extends Controller.extend(ValidationEngine) // Don't call the success handler, otherwise we will be redirected to admin this.session.skipAuthSuccessHandler = true; - return this.session.authenticate('authenticator:cookie', data.email, data.password).then(() => { + return this.session.authenticate('authenticator:cookie', {identification: data.email, password: data.password}).then(() => { this.set('blogCreated', true); return this._afterAuthentication(result); }).catch((error) => { diff --git a/ghost/admin/app/controllers/signin-verify.js b/ghost/admin/app/controllers/signin-verify.js new file mode 100644 index 0000000000..4c292f7ed2 --- /dev/null +++ b/ghost/admin/app/controllers/signin-verify.js @@ -0,0 +1,36 @@ +import Controller from '@ember/controller'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; +import {tracked} from '@glimmer/tracking'; + +export default class SigninVerifyController extends Controller { + @service ajax; + @service session; + + @tracked flowErrors = ''; + @tracked token = ''; + + @action + validateToken() { + return true; + } + + @action + handleTokenInput(event) { + this.token = event.target.value; + } + + @task + *verifyTokenTask() { + try { + yield this.session.authenticate('authenticator:cookie', {token: this.token}); + } catch (error) { + if (error && error.payload && error.payload.errors) { + this.flowErrors = error.payload.errors[0].message; + } else { + this.flowErrors = 'There was a problem with the verification token.'; + } + } + } +} diff --git a/ghost/admin/app/controllers/signin.js b/ghost/admin/app/controllers/signin.js index 1c4531cf3a..0cc8a6d1e3 100644 --- a/ghost/admin/app/controllers/signin.js +++ b/ghost/admin/app/controllers/signin.js @@ -7,6 +7,7 @@ import {action} from '@ember/object'; import {htmlSafe} from '@ember/template'; import {inject} from 'ghost-admin/decorators/inject'; import {isArray as isEmberArray} from '@ember/array'; +import {isForbiddenError} from 'ember-ajax/errors'; import {isVersionMismatchError} from 'ghost-admin/services/ajax'; import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; @@ -18,6 +19,7 @@ export default class SigninController extends Controller.extend(ValidationEngine @service ajax; @service ghostPaths; @service notifications; + @service router; @service session; @service settings; @@ -49,12 +51,17 @@ export default class SigninController extends Controller.extend(ValidationEngine } @task({drop: true}) - *authenticateTask(authStrategy, authentication) { + *authenticateTask(authStrategy, {identification, password}) { try { return yield this.session - .authenticate(authStrategy, ...authentication) + .authenticate(authStrategy, {identification, password}) .then(() => true); // ensure task button transitions to "success" state } catch (error) { + if (isForbiddenError(error)) { + this.router.transitionTo('signin-verify'); + return; + } + if (isVersionMismatchError(error)) { return this.notifications.showAPIError(error); } @@ -100,8 +107,7 @@ export default class SigninController extends Controller.extend(ValidationEngine @task({drop: true}) *validateAndAuthenticateTask() { - let signin = this.signin; - let authStrategy = 'authenticator:cookie'; + const {identification, password} = this.signin; this.flowErrors = ''; @@ -111,7 +117,7 @@ export default class SigninController extends Controller.extend(ValidationEngine try { yield this.validate({property: 'signin'}); return yield this.authenticateTask - .perform(authStrategy, [signin.identification, signin.password]); + .perform('authenticator:cookie', {identification, password}); } catch (error) { this.flowErrors = 'Please fill out the form to sign in.'; } diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index 0a82fe0c87..092e55f0a7 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -15,6 +15,7 @@ Router.map(function () { this.route('setup.done', {path: '/setup/done'}); this.route('signin'); + this.route('signin-verify', {path: '/signin/verify'}); this.route('signout'); this.route('signup', {path: '/signup/:token'}); this.route('reset', {path: '/reset/:token'}); @@ -33,7 +34,7 @@ Router.map(function () { this.route('posts.analytics', {path: '/posts/analytics/:post_id'}); this.route('posts.mentions', {path: '/posts/analytics/:post_id/mentions'}); this.route('posts.debug', {path: '/posts/analytics/:post_id/debug'}); - + this.route('restore-posts', {path: '/restore'}); this.route('pages'); diff --git a/ghost/admin/app/templates/signin-verify.hbs b/ghost/admin/app/templates/signin-verify.hbs new file mode 100644 index 0000000000..cf02e3cf99 --- /dev/null +++ b/ghost/admin/app/templates/signin-verify.hbs @@ -0,0 +1,44 @@ +
+
+
+ +
+
+
\ No newline at end of file diff --git a/ghost/admin/app/templates/signin.hbs b/ghost/admin/app/templates/signin.hbs index 6e4b1f0371..fcbcef243f 100644 --- a/ghost/admin/app/templates/signin.hbs +++ b/ghost/admin/app/templates/signin.hbs @@ -30,6 +30,7 @@ autocorrect="off" autocomplete="username" value={{this.signin.identification}} + data-test-input="email" {{on "input" this.handleInput}} {{on "blur" (fn this.validateProperty "identification")}} /> @@ -47,6 +48,7 @@ autocomplete="current-password" autocorrect="off" value={{this.signin.password}} + data-test-input="password" {{on "input" this.handleInput}} /> diff --git a/ghost/admin/tests/acceptance/authentication-test.js b/ghost/admin/tests/acceptance/authentication-test.js index 47c4498ea2..4fb3035999 100644 --- a/ghost/admin/tests/acceptance/authentication-test.js +++ b/ghost/admin/tests/acceptance/authentication-test.js @@ -3,7 +3,7 @@ import windowProxy from 'ghost-admin/utils/window-proxy'; import {Response} from 'miragejs'; import {afterEach, beforeEach, describe, it} from 'mocha'; import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; -import {currentRouteName, currentURL, fillIn, findAll, triggerKeyEvent, visit, waitFor} from '@ember/test-helpers'; +import {click, currentRouteName, currentURL, fillIn, findAll, triggerKeyEvent, visit, waitFor} from '@ember/test-helpers'; import {expect} from 'chai'; import {run} from '@ember/runloop'; import {setupApplicationTest} from 'ember-mocha'; @@ -117,6 +117,33 @@ describe('Acceptance: Authentication', function () { expect(currentRouteName(), 'path after invalid url').to.equal('error404'); expect(findAll('nav.gh-nav').length, 'nav menu presence').to.equal(1); }); + + it('has 2fa code happy path', async function () { + this.server.post('/session', function () { + return new Response(403, {}, { + errors: { + code: '2FA_TOKEN_REQUIRED' + } + }); + }); + + this.server.put('/session/verify', function () { + return new Response(201); + }); + + await invalidateSession(); + await visit('/signin'); + await fillIn('[data-test-input="email"]', 'my@email.com'); + await fillIn('[data-test-input="password"]', 'password'); + await click('[data-test-button="sign-in"]'); + + expect(currentURL(), 'url after u+p submit').to.equal('/signin/verify'); + + await fillIn('[data-test-input="token"]', 123456); + await click('[data-test-button="verify"]'); + + expect(currentURL()).to.equal('/dashboard'); + }); }); describe('editor', function () { diff --git a/ghost/admin/tests/unit/authenticators/cookie-test.js b/ghost/admin/tests/unit/authenticators/cookie-test.js index 09ff2dd663..8cab8f1b4b 100644 --- a/ghost/admin/tests/unit/authenticators/cookie-test.js +++ b/ghost/admin/tests/unit/authenticators/cookie-test.js @@ -10,6 +10,7 @@ const mockAjax = Service.extend({ init() { this._super(...arguments); this.post = sinon.stub().resolves(); + this.put = sinon.stub().resolves(); this.del = sinon.stub().resolves(); } }); @@ -61,7 +62,7 @@ describe('Unit: Authenticator: cookie', () => { let authenticator = this.owner.lookup('authenticator:cookie'); let post = authenticator.ajax.post; - return authenticator.authenticate('AzureDiamond', 'hunter2').then(() => { + return authenticator.authenticate({identification: 'AzureDiamond', password: 'hunter2'}).then(() => { expect(post.args[0][0]).to.equal(`${ghostPaths().apiRoot}/session`); expect(post.args[0][1]).to.deep.include({ data: { @@ -77,6 +78,26 @@ describe('Unit: Authenticator: cookie', () => { }); }); }); + + it('puts the token to the sessionVerifyEndpoint and returns the promise', function () { + let authenticator = this.owner.lookup('authenticator:cookie'); + let put = authenticator.ajax.put; + + return authenticator.authenticate({token: '123456'}).then(() => { + expect(put.args[0][0]).to.equal(`${ghostPaths().apiRoot}/session/verify`); + expect(put.args[0][1]).to.deep.include({ + data: { + token: '123456' + } + }); + expect(put.args[0][1]).to.deep.include({ + dataType: 'text' + }); + expect(put.args[0][1]).to.deep.include({ + contentType: 'application/json;charset=utf-8' + }); + }); + }); }); describe('#invalidate', function () {