From b3ed11719e7f04df2dbe22eb6ce98a63a5624042 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Wed, 17 Jul 2019 16:43:07 +0200 Subject: [PATCH 01/22] Migrated authentication.resetPassword method to v2 --- core/server/api/v2/authentication.js | 54 +++ core/server/api/v2/index.js | 4 + core/server/web/api/v2/admin/routes.js | 4 +- .../api/v2/admin/authentication_spec.js | 416 ++++++++++++++++++ 4 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 core/server/api/v2/authentication.js create mode 100644 core/test/regression/api/v2/admin/authentication_spec.js diff --git a/core/server/api/v2/authentication.js b/core/server/api/v2/authentication.js new file mode 100644 index 000000000000..f636edb1dad4 --- /dev/null +++ b/core/server/api/v2/authentication.js @@ -0,0 +1,54 @@ +const auth = require('../../services/auth'); +const api = require('./index'); +const web = require('../../web'); + +module.exports = { + docName: 'passwordreset', + + generateResetToken: { + permissions: true, + options: [ + 'email' + ], + query(frame) { + return Promise.resolve() + .then(() => { + return auth.setup.assertSetupCompleted(true); + }) + .then(() => { + return auth.passwordreset.generateToken(frame.data.email, api.settings); + }) + .then((token) => { + return auth.passwordreset.sendResetNotification(token, api.mail); + }); + } + }, + resetPassword: { + permissions: false, + options: [ + 'ip' + ], + query(frame) { + return Promise.resolve() + .then(() => { + return auth.setup.assertSetupCompleted(true); + }) + .then(() => { + // correct arguments used here + return auth.passwordreset.extractTokenParts(frame); + }) + .then((params) => { + return auth.passwordreset.protectBruteForce(params); + }) + .then(({options, tokenParts}) => { + options = Object.assign(options, {context: {internal: true}}); + return auth.passwordreset.doReset(options, tokenParts, api.settings) + .then((params) => { + // TODO: check opts.ip in frame! + web.shared.middlewares.api.spamPrevention.userLogin().reset(frame.options.ip, `${tokenParts.email}login`); + return params; + }); + }); + } + } +}; diff --git a/core/server/api/v2/index.js b/core/server/api/v2/index.js index 11e7872e6908..9ff2313ab309 100644 --- a/core/server/api/v2/index.js +++ b/core/server/api/v2/index.js @@ -6,6 +6,10 @@ module.exports = { return shared.http; }, + get authentication() { + return shared.pipeline(require('./authentication'), localUtils); + }, + get db() { return shared.pipeline(require('./db'), localUtils); }, diff --git a/core/server/web/api/v2/admin/routes.js b/core/server/web/api/v2/admin/routes.js index 8742ca5115ea..691ef57c3074 100644 --- a/core/server/web/api/v2/admin/routes.js +++ b/core/server/web/api/v2/admin/routes.js @@ -184,9 +184,9 @@ module.exports = function apiRoutes() { router.post('/authentication/passwordreset', shared.middlewares.brute.globalReset, shared.middlewares.brute.userReset, - api.http(api.authentication.generateResetToken) + api.http(apiv2.authentication.generateResetToken) ); - router.put('/authentication/passwordreset', shared.middlewares.brute.globalBlock, api.http(api.authentication.resetPassword)); + router.put('/authentication/passwordreset', shared.middlewares.brute.globalBlock, api.http(apiv2.authentication.resetPassword)); router.post('/authentication/invitation', api.http(api.authentication.acceptInvitation)); router.get('/authentication/invitation', api.http(api.authentication.isInvitation)); router.post('/authentication/setup', api.http(api.authentication.setup)); diff --git a/core/test/regression/api/v2/admin/authentication_spec.js b/core/test/regression/api/v2/admin/authentication_spec.js new file mode 100644 index 000000000000..58e548a49f2e --- /dev/null +++ b/core/test/regression/api/v2/admin/authentication_spec.js @@ -0,0 +1,416 @@ +const should = require('should'); +const supertest = require('supertest'); +const testUtils = require('../../../../utils/index'); +const localUtils = require('./utils'); +const moment = require('moment'); +const user = testUtils.DataGenerator.forModel.users[0]; +const models = require('../../../../../server/models/index'); +const constants = require('../../../../../server/lib/constants'); +const config = require('../../../../../server/config/index'); +const security = require('../../../../../server/lib/security/index'); +const settingsCache = require('../../../../../server/services/settings/cache'); + +let ghost = testUtils.startGhost; +let request; + +describe.only('Authentication API v2', function () { + var accesstoken = '', ghostServer; + + describe('auth & authorize', function () { + before(function () { + return ghost() + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + }) + .then(function () { + return localUtils.doAuth(request); + }) + .then(function (token) { + accesstoken = token; + }); + }); + + afterEach(function () { + return testUtils.clearBruteData(); + }); + + it('can authenticate', function (done) { + request.post(localUtils.API.getApiQuery('authentication/token')) + .set('Origin', config.get('url')) + .send({ + grant_type: 'password', + username: user.email, + password: user.password, + client_id: 'ghost-admin', + client_secret: 'not_available' + }) + .expect('Content-Type', /json/) + // TODO: make it possible to override oauth2orize's header so that this is consistent + .expect('Cache-Control', 'no-store') + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + should.not.exist(res.headers['x-cache-invalidate']); + var jsonResponse = res.body, + newAccessToken; + + should.exist(jsonResponse.access_token); + should.exist(jsonResponse.refresh_token); + should.exist(jsonResponse.expires_in); + should.exist(jsonResponse.token_type); + + models.Accesstoken.findOne({ + token: jsonResponse.access_token + }).then(function (_newAccessToken) { + newAccessToken = _newAccessToken; + + return models.Refreshtoken.findOne({ + token: jsonResponse.refresh_token + }); + }).then(function (newRefreshToken) { + newAccessToken.get('issued_by').should.eql(newRefreshToken.id); + done(); + }).catch(done); + }); + }); + + it('can\'t authenticate unknown user', function (done) { + request.post(localUtils.API.getApiQuery('authentication/token')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + grant_type: 'password', + username: 'invalid@email.com', + password: user.password, + client_id: 'ghost-admin', + client_secret: 'not_available' + }).expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(404) + .end(function (err, res) { + if (err) { + return done(err); + } + var jsonResponse = res.body; + should.exist(jsonResponse.errors[0].errorType); + jsonResponse.errors[0].errorType.should.eql('NotFoundError'); + done(); + }); + }); + + it('can\'t authenticate invalid password user', function (done) { + request.post(localUtils.API.getApiQuery('authentication/token')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + grant_type: 'password', + username: user.email, + password: 'invalid', + client_id: 'ghost-admin', + client_secret: 'not_available' + }).expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422) + .end(function (err, res) { + if (err) { + return done(err); + } + var jsonResponse = res.body; + should.exist(jsonResponse.errors[0].errorType); + jsonResponse.errors[0].errorType.should.eql('ValidationError'); + done(); + }); + }); + + it('can request new access token', function (done) { + request.post(localUtils.API.getApiQuery('authentication/token')) + .set('Origin', config.get('url')) + .send({ + grant_type: 'password', + username: user.email, + password: user.password, + client_id: 'ghost-admin', + client_secret: 'not_available' + }) + .expect('Content-Type', /json/) + // TODO: make it possible to override oauth2orize's header so that this is consistent + .expect('Cache-Control', 'no-store') + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + var refreshToken = res.body.refresh_token; + + models.Accesstoken.findOne({ + token: accesstoken + }).then(function (oldAccessToken) { + moment(oldAccessToken.get('expires')).diff(moment(), 'minutes').should.be.above(6); + + request.post(localUtils.API.getApiQuery('authentication/token')) + .set('Origin', config.get('url')) + .set('Authorization', 'Bearer ' + accesstoken) + .send({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: 'ghost-admin', + client_secret: 'not_available' + }) + .expect('Content-Type', /json/) + // TODO: make it possible to override oauth2orize's header so that this is consistent + .expect('Cache-Control', 'no-store') + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + var jsonResponse = res.body; + should.exist(jsonResponse.access_token); + should.exist(jsonResponse.expires_in); + + models.Accesstoken.findOne({ + token: accesstoken + }).then(function (oldAccessToken) { + moment(oldAccessToken.get('expires')).diff(moment(), 'minutes').should.be.below(6); + return models.Refreshtoken.findOne({ + token: refreshToken + }); + }).then(function (refreshTokenModel) { + // NOTE: the static 6 month ms number in our constants are based on 30 days + // We have to compare against the static number. We can't compare against the month in + // the next 6 month dynamically, because each month has a different number of days, + // which results in a different ms number. + moment(Date.now() + constants.SIX_MONTH_MS) + .startOf('day') + .diff(moment(refreshTokenModel.get('expires')).startOf('day'), 'month').should.eql(0); + + done(); + }); + }); + }); + }); + }); + + it('can\'t request new access token with invalid refresh token', function (done) { + request.post(localUtils.API.getApiQuery('authentication/token')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + grant_type: 'refresh_token', + refresh_token: 'invalid', + client_id: 'ghost-admin', + client_secret: 'not_available' + }).expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(403) + .end(function (err, res) { + if (err) { + return done(err); + } + var jsonResponse = res.body; + should.exist(jsonResponse.errors[0].errorType); + jsonResponse.errors[0].errorType.should.eql('NoPermissionError'); + done(); + }); + }); + + it('reset password', function (done) { + models.User.getOwnerUser(testUtils.context.internal) + .then(function (ownerUser) { + var token = security.tokens.resetToken.generateHash({ + expires: Date.now() + (1000 * 60), + email: user.email, + dbHash: settingsCache.get('db_hash'), + password: ownerUser.get('password') + }); + + request.put(localUtils.API.getApiQuery('authentication/passwordreset')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + token: token, + newPassword: 'thisissupersafe', + ne2Password: 'thisissupersafe' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err) { + if (err) { + return done(err); + } + + done(); + }); + }) + .catch(done); + }); + + it('reset password: invalid token', function () { + return request + .put(localUtils.API.getApiQuery('authentication/passwordreset')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + token: 'invalid', + newPassword: 'thisissupersafe', + ne2Password: 'thisissupersafe' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(401); + }); + + it('revoke token', function () { + return request + .post(localUtils.API.getApiQuery('authentication/revoke')) + .set('Authorization', 'Bearer ' + accesstoken) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + token: accesstoken, + token_type_hint: 'access_token' + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then(() => { + return request + .get(localUtils.API.getApiQuery('posts/')) + .set('Authorization', 'Bearer ' + accesstoken) + .expect(401); + }); + }); + }); + + describe('Blog setup', function () { + before(function () { + return ghost({forceStart: true}) + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + }); + }); + + it('is setup? no', function () { + return request + .get(localUtils.API.getApiQuery('authentication/setup')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + res.body.setup[0].status.should.be.false(); + }); + }); + + it('complete setup', function () { + return request + .post(localUtils.API.getApiQuery('authentication/setup')) + .set('Origin', config.get('url')) + .send({ + setup: [{ + name: 'test user', + email: 'test@example.com', + password: 'thisissupersafe', + blogTitle: 'a test blog' + }] + }) + .expect('Content-Type', /json/) + .expect(201) + .then((res) => { + const jsonResponse = res.body; + should.exist(jsonResponse.users); + should.not.exist(jsonResponse.meta); + + jsonResponse.users.should.have.length(1); + localUtils.API.checkResponse(jsonResponse.users[0], 'user'); + + const newUser = jsonResponse.users[0]; + newUser.id.should.equal(testUtils.DataGenerator.Content.users[0].id); + newUser.name.should.equal('test user'); + newUser.email.should.equal('test@example.com'); + }); + }); + + it('is setup? yes', function () { + return request + .get(localUtils.API.getApiQuery('authentication/setup')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + res.body.setup[0].status.should.be.true(); + }); + }); + + it('complete setup again', function () { + return request + .post(localUtils.API.getApiQuery('authentication/setup')) + .set('Origin', config.get('url')) + .send({ + setup: [{ + name: 'test user', + email: 'test-leo@example.com', + password: 'thisissupersafe', + blogTitle: 'a test blog' + }] + }) + .expect('Content-Type', /json/) + .expect(403); + }); + }); + + describe('Invitation', function () { + before(function () { + return ghost() + .then(function (_ghostServer) { + ghostServer = _ghostServer; + request = supertest.agent(config.get('url')); + + // simulates blog setup (initialises the owner) + return localUtils.doAuth(request, 'invites'); + }); + }); + + it('try to accept without invite', function () { + return request + .post(localUtils.API.getApiQuery('authentication/invitation')) + .set('Origin', config.get('url')) + .send({ + invitation: [{ + token: 'lul11111', + password: 'lel123456', + email: 'not-invited@example.org', + name: 'not invited' + }] + }) + .expect('Content-Type', /json/) + .expect(404); + }); + + it('try to accept with invite', function () { + return request + .post(localUtils.API.getApiQuery('authentication/invitation')) + .set('Origin', config.get('url')) + .send({ + invitation: [{ + token: testUtils.DataGenerator.forKnex.invites[0].token, + password: '12345678910', + email: testUtils.DataGenerator.forKnex.invites[0].email, + name: 'invited' + }] + }) + .expect('Content-Type', /json/) + .expect(200); + }); + }); +}); From 8135d4d188c0772a268f920d82d5ec88fb4f35fb Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Tue, 23 Jul 2019 18:30:17 +0200 Subject: [PATCH 02/22] Added validation layer to password reset - Adding a new method in all.js seems a little dirty, but that seems like the best place for now as similar method was added for changePassword method --- core/server/api/shared/validators/input/all.js | 5 +++++ core/server/api/v2/authentication.js | 12 +++++++++--- .../api/v2/utils/validators/input/index.js | 4 ++++ .../v2/utils/validators/input/passwordreset.js | 17 +++++++++++++++++ .../api/v2/admin/authentication_spec.js | 16 ++++++++++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 core/server/api/v2/utils/validators/input/passwordreset.js diff --git a/core/server/api/shared/validators/input/all.js b/core/server/api/shared/validators/input/all.js index 8a918cac08ac..c2096a26a64d 100644 --- a/core/server/api/shared/validators/input/all.js +++ b/core/server/api/shared/validators/input/all.js @@ -187,5 +187,10 @@ module.exports = { changePassword() { debug('validate changePassword'); return this.add(...arguments); + }, + + resetPassword() { + debug('validate resetPassword'); + return this.add(...arguments); } }; diff --git a/core/server/api/v2/authentication.js b/core/server/api/v2/authentication.js index f636edb1dad4..409e31d44a4b 100644 --- a/core/server/api/v2/authentication.js +++ b/core/server/api/v2/authentication.js @@ -3,7 +3,7 @@ const api = require('./index'); const web = require('../../web'); module.exports = { - docName: 'passwordreset', + docName: 'authentication', generateResetToken: { permissions: true, @@ -24,6 +24,14 @@ module.exports = { } }, resetPassword: { + validation: { + docName: 'passwordreset', + data: { + token: {required: true}, + newPassword: {required: true}, + ne2Password: {required: true} + } + }, permissions: false, options: [ 'ip' @@ -34,7 +42,6 @@ module.exports = { return auth.setup.assertSetupCompleted(true); }) .then(() => { - // correct arguments used here return auth.passwordreset.extractTokenParts(frame); }) .then((params) => { @@ -44,7 +51,6 @@ module.exports = { options = Object.assign(options, {context: {internal: true}}); return auth.passwordreset.doReset(options, tokenParts, api.settings) .then((params) => { - // TODO: check opts.ip in frame! web.shared.middlewares.api.spamPrevention.userLogin().reset(frame.options.ip, `${tokenParts.email}login`); return params; }); diff --git a/core/server/api/v2/utils/validators/input/index.js b/core/server/api/v2/utils/validators/input/index.js index 6ad410c15ad9..23e13626615b 100644 --- a/core/server/api/v2/utils/validators/input/index.js +++ b/core/server/api/v2/utils/validators/input/index.js @@ -1,4 +1,8 @@ module.exports = { + get passwordreset() { + return require('./passwordreset'); + }, + get posts() { return require('./posts'); }, diff --git a/core/server/api/v2/utils/validators/input/passwordreset.js b/core/server/api/v2/utils/validators/input/passwordreset.js new file mode 100644 index 000000000000..eabb9a147ea5 --- /dev/null +++ b/core/server/api/v2/utils/validators/input/passwordreset.js @@ -0,0 +1,17 @@ +const Promise = require('bluebird'); +const debug = require('ghost-ignition').debug('api:v2:utils:validators:input:users'); +const common = require('../../../../../lib/common'); + +module.exports = { + resetPassword(apiConfig, frame) { + debug('resetPassword'); + + const data = frame.data.passwordreset[0]; + + if (data.newPassword !== data.ne2Password) { + return Promise.reject(new common.errors.ValidationError({ + message: common.i18n.t('errors.models.user.newPasswordsDoNotMatch') + })); + } + } +}; diff --git a/core/test/regression/api/v2/admin/authentication_spec.js b/core/test/regression/api/v2/admin/authentication_spec.js index 58e548a49f2e..b13ef57be7c8 100644 --- a/core/test/regression/api/v2/admin/authentication_spec.js +++ b/core/test/regression/api/v2/admin/authentication_spec.js @@ -253,6 +253,22 @@ describe.only('Authentication API v2', function () { .catch(done); }); + it('reset password: invalid passwords', function () { + return request.put(localUtils.API.getApiQuery('authentication/passwordreset')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + token: 'doesntmatter', + newPassword: 'thisissupersafe', + ne2Password: 'thisissupersafebutdoesntmatch' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + it('reset password: invalid token', function () { return request .put(localUtils.API.getApiQuery('authentication/passwordreset')) From 4da03a38b634bc06c1f1ace81b65fb826c4237d2 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Tue, 23 Jul 2019 18:32:30 +0200 Subject: [PATCH 03/22] Corrected debug namespace --- core/server/api/v2/utils/validators/input/passwordreset.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/server/api/v2/utils/validators/input/passwordreset.js b/core/server/api/v2/utils/validators/input/passwordreset.js index eabb9a147ea5..52114714cf96 100644 --- a/core/server/api/v2/utils/validators/input/passwordreset.js +++ b/core/server/api/v2/utils/validators/input/passwordreset.js @@ -1,5 +1,5 @@ const Promise = require('bluebird'); -const debug = require('ghost-ignition').debug('api:v2:utils:validators:input:users'); +const debug = require('ghost-ignition').debug('api:v2:utils:validators:input:passwordreset'); const common = require('../../../../../lib/common'); module.exports = { From f4b97d3bc8ac734029cda2e510ee3d8627dc2218 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Wed, 24 Jul 2019 12:40:18 +0200 Subject: [PATCH 04/22] Migrated authentication.acceptInvitation method to v2 --- core/server/api/v2/authentication.js | 19 ++++++++++++- .../serializers/output/authentication.js | 14 ++++++++++ .../v2/utils/validators/input/invitations.js | 28 +++++++++++++++++++ core/server/web/api/v2/admin/routes.js | 2 +- .../api/v2/admin/authentication_spec.js | 5 +++- 5 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 core/server/api/v2/utils/serializers/output/authentication.js create mode 100644 core/server/api/v2/utils/validators/input/invitations.js diff --git a/core/server/api/v2/authentication.js b/core/server/api/v2/authentication.js index 409e31d44a4b..41f51c4ab3d6 100644 --- a/core/server/api/v2/authentication.js +++ b/core/server/api/v2/authentication.js @@ -1,6 +1,7 @@ -const auth = require('../../services/auth'); const api = require('./index'); const web = require('../../web'); +const auth = require('../../services/auth'); +const invitations = require('../../services/invitations'); module.exports = { docName: 'authentication', @@ -56,5 +57,21 @@ module.exports = { }); }); } + }, + + acceptInvitation: { + validation: { + docName: 'invitations' + }, + permissions: false, + query(frame) { + return Promise.resolve() + .then(() => { + return auth.setup.assertSetupCompleted(true); + }) + .then(() => { + return invitations.accept(frame.data); + }); + } } }; diff --git a/core/server/api/v2/utils/serializers/output/authentication.js b/core/server/api/v2/utils/serializers/output/authentication.js new file mode 100644 index 000000000000..26371986a743 --- /dev/null +++ b/core/server/api/v2/utils/serializers/output/authentication.js @@ -0,0 +1,14 @@ +const common = require('../../../../../lib/common'); +const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:authentication'); + +module.exports = { + acceptInvitation(data, apiConfig, frame) { + debug('acceptInvitation'); + + frame.response = { + invitation: [ + {message: common.i18n.t('common.api.authentication.mail.invitationAccepted')} + ] + }; + } +}; diff --git a/core/server/api/v2/utils/validators/input/invitations.js b/core/server/api/v2/utils/validators/input/invitations.js new file mode 100644 index 000000000000..78042a239c80 --- /dev/null +++ b/core/server/api/v2/utils/validators/input/invitations.js @@ -0,0 +1,28 @@ +const Promise = require('bluebird'); +const debug = require('ghost-ignition').debug('api:v2:utils:validators:input:invitation'); +const common = require('../../../../../lib/common'); + +module.exports = { + acceptInvitation(apiConfig, frame) { + debug('acceptInvitation'); + + const data = frame.data.invitation[0]; + + if (!data.token) { + return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.authentication.noTokenProvided')})); + } + + if (!data.email) { + return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.authentication.noEmailProvided')})); + } + + if (!data.password) { + return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.authentication.noPasswordProvided')})); + } + + if (!data.name) { + return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.authentication.noNameProvided')})); + } + } +}; + diff --git a/core/server/web/api/v2/admin/routes.js b/core/server/web/api/v2/admin/routes.js index 691ef57c3074..8bd80c701f64 100644 --- a/core/server/web/api/v2/admin/routes.js +++ b/core/server/web/api/v2/admin/routes.js @@ -187,7 +187,7 @@ module.exports = function apiRoutes() { api.http(apiv2.authentication.generateResetToken) ); router.put('/authentication/passwordreset', shared.middlewares.brute.globalBlock, api.http(apiv2.authentication.resetPassword)); - router.post('/authentication/invitation', api.http(api.authentication.acceptInvitation)); + router.post('/authentication/invitation', api.http(apiv2.authentication.acceptInvitation)); router.get('/authentication/invitation', api.http(api.authentication.isInvitation)); router.post('/authentication/setup', api.http(api.authentication.setup)); router.put('/authentication/setup', mw.authAdminApi, api.http(api.authentication.updateSetup)); diff --git a/core/test/regression/api/v2/admin/authentication_spec.js b/core/test/regression/api/v2/admin/authentication_spec.js index b13ef57be7c8..0e01ca79f9fc 100644 --- a/core/test/regression/api/v2/admin/authentication_spec.js +++ b/core/test/regression/api/v2/admin/authentication_spec.js @@ -426,7 +426,10 @@ describe.only('Authentication API v2', function () { }] }) .expect('Content-Type', /json/) - .expect(200); + .expect(200) + .then((res) => { + res.body.invitation[0].message.should.equal('Invitation accepted.'); + }); }); }); }); From 132e278a228cecc8e954e59347862e294566dc23 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Wed, 24 Jul 2019 14:53:09 +0200 Subject: [PATCH 05/22] Migrated authentication.isInvitation method to v2 --- core/server/api/v2/authentication.js | 19 ++++++++++++ .../serializers/output/authentication.js | 10 +++++++ .../api/v2/utils/serializers/output/index.js | 4 +++ .../api/v2/utils/validators/input/index.js | 4 +++ .../v2/utils/validators/input/invitations.js | 14 ++++++++- core/server/web/api/v2/admin/routes.js | 2 +- .../api/v2/admin/authentication_spec.js | 30 +++++++++++++++++++ 7 files changed, 81 insertions(+), 2 deletions(-) diff --git a/core/server/api/v2/authentication.js b/core/server/api/v2/authentication.js index 41f51c4ab3d6..39b9c1d4827b 100644 --- a/core/server/api/v2/authentication.js +++ b/core/server/api/v2/authentication.js @@ -1,5 +1,6 @@ const api = require('./index'); const web = require('../../web'); +const models = require('../../models'); const auth = require('../../services/auth'); const invitations = require('../../services/invitations'); @@ -73,5 +74,23 @@ module.exports = { return invitations.accept(frame.data); }); } + }, + + isInvitation: { + validation: { + docName: 'invitations' + }, + permissions: false, + query(frame) { + return Promise.resolve() + .then(() => { + return auth.setup.assertSetupCompleted(true); + }) + .then(() => { + const email = frame.data.email; + + return models.Invite.findOne({email: email, status: 'sent'}, frame.options) + }); + } } }; diff --git a/core/server/api/v2/utils/serializers/output/authentication.js b/core/server/api/v2/utils/serializers/output/authentication.js index 26371986a743..c59269959dc8 100644 --- a/core/server/api/v2/utils/serializers/output/authentication.js +++ b/core/server/api/v2/utils/serializers/output/authentication.js @@ -10,5 +10,15 @@ module.exports = { {message: common.i18n.t('common.api.authentication.mail.invitationAccepted')} ] }; + }, + + isInvitation(data, apiConfig, frame) { + debug('acceptInvitation'); + + frame.response = { + invitation: [{ + valid: !!data + }] + }; } }; diff --git a/core/server/api/v2/utils/serializers/output/index.js b/core/server/api/v2/utils/serializers/output/index.js index c709aae653b6..fc0d1c3506cf 100644 --- a/core/server/api/v2/utils/serializers/output/index.js +++ b/core/server/api/v2/utils/serializers/output/index.js @@ -3,6 +3,10 @@ module.exports = { return require('./all'); }, + get authentication() { + return require('./authentication'); + }, + get db() { return require('./db'); }, diff --git a/core/server/api/v2/utils/validators/input/index.js b/core/server/api/v2/utils/validators/input/index.js index 23e13626615b..014b3d988e1b 100644 --- a/core/server/api/v2/utils/validators/input/index.js +++ b/core/server/api/v2/utils/validators/input/index.js @@ -15,6 +15,10 @@ module.exports = { return require('./invites'); }, + get invitations() { + return require('./invitations'); + }, + get settings() { return require('./settings'); }, diff --git a/core/server/api/v2/utils/validators/input/invitations.js b/core/server/api/v2/utils/validators/input/invitations.js index 78042a239c80..444a76c2b420 100644 --- a/core/server/api/v2/utils/validators/input/invitations.js +++ b/core/server/api/v2/utils/validators/input/invitations.js @@ -1,4 +1,5 @@ const Promise = require('bluebird'); +const validator = require('validator'); const debug = require('ghost-ignition').debug('api:v2:utils:validators:input:invitation'); const common = require('../../../../../lib/common'); @@ -23,6 +24,17 @@ module.exports = { if (!data.name) { return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.authentication.noNameProvided')})); } + }, + + isInvitation(apiConfig, frame) { + debug('isInvitation'); + + const email = frame.data.email; + + if (typeof email !== 'string' || !validator.isEmail(email)) { + throw new common.errors.BadRequestError({ + message: common.i18n.t('errors.api.authentication.invalidEmailReceived') + }); + } } }; - diff --git a/core/server/web/api/v2/admin/routes.js b/core/server/web/api/v2/admin/routes.js index 8bd80c701f64..f21222fb7fa7 100644 --- a/core/server/web/api/v2/admin/routes.js +++ b/core/server/web/api/v2/admin/routes.js @@ -188,7 +188,7 @@ module.exports = function apiRoutes() { ); router.put('/authentication/passwordreset', shared.middlewares.brute.globalBlock, api.http(apiv2.authentication.resetPassword)); router.post('/authentication/invitation', api.http(apiv2.authentication.acceptInvitation)); - router.get('/authentication/invitation', api.http(api.authentication.isInvitation)); + router.get('/authentication/invitation', api.http(apiv2.authentication.isInvitation)); router.post('/authentication/setup', api.http(api.authentication.setup)); router.put('/authentication/setup', mw.authAdminApi, api.http(api.authentication.updateSetup)); router.get('/authentication/setup', api.http(api.authentication.isSetup)); diff --git a/core/test/regression/api/v2/admin/authentication_spec.js b/core/test/regression/api/v2/admin/authentication_spec.js index 0e01ca79f9fc..36696fc767d8 100644 --- a/core/test/regression/api/v2/admin/authentication_spec.js +++ b/core/test/regression/api/v2/admin/authentication_spec.js @@ -397,6 +397,36 @@ describe.only('Authentication API v2', function () { }); }); + it('check invite with invalid email', function () { + return request + .get(localUtils.API.getApiQuery('authentication/invitation?email=invalidemail')) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(400); + }); + + it('check valid invite', function () { + return request + .get(localUtils.API.getApiQuery(`authentication/invitation?email=${testUtils.DataGenerator.forKnex.invites[0].email}`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + res.body.invitation[0].valid.should.equal(true); + }); + }); + + it('check invalid invite', function () { + return request + .get(localUtils.API.getApiQuery(`authentication/invitation?email=notinvited@example.org`)) + .set('Origin', config.get('url')) + .expect('Content-Type', /json/) + .expect(200) + .then((res) => { + res.body.invitation[0].valid.should.equal(false); + }); + }); + it('try to accept without invite', function () { return request .post(localUtils.API.getApiQuery('authentication/invitation')) From a5990e555b38c3b503e87741f8a7aaa702545f60 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Wed, 24 Jul 2019 16:18:44 +0200 Subject: [PATCH 06/22] Fixed lint error --- core/server/api/v2/authentication.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/server/api/v2/authentication.js b/core/server/api/v2/authentication.js index 39b9c1d4827b..e44610ebc30f 100644 --- a/core/server/api/v2/authentication.js +++ b/core/server/api/v2/authentication.js @@ -89,7 +89,7 @@ module.exports = { .then(() => { const email = frame.data.email; - return models.Invite.findOne({email: email, status: 'sent'}, frame.options) + return models.Invite.findOne({email: email, status: 'sent'}, frame.options); }); } } From ddabd5e808f631ff570e0aba5c0d13d59ead7e90 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Wed, 24 Jul 2019 20:21:42 +0200 Subject: [PATCH 07/22] Migrated setup method --- .../server/api/shared/validators/input/all.js | 5 +++ core/server/api/v2/authentication.js | 36 ++++++++++++++++--- .../serializers/output/authentication.js | 9 +++++ core/server/web/api/v2/admin/routes.js | 2 +- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/core/server/api/shared/validators/input/all.js b/core/server/api/shared/validators/input/all.js index c2096a26a64d..f46e43992c5e 100644 --- a/core/server/api/shared/validators/input/all.js +++ b/core/server/api/shared/validators/input/all.js @@ -192,5 +192,10 @@ module.exports = { resetPassword() { debug('validate resetPassword'); return this.add(...arguments); + }, + + setup() { + debug('validate setup'); + return this.add(...arguments); } }; diff --git a/core/server/api/v2/authentication.js b/core/server/api/v2/authentication.js index e44610ebc30f..acfd947f2406 100644 --- a/core/server/api/v2/authentication.js +++ b/core/server/api/v2/authentication.js @@ -7,6 +7,33 @@ const invitations = require('../../services/invitations'); module.exports = { docName: 'authentication', + setup: { + permissions: false, + validation: { + docName: 'setup' + }, + query(frame) { + return Promise.resolve() + .then(() => { + return auth.setup.assertSetupCompleted(false)(); + }) + .then(() => { + const setupDetails = { + name: frame.data.setup[0].name, + email: frame.data.setup[0].email, + password: frame.data.setup[0].password, + blogTitle: frame.data.setup[0].blogTitle, + status: 'active' + }; + + return auth.setup.setupUser(setupDetails); + }) + .then((data) => { + return auth.setup.doSettings(data, api.settings); + }); + } + }, + generateResetToken: { permissions: true, options: [ @@ -15,7 +42,7 @@ module.exports = { query(frame) { return Promise.resolve() .then(() => { - return auth.setup.assertSetupCompleted(true); + return auth.setup.assertSetupCompleted(true)(); }) .then(() => { return auth.passwordreset.generateToken(frame.data.email, api.settings); @@ -25,6 +52,7 @@ module.exports = { }); } }, + resetPassword: { validation: { docName: 'passwordreset', @@ -41,7 +69,7 @@ module.exports = { query(frame) { return Promise.resolve() .then(() => { - return auth.setup.assertSetupCompleted(true); + return auth.setup.assertSetupCompleted(true)(); }) .then(() => { return auth.passwordreset.extractTokenParts(frame); @@ -68,7 +96,7 @@ module.exports = { query(frame) { return Promise.resolve() .then(() => { - return auth.setup.assertSetupCompleted(true); + return auth.setup.assertSetupCompleted(true)(); }) .then(() => { return invitations.accept(frame.data); @@ -84,7 +112,7 @@ module.exports = { query(frame) { return Promise.resolve() .then(() => { - return auth.setup.assertSetupCompleted(true); + return auth.setup.assertSetupCompleted(true)(); }) .then(() => { const email = frame.data.email; diff --git a/core/server/api/v2/utils/serializers/output/authentication.js b/core/server/api/v2/utils/serializers/output/authentication.js index c59269959dc8..a300a62ad0af 100644 --- a/core/server/api/v2/utils/serializers/output/authentication.js +++ b/core/server/api/v2/utils/serializers/output/authentication.js @@ -1,7 +1,16 @@ const common = require('../../../../../lib/common'); +const mapper = require('./utils/mapper'); const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:authentication'); module.exports = { + setup(user, apiConfig, frame) { + frame.response = { + users: [ + mapper.mapUser(user, {options: {context: {internal: true}}}) + ] + }; + }, + acceptInvitation(data, apiConfig, frame) { debug('acceptInvitation'); diff --git a/core/server/web/api/v2/admin/routes.js b/core/server/web/api/v2/admin/routes.js index f21222fb7fa7..b7296f508948 100644 --- a/core/server/web/api/v2/admin/routes.js +++ b/core/server/web/api/v2/admin/routes.js @@ -189,7 +189,7 @@ module.exports = function apiRoutes() { router.put('/authentication/passwordreset', shared.middlewares.brute.globalBlock, api.http(apiv2.authentication.resetPassword)); router.post('/authentication/invitation', api.http(apiv2.authentication.acceptInvitation)); router.get('/authentication/invitation', api.http(apiv2.authentication.isInvitation)); - router.post('/authentication/setup', api.http(api.authentication.setup)); + router.post('/authentication/setup', api.http(apiv2.authentication.setup)); router.put('/authentication/setup', mw.authAdminApi, api.http(api.authentication.updateSetup)); router.get('/authentication/setup', api.http(api.authentication.isSetup)); From 07782d8fa28ccf71054c361ebd272cb53298cf91 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Wed, 24 Jul 2019 20:22:08 +0200 Subject: [PATCH 08/22] Corrected object check to include 'url' property --- core/test/regression/api/v2/admin/utils.js | 1 + 1 file changed, 1 insertion(+) diff --git a/core/test/regression/api/v2/admin/utils.js b/core/test/regression/api/v2/admin/utils.js index e137e002f05b..608134276e7e 100644 --- a/core/test/regression/api/v2/admin/utils.js +++ b/core/test/regression/api/v2/admin/utils.js @@ -37,6 +37,7 @@ const expectedProperties = { .without('locale') .without('ghost_auth_access_token') .without('ghost_auth_id') + .concat('url') , tag: _(schema.tags) .keys() From 4441ee15a09b93d82e5598605ddd76bac3030723 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Thu, 25 Jul 2019 14:36:51 +0200 Subject: [PATCH 09/22] Migrated authentication.isSetup method to v2 --- core/server/api/v2/authentication.js | 17 +++++++++++++++++ .../utils/serializers/output/authentication.js | 6 ++++++ core/server/web/api/v2/admin/routes.js | 2 +- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/core/server/api/v2/authentication.js b/core/server/api/v2/authentication.js index acfd947f2406..a48a2fa21e3a 100644 --- a/core/server/api/v2/authentication.js +++ b/core/server/api/v2/authentication.js @@ -1,4 +1,5 @@ const api = require('./index'); +const config = require('../../config'); const web = require('../../web'); const models = require('../../models'); const auth = require('../../services/auth'); @@ -34,6 +35,22 @@ module.exports = { } }, + isSetup: { + permissions: false, + query() { + return auth.setup.checkIsSetup() + .then((isSetup) => { + return { + status: isSetup, + // Pre-populate from config if, and only if the values exist in config. + title: config.title || undefined, + name: config.user_name || undefined, + email: config.user_email || undefined + }; + }); + } + }, + generateResetToken: { permissions: true, options: [ diff --git a/core/server/api/v2/utils/serializers/output/authentication.js b/core/server/api/v2/utils/serializers/output/authentication.js index a300a62ad0af..9aa02df2894b 100644 --- a/core/server/api/v2/utils/serializers/output/authentication.js +++ b/core/server/api/v2/utils/serializers/output/authentication.js @@ -11,6 +11,12 @@ module.exports = { }; }, + isSetup(data, apiConfig, frame) { + frame.response = { + setup: [data] + }; + }, + acceptInvitation(data, apiConfig, frame) { debug('acceptInvitation'); diff --git a/core/server/web/api/v2/admin/routes.js b/core/server/web/api/v2/admin/routes.js index b7296f508948..85851f2458e7 100644 --- a/core/server/web/api/v2/admin/routes.js +++ b/core/server/web/api/v2/admin/routes.js @@ -191,7 +191,7 @@ module.exports = function apiRoutes() { router.get('/authentication/invitation', api.http(apiv2.authentication.isInvitation)); router.post('/authentication/setup', api.http(apiv2.authentication.setup)); router.put('/authentication/setup', mw.authAdminApi, api.http(api.authentication.updateSetup)); - router.get('/authentication/setup', api.http(api.authentication.isSetup)); + router.get('/authentication/setup', api.http(apiv2.authentication.isSetup)); // ## Images router.post('/images/upload', From 67c2fb828502371d765ffb87ff42d6f54af313d4 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Thu, 25 Jul 2019 16:43:46 +0200 Subject: [PATCH 10/22] Added missing test coverage for "setupUpdate" method --- .../api/v0.1/authentication_spec.js | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/core/test/regression/api/v0.1/authentication_spec.js b/core/test/regression/api/v0.1/authentication_spec.js index 8890d55f3172..f32cc51d3b63 100644 --- a/core/test/regression/api/v0.1/authentication_spec.js +++ b/core/test/regression/api/v0.1/authentication_spec.js @@ -366,6 +366,39 @@ describe('Authentication API', function () { .expect('Content-Type', /json/) .expect(403); }); + + it('update setup', function () { + return localUtils.doAuth(request) + .then((ownerAccessToken) => { + return request + .put(localUtils.API.getApiQuery('authentication/setup')) + .set('Authorization', 'Bearer ' + ownerAccessToken) + .set('Origin', config.get('url')) + .send({ + setup: [{ + name: 'test user edit', + email: 'test-edited@example.com', + password: 'thisissupersafe', + blogTitle: 'a test blog' + }] + }) + .expect('Content-Type', /json/) + .expect(200); + }) + .then((res) => { + const jsonResponse = res.body; + should.exist(jsonResponse.users); + should.not.exist(jsonResponse.meta); + + jsonResponse.users.should.have.length(1); + localUtils.API.checkResponse(jsonResponse.users[0], 'user'); + + const newUser = jsonResponse.users[0]; + newUser.id.should.equal(testUtils.DataGenerator.Content.users[0].id); + newUser.name.should.equal('test user edit'); + newUser.email.should.equal('test-edited@example.com'); + }); + }); }); describe('Invitation', function () { From 03934e30c97afd213c63d889f28e0eca49b745cc Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Thu, 25 Jul 2019 16:56:58 +0200 Subject: [PATCH 11/22] Removed old auth suite --- .../api/v2/admin/authentication_spec.js | 298 ------------------ 1 file changed, 298 deletions(-) diff --git a/core/test/regression/api/v2/admin/authentication_spec.js b/core/test/regression/api/v2/admin/authentication_spec.js index 36696fc767d8..ecdca6a4dc7f 100644 --- a/core/test/regression/api/v2/admin/authentication_spec.js +++ b/core/test/regression/api/v2/admin/authentication_spec.js @@ -2,13 +2,7 @@ const should = require('should'); const supertest = require('supertest'); const testUtils = require('../../../../utils/index'); const localUtils = require('./utils'); -const moment = require('moment'); -const user = testUtils.DataGenerator.forModel.users[0]; -const models = require('../../../../../server/models/index'); -const constants = require('../../../../../server/lib/constants'); const config = require('../../../../../server/config/index'); -const security = require('../../../../../server/lib/security/index'); -const settingsCache = require('../../../../../server/services/settings/cache'); let ghost = testUtils.startGhost; let request; @@ -16,298 +10,6 @@ let request; describe.only('Authentication API v2', function () { var accesstoken = '', ghostServer; - describe('auth & authorize', function () { - before(function () { - return ghost() - .then(function (_ghostServer) { - ghostServer = _ghostServer; - request = supertest.agent(config.get('url')); - }) - .then(function () { - return localUtils.doAuth(request); - }) - .then(function (token) { - accesstoken = token; - }); - }); - - afterEach(function () { - return testUtils.clearBruteData(); - }); - - it('can authenticate', function (done) { - request.post(localUtils.API.getApiQuery('authentication/token')) - .set('Origin', config.get('url')) - .send({ - grant_type: 'password', - username: user.email, - password: user.password, - client_id: 'ghost-admin', - client_secret: 'not_available' - }) - .expect('Content-Type', /json/) - // TODO: make it possible to override oauth2orize's header so that this is consistent - .expect('Cache-Control', 'no-store') - .expect(200) - .end(function (err, res) { - if (err) { - return done(err); - } - should.not.exist(res.headers['x-cache-invalidate']); - var jsonResponse = res.body, - newAccessToken; - - should.exist(jsonResponse.access_token); - should.exist(jsonResponse.refresh_token); - should.exist(jsonResponse.expires_in); - should.exist(jsonResponse.token_type); - - models.Accesstoken.findOne({ - token: jsonResponse.access_token - }).then(function (_newAccessToken) { - newAccessToken = _newAccessToken; - - return models.Refreshtoken.findOne({ - token: jsonResponse.refresh_token - }); - }).then(function (newRefreshToken) { - newAccessToken.get('issued_by').should.eql(newRefreshToken.id); - done(); - }).catch(done); - }); - }); - - it('can\'t authenticate unknown user', function (done) { - request.post(localUtils.API.getApiQuery('authentication/token')) - .set('Origin', config.get('url')) - .set('Accept', 'application/json') - .send({ - grant_type: 'password', - username: 'invalid@email.com', - password: user.password, - client_id: 'ghost-admin', - client_secret: 'not_available' - }).expect('Content-Type', /json/) - .expect('Cache-Control', testUtils.cacheRules.private) - .expect(404) - .end(function (err, res) { - if (err) { - return done(err); - } - var jsonResponse = res.body; - should.exist(jsonResponse.errors[0].errorType); - jsonResponse.errors[0].errorType.should.eql('NotFoundError'); - done(); - }); - }); - - it('can\'t authenticate invalid password user', function (done) { - request.post(localUtils.API.getApiQuery('authentication/token')) - .set('Origin', config.get('url')) - .set('Accept', 'application/json') - .send({ - grant_type: 'password', - username: user.email, - password: 'invalid', - client_id: 'ghost-admin', - client_secret: 'not_available' - }).expect('Content-Type', /json/) - .expect('Cache-Control', testUtils.cacheRules.private) - .expect(422) - .end(function (err, res) { - if (err) { - return done(err); - } - var jsonResponse = res.body; - should.exist(jsonResponse.errors[0].errorType); - jsonResponse.errors[0].errorType.should.eql('ValidationError'); - done(); - }); - }); - - it('can request new access token', function (done) { - request.post(localUtils.API.getApiQuery('authentication/token')) - .set('Origin', config.get('url')) - .send({ - grant_type: 'password', - username: user.email, - password: user.password, - client_id: 'ghost-admin', - client_secret: 'not_available' - }) - .expect('Content-Type', /json/) - // TODO: make it possible to override oauth2orize's header so that this is consistent - .expect('Cache-Control', 'no-store') - .expect(200) - .end(function (err, res) { - if (err) { - return done(err); - } - - var refreshToken = res.body.refresh_token; - - models.Accesstoken.findOne({ - token: accesstoken - }).then(function (oldAccessToken) { - moment(oldAccessToken.get('expires')).diff(moment(), 'minutes').should.be.above(6); - - request.post(localUtils.API.getApiQuery('authentication/token')) - .set('Origin', config.get('url')) - .set('Authorization', 'Bearer ' + accesstoken) - .send({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: 'ghost-admin', - client_secret: 'not_available' - }) - .expect('Content-Type', /json/) - // TODO: make it possible to override oauth2orize's header so that this is consistent - .expect('Cache-Control', 'no-store') - .expect(200) - .end(function (err, res) { - if (err) { - return done(err); - } - - var jsonResponse = res.body; - should.exist(jsonResponse.access_token); - should.exist(jsonResponse.expires_in); - - models.Accesstoken.findOne({ - token: accesstoken - }).then(function (oldAccessToken) { - moment(oldAccessToken.get('expires')).diff(moment(), 'minutes').should.be.below(6); - return models.Refreshtoken.findOne({ - token: refreshToken - }); - }).then(function (refreshTokenModel) { - // NOTE: the static 6 month ms number in our constants are based on 30 days - // We have to compare against the static number. We can't compare against the month in - // the next 6 month dynamically, because each month has a different number of days, - // which results in a different ms number. - moment(Date.now() + constants.SIX_MONTH_MS) - .startOf('day') - .diff(moment(refreshTokenModel.get('expires')).startOf('day'), 'month').should.eql(0); - - done(); - }); - }); - }); - }); - }); - - it('can\'t request new access token with invalid refresh token', function (done) { - request.post(localUtils.API.getApiQuery('authentication/token')) - .set('Origin', config.get('url')) - .set('Accept', 'application/json') - .send({ - grant_type: 'refresh_token', - refresh_token: 'invalid', - client_id: 'ghost-admin', - client_secret: 'not_available' - }).expect('Content-Type', /json/) - .expect('Cache-Control', testUtils.cacheRules.private) - .expect(403) - .end(function (err, res) { - if (err) { - return done(err); - } - var jsonResponse = res.body; - should.exist(jsonResponse.errors[0].errorType); - jsonResponse.errors[0].errorType.should.eql('NoPermissionError'); - done(); - }); - }); - - it('reset password', function (done) { - models.User.getOwnerUser(testUtils.context.internal) - .then(function (ownerUser) { - var token = security.tokens.resetToken.generateHash({ - expires: Date.now() + (1000 * 60), - email: user.email, - dbHash: settingsCache.get('db_hash'), - password: ownerUser.get('password') - }); - - request.put(localUtils.API.getApiQuery('authentication/passwordreset')) - .set('Origin', config.get('url')) - .set('Accept', 'application/json') - .send({ - passwordreset: [{ - token: token, - newPassword: 'thisissupersafe', - ne2Password: 'thisissupersafe' - }] - }) - .expect('Content-Type', /json/) - .expect('Cache-Control', testUtils.cacheRules.private) - .expect(200) - .end(function (err) { - if (err) { - return done(err); - } - - done(); - }); - }) - .catch(done); - }); - - it('reset password: invalid passwords', function () { - return request.put(localUtils.API.getApiQuery('authentication/passwordreset')) - .set('Origin', config.get('url')) - .set('Accept', 'application/json') - .send({ - passwordreset: [{ - token: 'doesntmatter', - newPassword: 'thisissupersafe', - ne2Password: 'thisissupersafebutdoesntmatch' - }] - }) - .expect('Content-Type', /json/) - .expect('Cache-Control', testUtils.cacheRules.private) - .expect(422); - }); - - it('reset password: invalid token', function () { - return request - .put(localUtils.API.getApiQuery('authentication/passwordreset')) - .set('Origin', config.get('url')) - .set('Accept', 'application/json') - .send({ - passwordreset: [{ - token: 'invalid', - newPassword: 'thisissupersafe', - ne2Password: 'thisissupersafe' - }] - }) - .expect('Content-Type', /json/) - .expect('Cache-Control', testUtils.cacheRules.private) - .expect(401); - }); - - it('revoke token', function () { - return request - .post(localUtils.API.getApiQuery('authentication/revoke')) - .set('Authorization', 'Bearer ' + accesstoken) - .set('Origin', config.get('url')) - .set('Accept', 'application/json') - .send({ - token: accesstoken, - token_type_hint: 'access_token' - }) - .expect('Content-Type', /json/) - .expect('Cache-Control', testUtils.cacheRules.private) - .expect(200) - .then(() => { - return request - .get(localUtils.API.getApiQuery('posts/')) - .set('Authorization', 'Bearer ' + accesstoken) - .expect(401); - }); - }); - }); - describe('Blog setup', function () { before(function () { return ghost({forceStart: true}) From 8b651bff9dc0470d8d0db0fbe3d723ee08cba6c7 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Thu, 25 Jul 2019 17:10:46 +0200 Subject: [PATCH 12/22] Migrated authentication.updateSetup method to v2 --- core/server/api/v2/authentication.js | 33 ++++++++++++++++++ .../serializers/output/authentication.js | 8 +++++ core/server/web/api/v2/admin/routes.js | 2 +- .../api/v2/admin/authentication_spec.js | 34 ++++++++++++++++++- 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/core/server/api/v2/authentication.js b/core/server/api/v2/authentication.js index a48a2fa21e3a..13e601e21372 100644 --- a/core/server/api/v2/authentication.js +++ b/core/server/api/v2/authentication.js @@ -1,5 +1,6 @@ const api = require('./index'); const config = require('../../config'); +const common = require('../../lib/common'); const web = require('../../web'); const models = require('../../models'); const auth = require('../../services/auth'); @@ -35,6 +36,38 @@ module.exports = { } }, + updateSetup: { + permissions: (frame) => { + return models.User.findOne({role: 'Owner', status: 'all'}) + .then((owner) => { + if (owner.id !== frame.options.context.user) { + throw new common.errors.NoPermissionError({message: common.i18n.t('errors.api.authentication.notTheBlogOwner')}); + } + }); + }, + validation: { + docName: 'setup' + }, + query(frame) { + return Promise.resolve() + .then(() => { + return auth.setup.assertSetupCompleted(true)(); + }) + .then(() => { + const setupDetails = { + name: frame.data.setup[0].name, + email: frame.data.setup[0].email, + password: frame.data.setup[0].password, + blogTitle: frame.data.setup[0].blogTitle, + status: 'active' + }; + + return auth.setup.setupUser(setupDetails) + .then(({user}) => user); + }); + } + }, + isSetup: { permissions: false, query() { diff --git a/core/server/api/v2/utils/serializers/output/authentication.js b/core/server/api/v2/utils/serializers/output/authentication.js index 9aa02df2894b..ac4de515ae4e 100644 --- a/core/server/api/v2/utils/serializers/output/authentication.js +++ b/core/server/api/v2/utils/serializers/output/authentication.js @@ -11,6 +11,14 @@ module.exports = { }; }, + updateSetup(user, apiConfig, frame) { + frame.response = { + users: [ + mapper.mapUser(user, {options: {context: {internal: true}}}) + ] + }; + }, + isSetup(data, apiConfig, frame) { frame.response = { setup: [data] diff --git a/core/server/web/api/v2/admin/routes.js b/core/server/web/api/v2/admin/routes.js index 85851f2458e7..ba5ac88cacc6 100644 --- a/core/server/web/api/v2/admin/routes.js +++ b/core/server/web/api/v2/admin/routes.js @@ -190,7 +190,7 @@ module.exports = function apiRoutes() { router.post('/authentication/invitation', api.http(apiv2.authentication.acceptInvitation)); router.get('/authentication/invitation', api.http(apiv2.authentication.isInvitation)); router.post('/authentication/setup', api.http(apiv2.authentication.setup)); - router.put('/authentication/setup', mw.authAdminApi, api.http(api.authentication.updateSetup)); + router.put('/authentication/setup', mw.authAdminApi, api.http(apiv2.authentication.updateSetup)); router.get('/authentication/setup', api.http(apiv2.authentication.isSetup)); // ## Images diff --git a/core/test/regression/api/v2/admin/authentication_spec.js b/core/test/regression/api/v2/admin/authentication_spec.js index ecdca6a4dc7f..1c3afcc4d59b 100644 --- a/core/test/regression/api/v2/admin/authentication_spec.js +++ b/core/test/regression/api/v2/admin/authentication_spec.js @@ -8,7 +8,7 @@ let ghost = testUtils.startGhost; let request; describe.only('Authentication API v2', function () { - var accesstoken = '', ghostServer; + let ghostServer; describe('Blog setup', function () { before(function () { @@ -85,6 +85,38 @@ describe.only('Authentication API v2', function () { .expect('Content-Type', /json/) .expect(403); }); + + it('update setup', function () { + return localUtils.doAuth(request) + .then(() => { + return request + .put(localUtils.API.getApiQuery('authentication/setup')) + .set('Origin', config.get('url')) + .send({ + setup: [{ + name: 'test user edit', + email: 'test-edit@example.com', + password: 'thisissupersafe', + blogTitle: 'a test blog' + }] + }) + .expect('Content-Type', /json/) + .expect(200); + }) + .then((res) => { + const jsonResponse = res.body; + should.exist(jsonResponse.users); + should.not.exist(jsonResponse.meta); + + jsonResponse.users.should.have.length(1); + localUtils.API.checkResponse(jsonResponse.users[0], 'user'); + + const newUser = jsonResponse.users[0]; + newUser.id.should.equal(testUtils.DataGenerator.Content.users[0].id); + newUser.name.should.equal('test user edit'); + newUser.email.should.equal('test-edit@example.com'); + }); + }); }); describe('Invitation', function () { From 7b97c1ada1ab74a4a790334c0a70ced049178d3c Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Tue, 30 Jul 2019 14:44:56 +0200 Subject: [PATCH 13/22] Added missing notification email when setting up a site --- core/server/api/v2/authentication.js | 5 +++++ .../regression/api/v2/admin/authentication_spec.js | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/core/server/api/v2/authentication.js b/core/server/api/v2/authentication.js index 13e601e21372..31109dab0f1e 100644 --- a/core/server/api/v2/authentication.js +++ b/core/server/api/v2/authentication.js @@ -32,6 +32,11 @@ module.exports = { }) .then((data) => { return auth.setup.doSettings(data, api.settings); + }) + .then((user) => { + const notificationUser = user.toJSON({context: {internal: true}}); + return auth.setup.sendNotification(notificationUser, api.mail) + .then(() => user); }); } }, diff --git a/core/test/regression/api/v2/admin/authentication_spec.js b/core/test/regression/api/v2/admin/authentication_spec.js index 1c3afcc4d59b..95fb5dd678d6 100644 --- a/core/test/regression/api/v2/admin/authentication_spec.js +++ b/core/test/regression/api/v2/admin/authentication_spec.js @@ -1,8 +1,10 @@ const should = require('should'); +const sinon = require('sinon'); const supertest = require('supertest'); const testUtils = require('../../../../utils/index'); const localUtils = require('./utils'); const config = require('../../../../../server/config/index'); +const mailService = require('../../../../../server/services/mail/index'); let ghost = testUtils.startGhost; let request; @@ -10,6 +12,14 @@ let request; describe.only('Authentication API v2', function () { let ghostServer; + beforeEach(function () { + sinon.stub(mailService.GhostMailer.prototype, 'send').resolves('Mail is disabled'); + }); + + afterEach(function () { + sinon.restore(); + }); + describe('Blog setup', function () { before(function () { return ghost({forceStart: true}) @@ -56,6 +66,9 @@ describe.only('Authentication API v2', function () { newUser.id.should.equal(testUtils.DataGenerator.Content.users[0].id); newUser.name.should.equal('test user'); newUser.email.should.equal('test@example.com'); + + mailService.GhostMailer.prototype.send.called.should.be.true(); + mailService.GhostMailer.prototype.send.args[0][0].to.should.equal('test@example.com'); }); }); From dae69072f62a60ed59e873adf96d4580f8d6dc09 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Tue, 30 Jul 2019 15:45:07 +0200 Subject: [PATCH 14/22] Removed unused 'setup.completed' event - Without the event it's possible to simplify sendNotification method to just use email address of the user --- core/server/services/auth/setup.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/core/server/services/auth/setup.js b/core/server/services/auth/setup.js index f8cbf953a968..6f244578a240 100644 --- a/core/server/services/auth/setup.js +++ b/core/server/services/auth/setup.js @@ -87,8 +87,6 @@ function sendNotification(setupUser, mailAPI) { ownerEmail: setupUser.email }; - common.events.emit('setup.completed', setupUser); - if (config.get('sendWelcomeEmail')) { return mail.utils.generateContent({data: data, template: 'welcome'}) .then((content) => { From 27523e2ed7e4d361e376a2374b6d16fcf7449734 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Tue, 30 Jul 2019 16:09:54 +0200 Subject: [PATCH 15/22] Moved mailService stub closer to it's usecase --- .../api/v2/admin/authentication_spec.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/core/test/regression/api/v2/admin/authentication_spec.js b/core/test/regression/api/v2/admin/authentication_spec.js index 95fb5dd678d6..938e2daad1da 100644 --- a/core/test/regression/api/v2/admin/authentication_spec.js +++ b/core/test/regression/api/v2/admin/authentication_spec.js @@ -12,14 +12,6 @@ let request; describe.only('Authentication API v2', function () { let ghostServer; - beforeEach(function () { - sinon.stub(mailService.GhostMailer.prototype, 'send').resolves('Mail is disabled'); - }); - - afterEach(function () { - sinon.restore(); - }); - describe('Blog setup', function () { before(function () { return ghost({forceStart: true}) @@ -29,6 +21,14 @@ describe.only('Authentication API v2', function () { }); }); + beforeEach(function () { + sinon.stub(mailService.GhostMailer.prototype, 'send').resolves('Mail is disabled'); + }); + + afterEach(function () { + sinon.restore(); + }); + it('is setup? no', function () { return request .get(localUtils.API.getApiQuery('authentication/setup')) From 36026ab929ae6de27e236a16ac17490ae1f89c6d Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Tue, 30 Jul 2019 16:10:55 +0200 Subject: [PATCH 16/22] Added email sending check to v0.1 test suite --- .../test/regression/api/v0.1/authentication_spec.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/core/test/regression/api/v0.1/authentication_spec.js b/core/test/regression/api/v0.1/authentication_spec.js index f32cc51d3b63..5ffed710c7b1 100644 --- a/core/test/regression/api/v0.1/authentication_spec.js +++ b/core/test/regression/api/v0.1/authentication_spec.js @@ -1,4 +1,5 @@ var should = require('should'), + sinon = require('sinon'), supertest = require('supertest'), testUtils = require('../../../utils/index'), localUtils = require('./utils'), @@ -9,6 +10,7 @@ var should = require('should'), config = require('../../../../server/config/index'), security = require('../../../../server/lib/security/index'), settingsCache = require('../../../../server/services/settings/cache'), + mailService = require('../../../../server/services/mail/index'), ghost = testUtils.startGhost, request; @@ -300,6 +302,14 @@ describe('Authentication API', function () { }); }); + beforeEach(function () { + sinon.stub(mailService.GhostMailer.prototype, 'send').resolves('Mail is disabled'); + }); + + afterEach(function () { + sinon.restore(); + }); + it('is setup? no', function () { return request .get(localUtils.API.getApiQuery('authentication/setup')) @@ -337,6 +347,9 @@ describe('Authentication API', function () { newUser.id.should.equal(testUtils.DataGenerator.Content.users[0].id); newUser.name.should.equal('test user'); newUser.email.should.equal('test@example.com'); + + mailService.GhostMailer.prototype.send.called.should.be.true(); + mailService.GhostMailer.prototype.send.args[0][0].to.should.equal('test@example.com'); }); }); From 8503bdceb888f1c86a748adc4a4e8959e26be380 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Tue, 30 Jul 2019 16:13:53 +0200 Subject: [PATCH 17/22] Refactored sendNotification method to just use email address as parameter --- core/server/services/auth/setup.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/core/server/services/auth/setup.js b/core/server/services/auth/setup.js index 6f244578a240..2f05bad6ec68 100644 --- a/core/server/services/auth/setup.js +++ b/core/server/services/auth/setup.js @@ -82,16 +82,16 @@ async function doSettings(data, settingsAPI) { return user; } -function sendNotification(setupUser, mailAPI) { - const data = { - ownerEmail: setupUser.email - }; - +function sendNotification(email, mailAPI) { if (config.get('sendWelcomeEmail')) { + const data = { + ownerEmail: email + }; + return mail.utils.generateContent({data: data, template: 'welcome'}) .then((content) => { const message = { - to: setupUser.email, + to: email, subject: common.i18n.t('common.api.authentication.mail.yourNewGhostBlog'), html: content.html, text: content.text @@ -108,11 +108,8 @@ function sendNotification(setupUser, mailAPI) { err.context = common.i18n.t('errors.api.authentication.unableToSendWelcomeEmail'); common.logging.error(err); }); - }) - .return(setupUser); + }); } - - return setupUser; } module.exports = { From c7a836b92606b367de8346be1825986defbde218 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Tue, 30 Jul 2019 16:15:53 +0200 Subject: [PATCH 18/22] Renamed sendNotification to sendWelcomeMail - The only thing the method does now is sending welcome mail, so new naming seems natural :) --- core/server/api/v0.1/authentication.js | 3 ++- core/server/api/v2/authentication.js | 3 +-- core/server/services/auth/setup.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/server/api/v0.1/authentication.js b/core/server/api/v0.1/authentication.js index d5863c05992b..965f901a8a53 100644 --- a/core/server/api/v0.1/authentication.js +++ b/core/server/api/v0.1/authentication.js @@ -290,7 +290,8 @@ authentication = { } function sendNotification(setupUser) { - return auth.setup.sendNotification(setupUser, mailAPI); + return auth.setup.sendWelcomeEmail(setupUser.email, mailAPI) + .then(() => setupUser); } function formatResponse(setupUser) { diff --git a/core/server/api/v2/authentication.js b/core/server/api/v2/authentication.js index 31109dab0f1e..4ea114cfe428 100644 --- a/core/server/api/v2/authentication.js +++ b/core/server/api/v2/authentication.js @@ -34,8 +34,7 @@ module.exports = { return auth.setup.doSettings(data, api.settings); }) .then((user) => { - const notificationUser = user.toJSON({context: {internal: true}}); - return auth.setup.sendNotification(notificationUser, api.mail) + return auth.setup.sendWelcomeEmail(user.get('email'), api.mail) .then(() => user); }); } diff --git a/core/server/services/auth/setup.js b/core/server/services/auth/setup.js index 2f05bad6ec68..7d3560d03117 100644 --- a/core/server/services/auth/setup.js +++ b/core/server/services/auth/setup.js @@ -82,7 +82,7 @@ async function doSettings(data, settingsAPI) { return user; } -function sendNotification(email, mailAPI) { +function sendWelcomeEmail(email, mailAPI) { if (config.get('sendWelcomeEmail')) { const data = { ownerEmail: email @@ -117,5 +117,5 @@ module.exports = { assertSetupCompleted: assertSetupCompleted, setupUser: setupUser, doSettings: doSettings, - sendNotification: sendNotification + sendWelcomeEmail: sendWelcomeEmail }; From 589b78d5753cf40d85fd4ce7b5704f611a999ac7 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Tue, 30 Jul 2019 16:37:32 +0200 Subject: [PATCH 19/22] Added missing validator to updateSetup method - This is the code corresponding to processArgs function in v1 authentication.updateSetup method --- core/server/api/v2/utils/validators/input/index.js | 4 ++++ core/server/api/v2/utils/validators/input/setup.js | 12 ++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 core/server/api/v2/utils/validators/input/setup.js diff --git a/core/server/api/v2/utils/validators/input/index.js b/core/server/api/v2/utils/validators/input/index.js index 014b3d988e1b..ade462c797bd 100644 --- a/core/server/api/v2/utils/validators/input/index.js +++ b/core/server/api/v2/utils/validators/input/index.js @@ -3,6 +3,10 @@ module.exports = { return require('./passwordreset'); }, + get setup() { + return require('./setup'); + }, + get posts() { return require('./posts'); }, diff --git a/core/server/api/v2/utils/validators/input/setup.js b/core/server/api/v2/utils/validators/input/setup.js new file mode 100644 index 000000000000..3dc8dd32b996 --- /dev/null +++ b/core/server/api/v2/utils/validators/input/setup.js @@ -0,0 +1,12 @@ +const debug = require('ghost-ignition').debug('api:v2:utils:validators:input:updateSetup'); +const common = require('../../../../../lib/common'); + +module.exports = { + updateSetup(apiConfig, frame) { + debug('resetPassword'); + + if (!frame.options.context || !frame.options.context.user) { + throw new common.errors.NoPermissionError({message: common.i18n.t('errors.api.authentication.notTheBlogOwner')}); + } + } +}; From 3945e8a5eeb6655f1cde37eaf66356dd35cfeb74 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Tue, 30 Jul 2019 16:52:37 +0200 Subject: [PATCH 20/22] Added missing doSettings call in updateSetup --- core/server/api/v2/authentication.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/server/api/v2/authentication.js b/core/server/api/v2/authentication.js index 4ea114cfe428..ac428341200c 100644 --- a/core/server/api/v2/authentication.js +++ b/core/server/api/v2/authentication.js @@ -66,8 +66,10 @@ module.exports = { status: 'active' }; - return auth.setup.setupUser(setupDetails) - .then(({user}) => user); + return auth.setup.setupUser(setupDetails); + }) + .then((data) => { + return auth.setup.doSettings(data, api.settings); }); } }, From 956da204f2b199f04ab9764a3408a0a9878f15c4 Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Tue, 30 Jul 2019 22:48:59 +0200 Subject: [PATCH 21/22] Expanded authentication test suite with cases for password reset flow - Added missing endpoint coverage - Minor fixes with formatting and validations uncovered by the test - Added same test to v0.1 coverage --- core/server/api/v2/authentication.js | 6 +- .../serializers/output/authentication.js | 16 +++ .../utils/validators/input/passwordreset.js | 13 +++ .../api/v0.1/authentication_spec.js | 26 +++++ .../api/v2/admin/authentication_spec.js | 104 +++++++++++++++++- 5 files changed, 161 insertions(+), 4 deletions(-) diff --git a/core/server/api/v2/authentication.js b/core/server/api/v2/authentication.js index ac428341200c..242b70cff34c 100644 --- a/core/server/api/v2/authentication.js +++ b/core/server/api/v2/authentication.js @@ -91,6 +91,9 @@ module.exports = { }, generateResetToken: { + validation: { + docName: 'passwordreset' + }, permissions: true, options: [ 'email' @@ -101,7 +104,7 @@ module.exports = { return auth.setup.assertSetupCompleted(true)(); }) .then(() => { - return auth.passwordreset.generateToken(frame.data.email, api.settings); + return auth.passwordreset.generateToken(frame.data.passwordreset[0].email, api.settings); }) .then((token) => { return auth.passwordreset.sendResetNotification(token, api.mail); @@ -113,7 +116,6 @@ module.exports = { validation: { docName: 'passwordreset', data: { - token: {required: true}, newPassword: {required: true}, ne2Password: {required: true} } diff --git a/core/server/api/v2/utils/serializers/output/authentication.js b/core/server/api/v2/utils/serializers/output/authentication.js index ac4de515ae4e..6c713b9fafab 100644 --- a/core/server/api/v2/utils/serializers/output/authentication.js +++ b/core/server/api/v2/utils/serializers/output/authentication.js @@ -25,6 +25,22 @@ module.exports = { }; }, + generateResetToken(data, apiConfig, frame) { + frame.response = { + passwordreset: [{ + message: common.i18n.t('common.api.authentication.mail.checkEmailForInstructions') + }] + }; + }, + + resetPassword(data, apiConfig, frame) { + frame.response = { + passwordreset: [{ + message: common.i18n.t('common.api.authentication.mail.passwordChanged') + }] + }; + }, + acceptInvitation(data, apiConfig, frame) { debug('acceptInvitation'); diff --git a/core/server/api/v2/utils/validators/input/passwordreset.js b/core/server/api/v2/utils/validators/input/passwordreset.js index 52114714cf96..175baafe1a53 100644 --- a/core/server/api/v2/utils/validators/input/passwordreset.js +++ b/core/server/api/v2/utils/validators/input/passwordreset.js @@ -1,4 +1,5 @@ const Promise = require('bluebird'); +const validator = require('validator'); const debug = require('ghost-ignition').debug('api:v2:utils:validators:input:passwordreset'); const common = require('../../../../../lib/common'); @@ -13,5 +14,17 @@ module.exports = { message: common.i18n.t('errors.models.user.newPasswordsDoNotMatch') })); } + }, + + generateResetToken(apiConfig, frame) { + debug('generateResetToken'); + + const email = frame.data.passwordreset[0].email; + + if (typeof email !== 'string' || !validator.isEmail(email)) { + throw new common.errors.BadRequestError({ + message: common.i18n.t('errors.api.authentication.invalidEmailReceived') + }); + } } }; diff --git a/core/test/regression/api/v0.1/authentication_spec.js b/core/test/regression/api/v0.1/authentication_spec.js index 5ffed710c7b1..503cb047e9a4 100644 --- a/core/test/regression/api/v0.1/authentication_spec.js +++ b/core/test/regression/api/v0.1/authentication_spec.js @@ -32,7 +32,12 @@ describe('Authentication API', function () { }); }); + beforeEach(function () { + sinon.stub(mailService.GhostMailer.prototype, 'send').resolves('Mail is disabled'); + }); + afterEach(function () { + sinon.restore(); return testUtils.clearBruteData(); }); @@ -271,6 +276,27 @@ describe('Authentication API', function () { .expect(401); }); + it('reset password: send reset password', function () { + return request + .post(localUtils.API.getApiQuery('authentication/passwordreset/')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + email: user.email + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + var jsonResponse = res.body; + should.exist(jsonResponse.passwordreset[0].message); + jsonResponse.passwordreset[0].message.should.equal('Check your email for further instructions.'); + mailService.GhostMailer.prototype.send.args[0][0].to.should.equal(user.email); + }); + }); + it('revoke token', function () { return request .post(localUtils.API.getApiQuery('authentication/revoke')) diff --git a/core/test/regression/api/v2/admin/authentication_spec.js b/core/test/regression/api/v2/admin/authentication_spec.js index 938e2daad1da..68b200054a37 100644 --- a/core/test/regression/api/v2/admin/authentication_spec.js +++ b/core/test/regression/api/v2/admin/authentication_spec.js @@ -1,15 +1,18 @@ const should = require('should'); const sinon = require('sinon'); const supertest = require('supertest'); -const testUtils = require('../../../../utils/index'); const localUtils = require('./utils'); +const testUtils = require('../../../../utils/index'); +const models = require('../../../../../server/models/index'); +const security = require('../../../../../server/lib/security/index'); +const settingsCache = require('../../../../../server/services/settings/cache'); const config = require('../../../../../server/config/index'); const mailService = require('../../../../../server/services/mail/index'); let ghost = testUtils.startGhost; let request; -describe.only('Authentication API v2', function () { +describe('Authentication API v2', function () { let ghostServer; describe('Blog setup', function () { @@ -209,4 +212,101 @@ describe.only('Authentication API v2', function () { }); }); }); + + describe('Password reset', function () { + const user = testUtils.DataGenerator.forModel.users[0]; + + before(function () { + return ghost({forceStart: true}) + .then(() => { + request = supertest.agent(config.get('url')); + }) + .then(() => { + return localUtils.doAuth(request); + }); + }); + + beforeEach(function () { + sinon.stub(mailService.GhostMailer.prototype, 'send').resolves('Mail is disabled'); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('reset password', function (done) { + models.User.getOwnerUser(testUtils.context.internal) + .then(function (ownerUser) { + var token = security.tokens.resetToken.generateHash({ + expires: Date.now() + (1000 * 60), + email: user.email, + dbHash: settingsCache.get('db_hash'), + password: ownerUser.get('password') + }); + + request.put(localUtils.API.getApiQuery('authentication/passwordreset')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + token: token, + newPassword: 'thisissupersafe', + ne2Password: 'thisissupersafe' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + const jsonResponse = res.body; + should.exist(jsonResponse.passwordreset[0].message); + jsonResponse.passwordreset[0].message.should.equal('Password changed successfully.'); + done(); + }); + }) + .catch(done); + }); + + it('reset password: invalid token', function () { + return request + .put(localUtils.API.getApiQuery('authentication/passwordreset')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + token: 'invalid', + newPassword: 'thisissupersafe', + ne2Password: 'thisissupersafe' + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(401); + }); + + it('reset password: generate reset token', function () { + return request + .post(localUtils.API.getApiQuery('authentication/passwordreset')) + .set('Origin', config.get('url')) + .set('Accept', 'application/json') + .send({ + passwordreset: [{ + email: user.email + }] + }) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(200) + .then((res) => { + const jsonResponse = res.body; + should.exist(jsonResponse.passwordreset[0].message); + jsonResponse.passwordreset[0].message.should.equal('Check your email for further instructions.'); + mailService.GhostMailer.prototype.send.args[0][0].to.should.equal(user.email); + }); + }); + }); }); From db9eed6288f34483a02cf289d930eded7d944b7d Mon Sep 17 00:00:00 2001 From: Nazar Gargol Date: Thu, 1 Aug 2019 13:06:15 +0200 Subject: [PATCH 22/22] Switched to use v2 http module instead of ovelooked v1 - Small adjustments in controller that came along with the switch --- core/server/api/v2/authentication.js | 4 ++++ core/server/web/api/v2/admin/routes.js | 14 +++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/core/server/api/v2/authentication.js b/core/server/api/v2/authentication.js index 242b70cff34c..ebd5918bc478 100644 --- a/core/server/api/v2/authentication.js +++ b/core/server/api/v2/authentication.js @@ -10,6 +10,7 @@ module.exports = { docName: 'authentication', setup: { + statusCode: 201, permissions: false, validation: { docName: 'setup' @@ -163,6 +164,9 @@ module.exports = { }, isInvitation: { + data: [ + 'email' + ], validation: { docName: 'invitations' }, diff --git a/core/server/web/api/v2/admin/routes.js b/core/server/web/api/v2/admin/routes.js index ba5ac88cacc6..1be698c6faff 100644 --- a/core/server/web/api/v2/admin/routes.js +++ b/core/server/web/api/v2/admin/routes.js @@ -184,14 +184,14 @@ module.exports = function apiRoutes() { router.post('/authentication/passwordreset', shared.middlewares.brute.globalReset, shared.middlewares.brute.userReset, - api.http(apiv2.authentication.generateResetToken) + http(apiv2.authentication.generateResetToken) ); - router.put('/authentication/passwordreset', shared.middlewares.brute.globalBlock, api.http(apiv2.authentication.resetPassword)); - router.post('/authentication/invitation', api.http(apiv2.authentication.acceptInvitation)); - router.get('/authentication/invitation', api.http(apiv2.authentication.isInvitation)); - router.post('/authentication/setup', api.http(apiv2.authentication.setup)); - router.put('/authentication/setup', mw.authAdminApi, api.http(apiv2.authentication.updateSetup)); - router.get('/authentication/setup', api.http(apiv2.authentication.isSetup)); + router.put('/authentication/passwordreset', shared.middlewares.brute.globalBlock, http(apiv2.authentication.resetPassword)); + router.post('/authentication/invitation', http(apiv2.authentication.acceptInvitation)); + router.get('/authentication/invitation', http(apiv2.authentication.isInvitation)); + router.post('/authentication/setup', http(apiv2.authentication.setup)); + router.put('/authentication/setup', mw.authAdminApi, http(apiv2.authentication.updateSetup)); + router.get('/authentication/setup', http(apiv2.authentication.isSetup)); // ## Images router.post('/images/upload',