diff --git a/back/methods/account/login.js b/back/methods/account/login.js index 7393e8374..47d4d629a 100644 --- a/back/methods/account/login.js +++ b/back/methods/account/login.js @@ -1,8 +1,9 @@ const md5 = require('md5'); const UserError = require('vn-loopback/util/user-error'); +const ForbiddenError = require('vn-loopback/util/forbiddenError'); module.exports = Self => { - Self.remoteMethod('login', { + Self.remoteMethodCtx('login', { description: 'Login a user with username/email and password', accepts: [ { @@ -26,7 +27,7 @@ module.exports = Self => { } }); - Self.login = async function(user, password) { + Self.login = async function(ctx, user, password) { let $ = Self.app.models; let token; let usesEmail = user.indexOf('@') !== -1; @@ -43,7 +44,7 @@ module.exports = Self => { ? {email: user} : {name: user}; let account = await Self.findOne({ - fields: ['active', 'password'], + fields: ['id', 'active', 'password', 'twoFactor'], where }); @@ -63,6 +64,29 @@ module.exports = Self => { } } + if (account.twoFactor === 'email') { + const authAccess = await $.UserAccess.findOne({ + where: { + userFk: account.id, + ip: ctx.req.connection.remoteAddress + } + }); + if (!authAccess) { + const code = String(Math.floor(Math.random() * 999999)); + const maxTTL = ((60 * 1000) * 5); // 5 min + await $.AuthCode.upsertWithWhere({userFk: account.id}, { + userFk: account.id, + code: code, + expires: Date.now() + maxTTL + }); + + ctx.args.code = code; + await Self.sendTemplate(ctx, 'auth-code'); + + throw new ForbiddenError(); + } + } + let loginInfo = Object.assign({password}, userInfo); token = await $.User.login(loginInfo, 'user'); return {token: token.id}; diff --git a/back/methods/account/validate-auth.js b/back/methods/account/validate-auth.js new file mode 100644 index 000000000..ba1c6a3bb --- /dev/null +++ b/back/methods/account/validate-auth.js @@ -0,0 +1,73 @@ +const UserError = require('vn-loopback/util/user-error'); + +module.exports = Self => { + Self.remoteMethodCtx('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 function(ctx, username, password, code) { + const {AuthCode, UserAccess} = Self.app.models; + + const authCode = await AuthCode.findOne({ + where: { + code: code + } + }); + + const expired = Date.now() > authCode.expires; + if (!authCode || expired) + throw new UserError('Invalid or expired verification code'); + + const user = await Self.findById(authCode.userFk, { + fields: ['name', 'twoFactor'] + }); + + if (user.name !== username) + throw new UserError('Authentication failed'); + + const headers = ctx.req.headers; + let platform = headers['sec-ch-ua-platform']; + let browser = headers['sec-ch-ua']; + + if (platform) platform = platform.replace(/['"]+/g, ''); + if (browser) browser = browser.split(';')[0].replace(/['"]+/g, ''); + + await UserAccess.upsertWithWhere({userFk: authCode.userFk}, { + userFk: authCode.userFk, + ip: ctx.req.connection.remoteAddress, + agent: headers['user-agent'], + platform: platform, + browser: browser + }); + + await authCode.destroy(); + + return Self.login(ctx, username, password); + }; +}; diff --git a/back/model-config.json b/back/model-config.json index 29676e979..91061eb32 100644 --- a/back/model-config.json +++ b/back/model-config.json @@ -5,6 +5,9 @@ "AccountingType": { "dataSource": "vn" }, + "AuthCode": { + "dataSource": "vn" + }, "Bank": { "dataSource": "vn" }, @@ -116,6 +119,9 @@ "Town": { "dataSource": "vn" }, + "UserAccess": { + "dataSource": "vn" + }, "Url": { "dataSource": "vn" }, diff --git a/back/models/account.js b/back/models/account.js index 6d71a4e52..fb9c95005 100644 --- a/back/models/account.js +++ b/back/models/account.js @@ -11,6 +11,7 @@ module.exports = Self => { require('../methods/account/set-password')(Self); require('../methods/account/recover-password')(Self); require('../methods/account/validate-token')(Self); + require('../methods/account/validate-auth')(Self); require('../methods/account/privileges')(Self); // Validations diff --git a/back/models/account.json b/back/models/account.json index 5e35c711a..3b3e2cca5 100644 --- a/back/models/account.json +++ b/back/models/account.json @@ -54,6 +54,9 @@ }, "hasGrant": { "type": "boolean" + }, + "twoFactor": { + "type": "string" } }, "relations": { @@ -113,6 +116,13 @@ "principalId": "$authenticated", "permission": "ALLOW" }, + { + "property": "validateAuth", + "accessType": "EXECUTE", + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ALLOW" + }, { "property": "privileges", "accessType": "*", diff --git a/back/models/auth-code.json b/back/models/auth-code.json new file mode 100644 index 000000000..b6a89115f --- /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/user-access.json b/back/models/user-access.json new file mode 100644 index 000000000..f60d70251 --- /dev/null +++ b/back/models/user-access.json @@ -0,0 +1,36 @@ +{ + "name": "UserAccess", + "base": "VnModel", + "options": { + "mysql": { + "table": "salix.userAccess" + } + }, + "properties": { + "userFk": { + "type": "number", + "required": true, + "id": true + }, + "ip": { + "type": "string", + "required": true + }, + "agent": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "browser": { + "type": "string" + } + }, + "relations": { + "user": { + "type": "belongsTo", + "model": "Account", + "foreignKey": "userFk" + } + } +} diff --git a/db/changes/231401/00-authCode.sql b/db/changes/231401/00-authCode.sql index 8d1b48f12..7f742d4af 100644 --- a/db/changes/231401/00-authCode.sql +++ b/db/changes/231401/00-authCode.sql @@ -2,6 +2,7 @@ create table `salix`.`authCode` ( userFk int UNSIGNED not null, code int not null, + expires TIMESTAMP not null, constraint authCode_pk primary key (userFk), constraint authCode_unique @@ -11,3 +12,16 @@ create table `salix`.`authCode` on update cascade on delete cascade ); +create table `salix`.`userAccess` +( + userFk int UNSIGNED not null, + ip VARCHAR(25) not null, + agent text null, + platform VARCHAR(25) null, + browser VARCHAR(25) null, + constraint userAccess_pk + primary key (userFk), + constraint userAccess_user_null_fk + foreign key (userFk) references account.user (id) +) + auto_increment = 0; \ No newline at end of file diff --git a/db/changes/231401/00-user.sql b/db/changes/231401/00-user.sql index 2c457c14e..1427d0460 100644 --- a/db/changes/231401/00-user.sql +++ b/db/changes/231401/00-user.sql @@ -1,3 +1,3 @@ alter table `account`.`user` - add `2FA` ENUM ('email') null comment 'Two factor auth type'; + add `twoFactor` ENUM ('email') null comment 'Two factor auth type'; diff --git a/front/core/services/auth.js b/front/core/services/auth.js index 5755f8f34..479696931 100644 --- a/front/core/services/auth.js +++ b/front/core/services/auth.js @@ -63,6 +63,23 @@ export default class Auth { json => this.onLoginOk(json, 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 + }; + + return this.$http.post('Accounts/validate-auth', params).then( + json => this.onLoginOk(json, remember)); + } + onLoginOk(json, remember) { this.vnToken.set(json.data.token, remember); diff --git a/front/salix/components/login/index.js b/front/salix/components/login/index.js index 150e896a1..78632aa99 100644 --- a/front/salix/components/login/index.js +++ b/front/salix/components/login/index.js @@ -5,13 +5,14 @@ import './style.scss'; * A simple login form. */ export default class Controller { - constructor($, $element, vnAuth) { + constructor($, $element, vnAuth, $state) { Object.assign(this, { $, $element, vnAuth, user: localStorage.getItem('lastUser'), - remember: true + remember: true, + $state }); } @@ -22,11 +23,21 @@ export default class Controller { localStorage.setItem('lastUser', this.user); this.loading = false; }) - .catch(err => { + .catch(error => { + if (error.message === 'Forbidden') { + this.outLayout.login = { + user: this.user, + password: this.password, + remember: this.remember + }; + this.$state.go('validate-email'); + return; + } + this.loading = false; this.password = ''; this.focusUser(); - throw err; + throw error; }); } @@ -35,9 +46,12 @@ export default class Controller { this.$.userField.focus(); } } -Controller.$inject = ['$scope', '$element', 'vnAuth']; +Controller.$inject = ['$scope', '$element', 'vnAuth', '$state']; ngModule.vnComponent('vnLogin', { template: require('./index.html'), - controller: Controller + controller: Controller, + require: { + outLayout: '^vnOutLayout' + } }); diff --git a/front/salix/components/validate-email/index.html b/front/salix/components/validate-email/index.html index bdbdc113e..e03cb9dcb 100644 --- a/front/salix/components/validate-email/index.html +++ b/front/salix/components/validate-email/index.html @@ -1,19 +1,10 @@ -
Reset password
- - - +
Enter verification code
+Please enter the verification code that we have sent to your email address within 5 minutes. + + \ No newline at end of file diff --git a/front/salix/components/validate-email/index.js b/front/salix/components/validate-email/index.js index c10e1b2b0..f337ab8af 100644 --- a/front/salix/components/validate-email/index.js +++ b/front/salix/components/validate-email/index.js @@ -2,47 +2,42 @@ import ngModule from '../../module'; import './style.scss'; export default class Controller { - constructor($scope, $element, $http, vnApp, $translate, $state, $location) { + constructor($scope, $element, vnAuth, $state) { Object.assign(this, { $scope, $element, - $http, - vnApp, - $translate, - $state, - $location + vnAuth, + user: localStorage.getItem('lastUser'), + remember: true, + $state }); } $onInit() { - this.$http.get('UserPasswords/findOne') - .then(res => { - this.passRequirements = res.data; - }); + this.loginData = this.outLayout.login; + if (!this.loginData) + this.$state.go('login'); } submit() { - if (!this.newPassword) - throw new UserError(`You must enter a new password`); - if (this.newPassword != this.repeatPassword) - throw new UserError(`Passwords don't match`); - - const headers = { - Authorization: this.$location.$$search.access_token - }; - - const newPassword = this.newPassword; - - this.$http.post('users/reset-password', {newPassword}, {headers}) + this.loading = true; + this.vnAuth.validateCode(this.loginData.user, this.loginData.password, this.code, this.loginData.remember) .then(() => { - this.vnApp.showSuccess(this.$translate.instant('Password changed!')); - this.$state.go('login'); + localStorage.setItem('lastUser', this.user); + this.loading = false; + }) + .catch(error => { + this.loading = false; + throw error; }); } } -Controller.$inject = ['$scope', '$element', '$http', 'vnApp', '$translate', '$state', '$location']; +Controller.$inject = ['$scope', '$element', 'vnAuth', '$state']; ngModule.vnComponent('vnValidateEmail', { template: require('./index.html'), - controller: Controller + controller: Controller, + require: { + outLayout: '^vnOutLayout' + } }); diff --git a/loopback/locale/es.json b/loopback/locale/es.json index 42276efe7..70ba15098 100644 --- a/loopback/locale/es.json +++ b/loopback/locale/es.json @@ -273,6 +273,8 @@ "Not exist this branch": "La rama no existe", "This ticket cannot be signed because it has not been boxed": "Este ticket no puede firmarse porque no ha sido encajado", "Insert a date range": "Inserte un rango de fechas", - "Added observation": "{{user}} añadió esta observacion: {{text}}", - "Comment added to client": "Observación añadida al cliente {{clientFk}}" -} + "Added observation": "{{user}} añadió esta observacion: {{text}}", + "Comment added to client": "Observación añadida al cliente {{clientFk}}", + "Invalid auth code": "Invalid auth code", + "Invalid or expired verification code": "Invalid or expired verification code" +} \ No newline at end of file diff --git a/loopback/util/forbiddenError.js b/loopback/util/forbiddenError.js new file mode 100644 index 000000000..998cb4593 --- /dev/null +++ b/loopback/util/forbiddenError.js @@ -0,0 +1,9 @@ +module.exports = class ForbiddenError extends Error { + constructor(message, code, ...translateArgs) { + super(message); + this.name = 'ForbiddenError'; + this.statusCode = 403; + this.code = code; + this.translateArgs = translateArgs; + } +}; diff --git a/print/templates/email/auth-code/assets/css/import.js b/print/templates/email/auth-code/assets/css/import.js new file mode 100644 index 000000000..7360587f7 --- /dev/null +++ b/print/templates/email/auth-code/assets/css/import.js @@ -0,0 +1,13 @@ +const Stylesheet = require(`vn-print/core/stylesheet`); + +const path = require('path'); +const vnPrintPath = path.resolve('print'); + +module.exports = new Stylesheet([ + `${vnPrintPath}/common/css/spacing.css`, + `${vnPrintPath}/common/css/misc.css`, + `${vnPrintPath}/common/css/layout.css`, + `${vnPrintPath}/common/css/email.css`, + `${__dirname}/style.css`]) + .mergeStyles(); + diff --git a/print/templates/email/auth-code/assets/css/style.css b/print/templates/email/auth-code/assets/css/style.css new file mode 100644 index 000000000..d3bfa2aea --- /dev/null +++ b/print/templates/email/auth-code/assets/css/style.css @@ -0,0 +1,5 @@ +.code { + border: 2px dashed #8dba25; + border-radius: 3px; + text-align: center +} \ No newline at end of file diff --git a/print/templates/email/auth-code/auth-code.html b/print/templates/email/auth-code/auth-code.html new file mode 100644 index 000000000..ea87e6c66 --- /dev/null +++ b/print/templates/email/auth-code/auth-code.html @@ -0,0 +1,17 @@ + +
+
+

{{ $t('title') }}

+

+
+
+
+
+

{{$t('Enter the following code to continue to your account')}}

+
+ {{ code }} +
+

{{$t('It expires in 5 minutes.')}}

+
+
+
\ No newline at end of file diff --git a/print/templates/email/auth-code/auth-code.js b/print/templates/email/auth-code/auth-code.js new file mode 100755 index 000000000..7ddd1c2db --- /dev/null +++ b/print/templates/email/auth-code/auth-code.js @@ -0,0 +1,15 @@ +const Component = require(`vn-print/core/component`); +const emailBody = new Component('email-body'); + +module.exports = { + name: 'auth-code', + components: { + 'email-body': emailBody.build(), + }, + props: { + code: { + type: String, + required: true + } + } +}; diff --git a/print/templates/email/auth-code/locale/es.yml b/print/templates/email/auth-code/locale/es.yml new file mode 100644 index 000000000..b77937468 --- /dev/null +++ b/print/templates/email/auth-code/locale/es.yml @@ -0,0 +1,5 @@ +subject: Código de verificación +title: Código de verificación +description: Alguien ha solicitado un código de verificación para poder iniciar sesión. Si no lo has solicitado tu, ignora este email. +Enter the following code to continue to your account: Introduce el siguiente código para poder continuar con tu cuenta +It expires in 5 minutes.: Expira en 5 minutos