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 @@ -
{{$t('Enter the following code to continue to your account')}}
+{{$t('It expires in 5 minutes.')}}
+