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:
Kevin Ansfield 2024-10-08 17:01:45 +01:00
parent a70e88b903
commit 68af12cfad
11 changed files with 167 additions and 15 deletions

View File

@ -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,

View File

@ -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;
} }

View File

@ -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'});

View File

@ -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) => {

View 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.';
}
}
}
}

View File

@ -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.';
} }

View File

@ -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');

View 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 &rarr;"
@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>

View File

@ -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}}
/> />

View File

@ -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 () {

View File

@ -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 () {