Validate code
gitea/salix/pipeline/head There was a failure building this commit Details

This commit is contained in:
Joan Sanchez 2023-04-06 14:59:25 +02:00
parent 641fdf39d8
commit 2e0325e567
20 changed files with 331 additions and 53 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "*",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +1,9 @@
<h5 class="vn-mb-md vn-mt-lg" translate>Reset password</h5>
<vn-textfield
label="New password"
ng-model="$ctrl.newPassword"
type="password"
info="{{'Password requirements' | translate:$ctrl.passRequirements}}"
vn-focus>
</vn-textfield>
<vn-textfield
label="Repeat password"
ng-model="$ctrl.repeatPassword"
type="password">
<h5 class="vn-mb-md vn-mt-lg" translate>Enter verification code</h5>
<span>Please enter the verification code that we have sent to your email address within 5 minutes.</span>
<vn-textfield label="Code" ng-model="$ctrl.code" type="text" vn-focus>
</vn-textfield>
<div class="footer">
<vn-submit label="Reset password" ng-click="$ctrl.submit()"></vn-submit>
<vn-submit label="Validate" ng-click="$ctrl.submit()"></vn-submit>
<div class="spinner-wrapper">
<vn-spinner enable="$ctrl.loading"></vn-spinner>
</div>

View File

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

View File

@ -274,5 +274,7 @@
"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}}"
"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"
}

View File

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

View File

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

View File

@ -0,0 +1,5 @@
.code {
border: 2px dashed #8dba25;
border-radius: 3px;
text-align: center
}

View File

@ -0,0 +1,17 @@
<email-body v-bind="$props">
<div class="grid-row">
<div class="grid-block vn-pa-ml">
<h1>{{ $t('title') }}</h1>
<p v-html="$t('description')"></p>
</div>
</div>
<div class="grid-row">
<div class="grid-block vn-pa-ml">
<p>{{$t('Enter the following code to continue to your account')}}</p>
<div class="code vn-pa-sm vn-m-md">
{{ code }}
</div>
<p>{{$t('It expires in 5 minutes.')}}</p>
</div>
</div>
</email-body>

View File

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

View File

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