diff --git a/back/methods/vn-user/sign-in.js b/back/methods/vn-user/sign-in.js new file mode 100644 index 0000000000..73cc705de0 --- /dev/null +++ b/back/methods/vn-user/sign-in.js @@ -0,0 +1,102 @@ +const ForbiddenError = require('vn-loopback/util/forbiddenError'); +const UserError = require('vn-loopback/util/user-error'); + +module.exports = Self => { + Self.remoteMethodCtx('signIn', { + description: 'Login a user with username/email and password', + accepts: [ + { + arg: 'user', + type: 'String', + description: 'The user name or email', + required: true + }, { + arg: 'password', + type: 'String', + description: 'The password' + } + ], + returns: { + type: 'object', + root: true + }, + http: { + path: `/sign-in`, + verb: 'POST' + } + }); + + Self.signIn = async function(ctx, user, password, options) { + const myOptions = {}; + if (typeof options == 'object') + Object.assign(myOptions, options); + + const where = Self.userUses(user); + const vnUser = await Self.findOne({ + fields: ['id', 'name', 'password', 'active', 'email', 'passExpired', 'twoFactor'], + where + }, myOptions); + + const validCredentials = vnUser + && await vnUser.hasPassword(password); + + if (validCredentials) { + if (!vnUser.active) + throw new UserError('User disabled'); + await Self.sendTwoFactor(ctx, vnUser, myOptions); + await Self.passExpired(vnUser, myOptions); + + if (vnUser.twoFactor) + throw new ForbiddenError(null, 'REQUIRES_2FA'); + } + + return Self.validateLogin(user, password); + }; + + Self.passExpired = async(vnUser, myOptions) => { + const today = Date.vnNew(); + today.setHours(0, 0, 0, 0); + + if (vnUser.passExpired && vnUser.passExpired.getTime() <= today.getTime()) { + const $ = Self.app.models; + const changePasswordToken = await $.AccessToken.create({ + scopes: ['changePassword'], + userId: vnUser.id + }, myOptions); + const err = new UserError('Pass expired', 'passExpired'); + changePasswordToken.twoFactor = vnUser.twoFactor ? true : false; + err.details = {token: changePasswordToken}; + throw err; + } + }; + + Self.sendTwoFactor = async(ctx, vnUser, myOptions) => { + if (vnUser.twoFactor === 'email') { + const $ = Self.app.models; + + const code = String(Math.floor(Math.random() * 999999)); + const maxTTL = ((60 * 1000) * 5); // 5 min + await $.AuthCode.upsertWithWhere({userFk: vnUser.id}, { + userFk: vnUser.id, + code: code, + expires: Date.vnNow() + maxTTL + }, myOptions); + + const headers = ctx.req.headers; + const platform = headers['sec-ch-ua-platform']?.replace(/['"=]+/g, ''); + const browser = headers['sec-ch-ua']?.replace(/['"=]+/g, ''); + const params = { + args: { + recipientId: vnUser.id, + recipient: vnUser.email, + code: code, + ip: ctx.req?.connection?.remoteAddress, + device: platform && browser ? platform + ', ' + browser : headers['user-agent'], + }, + req: {getLocale: ctx.req.getLocale}, + }; + + await Self.sendTemplate(params, 'auth-code', true); + } + }; +}; diff --git a/back/methods/vn-user/signIn.js b/back/methods/vn-user/signIn.js deleted file mode 100644 index e52d68df5b..0000000000 --- a/back/methods/vn-user/signIn.js +++ /dev/null @@ -1,81 +0,0 @@ -const UserError = require('vn-loopback/util/user-error'); - -module.exports = Self => { - Self.remoteMethod('signIn', { - description: 'Login a user with username/email and password', - accepts: [ - { - arg: 'user', - type: 'String', - description: 'The user name or email', - http: {source: 'form'}, - required: true - }, { - arg: 'password', - type: 'String', - description: 'The password' - } - ], - returns: { - type: 'object', - root: true - }, - http: { - path: `/signIn`, - verb: 'POST' - } - }); - - Self.signIn = async function(user, password) { - const models = Self.app.models; - const usesEmail = user.indexOf('@') !== -1; - let token; - - const userInfo = usesEmail - ? {email: user} - : {username: user}; - const instance = await Self.findOne({ - fields: ['username', 'password'], - where: userInfo - }); - - const where = usesEmail - ? {email: user} - : {name: user}; - const vnUser = await Self.findOne({ - fields: ['id', 'active', 'passExpired'], - where - }); - - const today = Date.vnNew(); - today.setHours(0, 0, 0, 0); - - const validCredentials = instance - && await instance.hasPassword(password); - - if (validCredentials) { - if (!vnUser.active) - throw new UserError('User disabled'); - - if (vnUser.passExpired && vnUser.passExpired.getTime() <= today.getTime()) { - const changePasswordToken = await models.AccessToken.create({ - scopes: ['change-password'], - userId: vnUser.id - }); - const err = new UserError('Pass expired', 'passExpired'); - err.details = {token: changePasswordToken}; - throw err; - } - - try { - await models.Account.sync(instance.username, password); - } catch (err) { - console.warn(err); - } - } - - let loginInfo = Object.assign({password}, userInfo); - token = await Self.login(loginInfo, 'user'); - return {token: token.id, ttl: token.ttl}; - }; -}; diff --git a/back/methods/vn-user/specs/sign-in.spec.js b/back/methods/vn-user/specs/sign-in.spec.js new file mode 100644 index 0000000000..f4cad88b9c --- /dev/null +++ b/back/methods/vn-user/specs/sign-in.spec.js @@ -0,0 +1,101 @@ +const {models} = require('vn-loopback/server/server'); + +describe('VnUser Sign-in()', () => { + const employeeId = 1; + const unauthCtx = { + req: { + headers: {}, + connection: { + remoteAddress: '127.0.0.1' + }, + getLocale: () => 'en' + }, + args: {} + }; + const {VnUser, AccessToken} = models; + describe('when credentials are correct', () => { + it('should return the token', async() => { + let login = await VnUser.signIn(unauthCtx, 'salesAssistant', 'nightmare'); + let accessToken = await AccessToken.findById(login.token); + let ctx = {req: {accessToken: accessToken}}; + + expect(login.token).toBeDefined(); + + await VnUser.logout(ctx.req.accessToken.id); + }); + + it('should return the token if the user doesnt exist but the client does', async() => { + let login = await VnUser.signIn(unauthCtx, 'PetterParker', 'nightmare'); + let accessToken = await AccessToken.findById(login.token); + let ctx = {req: {accessToken: accessToken}}; + + expect(login.token).toBeDefined(); + + await VnUser.logout(ctx.req.accessToken.id); + }); + }); + + describe('when credentials are incorrect', () => { + it('should throw a 401 error', async() => { + let error; + + try { + await VnUser.signIn(unauthCtx, 'IDontExist', 'TotallyWrongPassword'); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.statusCode).toBe(401); + expect(error.code).toBe('LOGIN_FAILED'); + }); + }); + + describe('when two-factor auth is required', () => { + it('should throw a 403 error', async() => { + const employee = await VnUser.findById(employeeId); + const tx = await VnUser.beginTransaction({}); + + let error; + try { + const options = {transaction: tx}; + await employee.updateAttribute('twoFactor', 'email', options); + + await VnUser.signIn(unauthCtx, 'employee', 'nightmare', options); + await tx.rollback(); + } catch (e) { + await tx.rollback(); + error = e; + } + + expect(error).toBeDefined(); + expect(error.statusCode).toBe(403); + expect(error.code).toBe('REQUIRES_2FA'); + }); + }); + + describe('when passExpired', () => { + it('should throw a passExpired error', async() => { + const tx = await VnUser.beginTransaction({}); + const employee = await VnUser.findById(employeeId); + const yesterday = Date.vnNew(); + yesterday.setDate(yesterday.getDate() - 1); + + let error; + try { + const options = {transaction: tx}; + await employee.updateAttribute('passExpired', yesterday, options); + + await VnUser.signIn(unauthCtx, 'employee', 'nightmare', options); + await tx.rollback(); + } catch (e) { + await tx.rollback(); + error = e; + } + + expect(error).toBeDefined(); + expect(error.statusCode).toBe(400); + expect(error.message).toBe('Pass expired'); + }); + }); +}); diff --git a/back/methods/vn-user/specs/signIn.spec.js b/back/methods/vn-user/specs/signIn.spec.js deleted file mode 100644 index c3f4630c63..0000000000 --- a/back/methods/vn-user/specs/signIn.spec.js +++ /dev/null @@ -1,41 +0,0 @@ -const {models} = require('vn-loopback/server/server'); - -describe('VnUser signIn()', () => { - describe('when credentials are correct', () => { - it('should return the token', async() => { - let login = await models.VnUser.signIn('salesAssistant', 'nightmare'); - let accessToken = await models.AccessToken.findById(login.token); - let ctx = {req: {accessToken: accessToken}}; - - expect(login.token).toBeDefined(); - - await models.VnUser.logout(ctx.req.accessToken.id); - }); - - it('should return the token if the user doesnt exist but the client does', async() => { - let login = await models.VnUser.signIn('PetterParker', 'nightmare'); - let accessToken = await models.AccessToken.findById(login.token); - let ctx = {req: {accessToken: accessToken}}; - - expect(login.token).toBeDefined(); - - await models.VnUser.logout(ctx.req.accessToken.id); - }); - }); - - describe('when credentials are incorrect', () => { - it('should throw a 401 error', async() => { - let error; - - try { - await models.VnUser.signIn('IDontExist', 'TotallyWrongPassword'); - } catch (e) { - error = e; - } - - expect(error).toBeDefined(); - expect(error.statusCode).toBe(401); - expect(error.code).toBe('LOGIN_FAILED'); - }); - }); -}); diff --git a/back/methods/vn-user/specs/validate-auth.spec.js b/back/methods/vn-user/specs/validate-auth.spec.js new file mode 100644 index 0000000000..8018bd3e1d --- /dev/null +++ b/back/methods/vn-user/specs/validate-auth.spec.js @@ -0,0 +1,52 @@ +const {models} = require('vn-loopback/server/server'); + +describe('VnUser validate-auth()', () => { + describe('validateAuth', () => { + it('should signin if data is correct', async() => { + await models.AuthCode.create({ + userFk: 9, + code: '555555', + expires: Date.vnNow() + (60 * 1000) + }); + const token = await models.VnUser.validateAuth('developer', 'nightmare', '555555'); + + expect(token.token).toBeDefined(); + }); + }); + + describe('validateCode', () => { + it('should throw an error for a non existent code', async() => { + let error; + try { + await models.VnUser.validateCode('developer', '123456'); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.statusCode).toBe(400); + expect(error.message).toEqual('Invalid or expired verification code'); + }); + + it('should throw an error when a code doesn`t match the login username', async() => { + let error; + let authCode; + try { + authCode = await models.AuthCode.create({ + userFk: 1, + code: '555555', + expires: Date.vnNow() + (60 * 1000) + }); + + await models.VnUser.validateCode('developer', '555555'); + } catch (e) { + authCode && await authCode.destroy(); + error = e; + } + + expect(error).toBeDefined(); + expect(error.statusCode).toBe(400); + expect(error.message).toEqual('Authentication failed'); + }); + }); +}); diff --git a/back/methods/vn-user/validate-auth.js b/back/methods/vn-user/validate-auth.js new file mode 100644 index 0000000000..beab43417a --- /dev/null +++ b/back/methods/vn-user/validate-auth.js @@ -0,0 +1,66 @@ +const UserError = require('vn-loopback/util/user-error'); + +module.exports = Self => { + Self.remoteMethod('validateAuth', { + description: 'Login a user with username/email and password', + accepts: [ + { + arg: 'user', + type: 'String', + description: 'The user name or email', + required: true + }, + { + arg: 'password', + type: 'String', + description: 'The password' + }, + { + arg: 'code', + type: 'String', + description: 'The auth code' + } + ], + returns: { + type: 'object', + root: true + }, + http: { + path: `/validate-auth`, + verb: 'POST' + } + }); + + Self.validateAuth = async(username, password, code, options) => { + const myOptions = {}; + if (typeof options == 'object') + Object.assign(myOptions, options); + + const token = Self.validateLogin(username, password); + await Self.validateCode(username, code, myOptions); + return token; + }; + + Self.validateCode = async(username, code, myOptions) => { + const {AuthCode} = Self.app.models; + + const authCode = await AuthCode.findOne({ + where: { + code: code + } + }, myOptions); + + const expired = authCode && Date.vnNow() > authCode.expires; + if (!authCode || expired) + throw new UserError('Invalid or expired verification code'); + + const user = await Self.findById(authCode.userFk, { + fields: ['name', 'twoFactor'] + }, myOptions); + + if (user.name !== username) + throw new UserError('Authentication failed'); + + await authCode.destroy(myOptions); + }; +}; diff --git a/back/model-config.json b/back/model-config.json index d945f32508..0e37bf5278 100644 --- a/back/model-config.json +++ b/back/model-config.json @@ -1,7 +1,4 @@ { - "AccountingType": { - "dataSource": "vn" - }, "AccessTokenConfig": { "dataSource": "vn", "options": { @@ -10,6 +7,12 @@ } } }, + "AccountingType": { + "dataSource": "vn" + }, + "AuthCode": { + "dataSource": "vn" + }, "Bank": { "dataSource": "vn" }, diff --git a/back/models/auth-code.json b/back/models/auth-code.json new file mode 100644 index 0000000000..b6a89115fa --- /dev/null +++ b/back/models/auth-code.json @@ -0,0 +1,31 @@ +{ + "name": "AuthCode", + "base": "VnModel", + "options": { + "mysql": { + "table": "salix.authCode" + } + }, + "properties": { + "userFk": { + "type": "number", + "required": true, + "id": true + }, + "code": { + "type": "string", + "required": true + }, + "expires": { + "type": "number", + "required": true + } + }, + "relations": { + "user": { + "type": "belongsTo", + "model": "Account", + "foreignKey": "userFk" + } + } +} diff --git a/back/models/vn-user.js b/back/models/vn-user.js index b58395acc8..a7ce120735 100644 --- a/back/models/vn-user.js +++ b/back/models/vn-user.js @@ -5,11 +5,12 @@ const {Email} = require('vn-print'); module.exports = function(Self) { vnModel(Self); - require('../methods/vn-user/signIn')(Self); + require('../methods/vn-user/sign-in')(Self); require('../methods/vn-user/acl')(Self); require('../methods/vn-user/recover-password')(Self); require('../methods/vn-user/validate-token')(Self); require('../methods/vn-user/privileges')(Self); + require('../methods/vn-user/validate-auth')(Self); require('../methods/vn-user/renew-token')(Self); Self.definition.settings.acls = Self.definition.settings.acls.filter(acl => acl.property !== 'create'); @@ -111,6 +112,18 @@ module.exports = function(Self) { return email.send(); }); + Self.validateLogin = async function(user, password) { + let loginInfo = Object.assign({password}, Self.userUses(user)); + token = await Self.login(loginInfo, 'user'); + return {token: token.id, ttl: token.ttl}; + }; + + Self.userUses = function(user) { + return user.indexOf('@') !== -1 + ? {email: user} + : {username: user}; + }; + const _setPassword = Self.prototype.setPassword; Self.prototype.setPassword = async function(newPassword, options, cb) { if (cb === undefined && typeof options === 'function') { @@ -143,8 +156,9 @@ module.exports = function(Self) { } }; - Self.sharedClass._methods.find(method => method.name == 'changePassword') - .accessScopes = ['change-password']; + Self.sharedClass._methods.find(method => method.name == 'changePassword').ctor.settings.acls = + Self.sharedClass._methods.find(method => method.name == 'changePassword').ctor.settings.acls + .filter(acl => acl.property != 'changePassword'); // FIXME: https://redmine.verdnatura.es/issues/5761 // Self.afterRemote('prototype.patchAttributes', async(ctx, instance) => { diff --git a/back/models/vn-user.json b/back/models/vn-user.json index 61e42f77ab..9131c9134a 100644 --- a/back/models/vn-user.json +++ b/back/models/vn-user.json @@ -59,7 +59,10 @@ }, "passExpired": { "type": "date" - } + }, + "twoFactor": { + "type": "string" + } }, "relations": { "role": { @@ -111,6 +114,13 @@ "principalId": "$authenticated", "permission": "ALLOW" }, + { + "property": "validateAuth", + "accessType": "EXECUTE", + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ALLOW" + }, { "property": "privileges", "accessType": "*", diff --git a/db/changes/232801/00-authCode.sql b/db/changes/232801/00-authCode.sql new file mode 100644 index 0000000000..a256db43f5 --- /dev/null +++ b/db/changes/232801/00-authCode.sql @@ -0,0 +1,13 @@ +create table `salix`.`authCode` +( + userFk int UNSIGNED not null, + code int not null, + expires bigint not null, + constraint authCode_pk + primary key (userFk), + constraint authCode_unique + unique (code), + constraint authCode_user_id_fk + foreign key (userFk) references `account`.`user` (id) + on update cascade on delete cascade +); diff --git a/db/changes/232801/00-department.sql b/db/changes/232801/00-department.sql new file mode 100644 index 0000000000..d9a91ee30b --- /dev/null +++ b/db/changes/232801/00-department.sql @@ -0,0 +1,24 @@ +alter table `vn`.`department` + add `twoFactor` ENUM ('email') null comment 'Default user two-factor auth type'; + +drop trigger `vn`.`department_afterUpdate`; + +DELIMITER $$ +$$ +create definer = root@localhost trigger department_afterUpdate + after update + on department + for each row +BEGIN + IF !(OLD.parentFk <=> NEW.parentFk) THEN + UPDATE vn.department_recalc SET isChanged = TRUE; + END IF; + + IF !(OLD.twoFactor <=> NEW.twoFactor) THEN + UPDATE account.user u + JOIN vn.workerDepartment wd ON wd.workerFk = u.id + SET u.twoFactor = NEW.twoFactor + WHERE wd.departmentFk = NEW.id; + END IF; +END;$$ +DELIMITER ; diff --git a/db/changes/232801/00-user.sql b/db/changes/232801/00-user.sql new file mode 100644 index 0000000000..376b3dbb15 --- /dev/null +++ b/db/changes/232801/00-user.sql @@ -0,0 +1,5 @@ +alter table `account`.`user` + add `twoFactor` ENUM ('email') null comment 'Two-factor auth type'; + +DELETE FROM `salix`.`ACL` + WHERE model = 'VnUser' AND property = 'changePassword'; diff --git a/db/dump/fixtures.sql b/db/dump/fixtures.sql index c02e3dce4c..14c9fba5d2 100644 --- a/db/dump/fixtures.sql +++ b/db/dump/fixtures.sql @@ -77,7 +77,10 @@ INSERT INTO `account`.`user`(`id`,`name`, `nickname`, `role`,`active`,`email`, ` ORDER BY id; INSERT INTO `account`.`account`(`id`) - SELECT id FROM `account`.`user`; + SELECT `u`.`id` + FROM `account`.`user` `u` + JOIN `account`.`role` `r` ON `u`.`role` = `r`.`id` + WHERE `r`.`name` <> 'customer'; INSERT INTO `vn`.`educationLevel` (`id`, `name`) VALUES @@ -2849,8 +2852,8 @@ INSERT INTO `vn`.`profileType` (`id`, `name`) INSERT INTO `salix`.`url` (`appName`, `environment`, `url`) VALUES - ('lilium', 'dev', 'http://localhost:9000/#/'), - ('salix', 'dev', 'http://localhost:5000/#!/'); + ('lilium', 'development', 'http://localhost:9000/#/'), + ('salix', 'development', 'http://localhost:5000/#!/'); INSERT INTO `vn`.`report` (`id`, `name`, `paperSizeFk`, `method`) VALUES diff --git a/e2e/paths/01-salix/05_changePassword.spec.js b/e2e/paths/01-salix/05_changePassword.spec.js index 6e4cfb7f38..950f773ddd 100644 --- a/e2e/paths/01-salix/05_changePassword.spec.js +++ b/e2e/paths/01-salix/05_changePassword.spec.js @@ -16,6 +16,7 @@ describe('ChangePassword path', async() => { await browser.close(); }); + const badPassword = 'badpass'; const oldPassword = 'nightmare'; const newPassword = 'newPass.1234'; describe('Bad login', async() => { @@ -37,13 +38,22 @@ describe('ChangePassword path', async() => { expect(message.text).toContain('Invalid current password'); // Bad attempt: password not meet requirements + message = await page.sendForm($.form, { + oldPassword: oldPassword, + newPassword: badPassword, + repeatPassword: badPassword + }); + + expect(message.text).toContain('Password does not meet requirements'); + + // Bad attempt: same password message = await page.sendForm($.form, { oldPassword: oldPassword, newPassword: oldPassword, repeatPassword: oldPassword }); - expect(message.text).toContain('Password does not meet requirements'); + expect(message.text).toContain('You can not use the same password'); // Correct attempt: change password message = await page.sendForm($.form, { diff --git a/front/core/services/auth.js b/front/core/services/auth.js index 92ff4b0611..844a5145d8 100644 --- a/front/core/services/auth.js +++ b/front/core/services/auth.js @@ -24,7 +24,7 @@ export default class Auth { initialize() { let criteria = { to: state => { - const outLayout = ['login', 'recover-password', 'reset-password', 'change-password']; + const outLayout = ['login', 'recover-password', 'reset-password', 'change-password', 'validate-email']; return !outLayout.some(ol => ol == state.name); } }; @@ -60,7 +60,25 @@ export default class Auth { }; const now = new Date(); - return this.$http.post('VnUsers/signIn', params) + return this.$http.post('VnUsers/sign-in', params).then( + json => this.onLoginOk(json, now, remember)); + } + + validateCode(user, password, code, remember) { + if (!user) { + let err = new UserError('Please enter your username'); + err.code = 'EmptyLogin'; + return this.$q.reject(err); + } + + let params = { + user: user, + password: password || undefined, + code: code + }; + + const now = new Date(); + return this.$http.post('VnUsers/validate-auth', params) .then(json => this.onLoginOk(json, now, remember)); } diff --git a/front/core/services/token.js b/front/core/services/token.js index 8f9f80e5c7..c4b644a897 100644 --- a/front/core/services/token.js +++ b/front/core/services/token.js @@ -34,7 +34,6 @@ export default class Token { remember }); this.vnInterceptor.setToken(token); - try { if (remember) this.setStorage(localStorage, token, created, ttl); diff --git a/front/salix/components/change-password/index.html b/front/salix/components/change-password/index.html index 8d338d4118..04f66976e8 100644 --- a/front/salix/components/change-password/index.html +++ b/front/salix/components/change-password/index.html @@ -21,6 +21,14 @@ type="password" autocomplete="false"> + +