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`;
|
||||
}),
|
||||
|
||||
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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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'});
|
||||
|
@ -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) => {
|
||||
|
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 {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.';
|
||||
}
|
||||
|
@ -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');
|
||||
|
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"
|
||||
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}}
|
||||
/>
|
||||
|
||||
|
@ -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 () {
|
||||
|
@ -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 () {
|
||||
|
Loading…
Reference in New Issue
Block a user