mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-26 13:35:16 +03:00
Added 2fa happy path to Admin
closes https://linear.app/tryghost/issue/ENG-1617/ closes https://linear.app/tryghost/issue/ENG-1619/ - updated cookie authenticator's `authenticate` method to accept an `{identification, pasword, token}` object - if `token` is provided, hit our `PUT /session/verify/` endpoint passing through the token instead of hitting the `POST /session/` endpoint - added `signin/verify` route - displays a 2fa code input field, including required attributes for macOS auto-fill from email/messages to work - uses `session.authenticate({token})` when submitted - updated signin routine to detect token-required state - detects a `403` response with a `2FA_TOKEN_REQUIRED` code property when authenticating - if detected transitions to the `signin/verify` route
This commit is contained in:
parent
a70e88b903
commit
68af12cfad
@ -11,11 +11,27 @@ export default Authenticator.extend({
|
|||||||
return `${this.ghostPaths.apiRoot}/session`;
|
return `${this.ghostPaths.apiRoot}/session`;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
sessionVerifyEndpoint: computed('ghostPaths.apiRoot', function () {
|
||||||
|
return `${this.ghostPaths.apiRoot}/session/verify`;
|
||||||
|
}),
|
||||||
|
|
||||||
restore: function () {
|
restore: function () {
|
||||||
return RSVP.resolve();
|
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 data = {username: identification, password};
|
||||||
const options = {
|
const options = {
|
||||||
data,
|
data,
|
||||||
|
@ -87,13 +87,12 @@ export default class ReAuthenticateModal extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _authenticate() {
|
async _authenticate() {
|
||||||
const authStrategy = 'authenticator:cookie';
|
|
||||||
const {identification, password} = this.signin;
|
const {identification, password} = this.signin;
|
||||||
|
|
||||||
this.session.skipAuthSuccessHandler = true;
|
this.session.skipAuthSuccessHandler = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.session.authenticate(authStrategy, identification, password);
|
await this.session.authenticate('authenticator:cookie', {identification, password});
|
||||||
} finally {
|
} finally {
|
||||||
this.session.skipAuthSuccessHandler = undefined;
|
this.session.skipAuthSuccessHandler = undefined;
|
||||||
}
|
}
|
||||||
|
@ -66,7 +66,7 @@ export default class ResetController extends Controller.extend(ValidationEngine)
|
|||||||
resp.password_reset[0].message,
|
resp.password_reset[0].message,
|
||||||
{type: 'info', delayed: true, key: 'password.reset'}
|
{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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.notifications.showAPIError(error, {key: 'password.reset'});
|
this.notifications.showAPIError(error, {key: 'password.reset'});
|
||||||
|
@ -50,12 +50,12 @@ export default class SetupController extends Controller.extend(ValidationEngine)
|
|||||||
})
|
})
|
||||||
setupTask;
|
setupTask;
|
||||||
|
|
||||||
@task(function* (authStrategy, authentication) {
|
@task(function* (authStrategy, {identification, password}) {
|
||||||
// we don't want to redirect after sign-in during setup
|
// we don't want to redirect after sign-in during setup
|
||||||
this.session.skipAuthSuccessHandler = true;
|
this.session.skipAuthSuccessHandler = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
yield this.session.authenticate(authStrategy, ...authentication);
|
yield this.session.authenticate(authStrategy, {identification, password});
|
||||||
|
|
||||||
this.errors.remove('session');
|
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
|
// Don't call the success handler, otherwise we will be redirected to admin
|
||||||
this.session.skipAuthSuccessHandler = true;
|
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);
|
this.set('blogCreated', true);
|
||||||
return this._afterAuthentication(result);
|
return this._afterAuthentication(result);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
|
36
ghost/admin/app/controllers/signin-verify.js
Normal file
36
ghost/admin/app/controllers/signin-verify.js
Normal file
@ -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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -7,6 +7,7 @@ import {action} from '@ember/object';
|
|||||||
import {htmlSafe} from '@ember/template';
|
import {htmlSafe} from '@ember/template';
|
||||||
import {inject} from 'ghost-admin/decorators/inject';
|
import {inject} from 'ghost-admin/decorators/inject';
|
||||||
import {isArray as isEmberArray} from '@ember/array';
|
import {isArray as isEmberArray} from '@ember/array';
|
||||||
|
import {isForbiddenError} from 'ember-ajax/errors';
|
||||||
import {isVersionMismatchError} from 'ghost-admin/services/ajax';
|
import {isVersionMismatchError} from 'ghost-admin/services/ajax';
|
||||||
import {inject as service} from '@ember/service';
|
import {inject as service} from '@ember/service';
|
||||||
import {task} from 'ember-concurrency';
|
import {task} from 'ember-concurrency';
|
||||||
@ -18,6 +19,7 @@ export default class SigninController extends Controller.extend(ValidationEngine
|
|||||||
@service ajax;
|
@service ajax;
|
||||||
@service ghostPaths;
|
@service ghostPaths;
|
||||||
@service notifications;
|
@service notifications;
|
||||||
|
@service router;
|
||||||
@service session;
|
@service session;
|
||||||
@service settings;
|
@service settings;
|
||||||
|
|
||||||
@ -49,12 +51,17 @@ export default class SigninController extends Controller.extend(ValidationEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
@task({drop: true})
|
@task({drop: true})
|
||||||
*authenticateTask(authStrategy, authentication) {
|
*authenticateTask(authStrategy, {identification, password}) {
|
||||||
try {
|
try {
|
||||||
return yield this.session
|
return yield this.session
|
||||||
.authenticate(authStrategy, ...authentication)
|
.authenticate(authStrategy, {identification, password})
|
||||||
.then(() => true); // ensure task button transitions to "success" state
|
.then(() => true); // ensure task button transitions to "success" state
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isForbiddenError(error)) {
|
||||||
|
this.router.transitionTo('signin-verify');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isVersionMismatchError(error)) {
|
if (isVersionMismatchError(error)) {
|
||||||
return this.notifications.showAPIError(error);
|
return this.notifications.showAPIError(error);
|
||||||
}
|
}
|
||||||
@ -100,8 +107,7 @@ export default class SigninController extends Controller.extend(ValidationEngine
|
|||||||
|
|
||||||
@task({drop: true})
|
@task({drop: true})
|
||||||
*validateAndAuthenticateTask() {
|
*validateAndAuthenticateTask() {
|
||||||
let signin = this.signin;
|
const {identification, password} = this.signin;
|
||||||
let authStrategy = 'authenticator:cookie';
|
|
||||||
|
|
||||||
this.flowErrors = '';
|
this.flowErrors = '';
|
||||||
|
|
||||||
@ -111,7 +117,7 @@ export default class SigninController extends Controller.extend(ValidationEngine
|
|||||||
try {
|
try {
|
||||||
yield this.validate({property: 'signin'});
|
yield this.validate({property: 'signin'});
|
||||||
return yield this.authenticateTask
|
return yield this.authenticateTask
|
||||||
.perform(authStrategy, [signin.identification, signin.password]);
|
.perform('authenticator:cookie', {identification, password});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.flowErrors = 'Please fill out the form to sign in.';
|
this.flowErrors = 'Please fill out the form to sign in.';
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ Router.map(function () {
|
|||||||
this.route('setup.done', {path: '/setup/done'});
|
this.route('setup.done', {path: '/setup/done'});
|
||||||
|
|
||||||
this.route('signin');
|
this.route('signin');
|
||||||
|
this.route('signin-verify', {path: '/signin/verify'});
|
||||||
this.route('signout');
|
this.route('signout');
|
||||||
this.route('signup', {path: '/signup/:token'});
|
this.route('signup', {path: '/signup/:token'});
|
||||||
this.route('reset', {path: '/reset/: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.analytics', {path: '/posts/analytics/:post_id'});
|
||||||
this.route('posts.mentions', {path: '/posts/analytics/:post_id/mentions'});
|
this.route('posts.mentions', {path: '/posts/analytics/:post_id/mentions'});
|
||||||
this.route('posts.debug', {path: '/posts/analytics/:post_id/debug'});
|
this.route('posts.debug', {path: '/posts/analytics/:post_id/debug'});
|
||||||
|
|
||||||
this.route('restore-posts', {path: '/restore'});
|
this.route('restore-posts', {path: '/restore'});
|
||||||
|
|
||||||
this.route('pages');
|
this.route('pages');
|
||||||
|
44
ghost/admin/app/templates/signin-verify.hbs
Normal file
44
ghost/admin/app/templates/signin-verify.hbs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<div class="gh-flow">
|
||||||
|
<div class="gh-flow-content-wrap">
|
||||||
|
<section class="gh-flow-content">
|
||||||
|
<form id="login" method="post" action="javascript:void(0)" class="gh-signin" novalidate="novalidate" {{on "submit" (perform this.verifyTokenTask)}}>
|
||||||
|
<header>
|
||||||
|
<div class="gh-site-icon" style={{site-icon-style}}></div>
|
||||||
|
<h1>New browser detected</h1>
|
||||||
|
<p>
|
||||||
|
For security, you need to verify your sign-in.
|
||||||
|
An email has been sent to you with a 6-digit code, please enter it below.
|
||||||
|
</p>
|
||||||
|
<GhFormGroup @errors={{this.errors}} @hasValidated={{this.hasValidated}} @property="token">
|
||||||
|
<label for="token">Verification code</label>
|
||||||
|
<span class="gh-input-icon">
|
||||||
|
<input
|
||||||
|
id="token"
|
||||||
|
name="token"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
placeholder="123456"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
class="gh-input email"
|
||||||
|
value={{this.token}}
|
||||||
|
data-test-input="token"
|
||||||
|
{{on "input" this.handleTokenInput}}
|
||||||
|
{{on "blur" this.validateToken}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</GhFormGroup>
|
||||||
|
|
||||||
|
<GhTaskButton
|
||||||
|
@buttonText="Verify sign-in →"
|
||||||
|
@task={{this.verifyTokenTask}}
|
||||||
|
@showSuccess={{false}}
|
||||||
|
@class="login gh-btn gh-btn-login gh-btn-block gh-btn-icon"
|
||||||
|
@type="submit"
|
||||||
|
@useAccentColor={{true}}
|
||||||
|
data-test-button="verify" />
|
||||||
|
</header>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -30,6 +30,7 @@
|
|||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
value={{this.signin.identification}}
|
value={{this.signin.identification}}
|
||||||
|
data-test-input="email"
|
||||||
{{on "input" this.handleInput}}
|
{{on "input" this.handleInput}}
|
||||||
{{on "blur" (fn this.validateProperty "identification")}}
|
{{on "blur" (fn this.validateProperty "identification")}}
|
||||||
/>
|
/>
|
||||||
@ -47,6 +48,7 @@
|
|||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
value={{this.signin.password}}
|
value={{this.signin.password}}
|
||||||
|
data-test-input="password"
|
||||||
{{on "input" this.handleInput}}
|
{{on "input" this.handleInput}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import windowProxy from 'ghost-admin/utils/window-proxy';
|
|||||||
import {Response} from 'miragejs';
|
import {Response} from 'miragejs';
|
||||||
import {afterEach, beforeEach, describe, it} from 'mocha';
|
import {afterEach, beforeEach, describe, it} from 'mocha';
|
||||||
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
|
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 {expect} from 'chai';
|
||||||
import {run} from '@ember/runloop';
|
import {run} from '@ember/runloop';
|
||||||
import {setupApplicationTest} from 'ember-mocha';
|
import {setupApplicationTest} from 'ember-mocha';
|
||||||
@ -117,6 +117,33 @@ describe('Acceptance: Authentication', function () {
|
|||||||
expect(currentRouteName(), 'path after invalid url').to.equal('error404');
|
expect(currentRouteName(), 'path after invalid url').to.equal('error404');
|
||||||
expect(findAll('nav.gh-nav').length, 'nav menu presence').to.equal(1);
|
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 () {
|
describe('editor', function () {
|
||||||
|
@ -10,6 +10,7 @@ const mockAjax = Service.extend({
|
|||||||
init() {
|
init() {
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
this.post = sinon.stub().resolves();
|
this.post = sinon.stub().resolves();
|
||||||
|
this.put = sinon.stub().resolves();
|
||||||
this.del = sinon.stub().resolves();
|
this.del = sinon.stub().resolves();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -61,7 +62,7 @@ describe('Unit: Authenticator: cookie', () => {
|
|||||||
let authenticator = this.owner.lookup('authenticator:cookie');
|
let authenticator = this.owner.lookup('authenticator:cookie');
|
||||||
let post = authenticator.ajax.post;
|
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][0]).to.equal(`${ghostPaths().apiRoot}/session`);
|
||||||
expect(post.args[0][1]).to.deep.include({
|
expect(post.args[0][1]).to.deep.include({
|
||||||
data: {
|
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 () {
|
describe('#invalidate', function () {
|
||||||
|
Loading…
Reference in New Issue
Block a user