5475 - Autenticación de doble factor por email #1429

Merged
alexm merged 40 commits from 5475-email_2fa into dev 2023-07-06 08:59:23 +00:00
49 changed files with 849 additions and 185 deletions

View File

@ -0,0 +1,102 @@
const ForbiddenError = require('vn-loopback/util/forbiddenError');
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
alexm marked this conversation as resolved
Review

Esta ruta no se llama nunca

Esta ruta no se llama nunca
Self.remoteMethodCtx('signIn', {
alexm marked this conversation as resolved Outdated
Outdated
Review

Poner la i mayúscula: signIn

Poner la i mayúscula: `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);
alexm marked this conversation as resolved Outdated
Outdated
Review

Comprobar si options es un objeto, podría ser el callback

Comprobar si options es un objeto, podría ser el callback
const where = Self.userUses(user);
const vnUser = await Self.findOne({
fields: ['id', 'name', 'password', 'active', 'email', 'passExpired', 'twoFactor'],
alexm marked this conversation as resolved Outdated
Outdated
Review

Código duplicado con Self.validateLogin() , extraer a una función

Código duplicado con `Self.validateLogin()` , extraer a una función
where
}, myOptions);
const validCredentials = vnUser
&& await vnUser.hasPassword(password);
alexm marked this conversation as resolved Outdated
Outdated
Review

i si no te el twoDactor?

i si no te el twoDactor?
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');
Review

Revisar que si el parámetro message es null, se utiliza el valor por defecto, si lo tiene.

Revisar que si el parámetro message es null, se utiliza el valor por defecto, si lo tiene.
Review

Revisar que si el parámetro message es null, se utiliza el valor por defecto, si lo tiene.

Revisar que si el parámetro message es null, se utiliza el valor por defecto, si lo tiene.
}
return Self.validateLogin(user, password);
alexm marked this conversation as resolved
Review

El primer parámetro al instanciar un error es el mensaje, el código va en el segundo parámetro

El primer parámetro al instanciar un error es el mensaje, el código va en el segundo parámetro
};
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,
alexm marked this conversation as resolved
Review

platform y browser son const

platform y browser son `const`
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);
}
};
};

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,66 @@
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethod('validateAuth', {
alexm marked this conversation as resolved Outdated
Outdated
Review

Aço quan es gasta?

Aço quan es gasta?
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')
alexm marked this conversation as resolved Outdated
Outdated
Review

Validar credenciales antes del código

Validar credenciales antes del código
Object.assign(myOptions, options);
const token = Self.validateLogin(username, password);
await Self.validateCode(username, code, myOptions);
Review

Antes de validar el código, validar las credenciales pero sin generar el token. Despues de validar las credenciales, generar el token.

Hacerlo todo en una transacción.

Antes de validar el código, validar las credenciales pero sin generar el token. Despues de validar las credenciales, generar el token. Hacerlo todo en una transacción.
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);
};
};

View File

@ -1,7 +1,4 @@
{
"AccountingType": {
"dataSource": "vn"
},
"AccessTokenConfig": {
"dataSource": "vn",
"options": {
@ -10,6 +7,12 @@
}
}
},
"AccountingType": {
"dataSource": "vn"
},
"AuthCode": {
"dataSource": "vn"
},
"Bank": {
"dataSource": "vn"
},

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

@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

@ -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`
alexm marked this conversation as resolved Outdated
Outdated
Review

Es mas claro hacer join con role y usar el nombre del rol

Es mas claro hacer join con role y usar el nombre del rol
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

View File

@ -16,6 +16,7 @@ describe('ChangePassword path', async() => {
await browser.close();
});
const badPassword = 'badpass';
alexm marked this conversation as resolved
Review

badPassword podría ser útil si estás validando la fuerza de una contraseña, donde "mala" puede implicar que la contraseña es débil, demasiado corta, no tiene suficiente variedad de caracteres, etc.

wrongPassword, por otro lado, sugiere que se ha ingresado una contraseña incorrecta, como en un sistema de inicio de sesión, donde el usuario ha proporcionado una contraseña que no coincide con la almacenada para ese nombre de usuario.

Por lo tanto, la elección entre estos dos nombres depende de la situación exacta en la que estés trabajando. Como buena práctica de programación, debes tratar de usar identificadores que sean lo más descriptivos posible y que describan claramente la funcionalidad o el propósito de la variable en cuestión.

badPassword podría ser útil si estás validando la fuerza de una contraseña, donde "mala" puede implicar que la contraseña es débil, demasiado corta, no tiene suficiente variedad de caracteres, etc. wrongPassword, por otro lado, sugiere que se ha ingresado una contraseña incorrecta, como en un sistema de inicio de sesión, donde el usuario ha proporcionado una contraseña que no coincide con la almacenada para ese nombre de usuario. Por lo tanto, la elección entre estos dos nombres depende de la situación exacta en la que estés trabajando. Como buena práctica de programación, debes tratar de usar identificadores que sean lo más descriptivos posible y que describan claramente la funcionalidad o el propósito de la variable en cuestión.
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, {

View File

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

View File

@ -34,7 +34,6 @@ export default class Token {
remember
});
this.vnInterceptor.setToken(token);
try {
if (remember)
this.setStorage(localStorage, token, created, ttl);

View File

@ -21,6 +21,14 @@
type="password"
autocomplete="false">
</vn-textfield>
<vn-textfield
ng-if="$ctrl.$state.params.twoFactor == 'true'"
label="Verification code"
ng-model="$ctrl.code"
vn-name="code"
autocomplete="false"
class="vn-mt-md">
</vn-textfield>
<div class="footer">
<vn-submit label="Change password" ng-click="$ctrl.submit()"></vn-submit>
<div class="spinner-wrapper">

View File

@ -26,11 +26,13 @@ export default class Controller {
submit() {
const userId = this.$state.params.userId;
const newPassword = this.newPassword;
const oldPassword = this.oldPassword;
const newPassword = this.newPassword;
const repeatPassword = this.repeatPassword;
const code = this.code;
if (!newPassword)
throw new UserError(`You must enter a new password`);
if (!oldPassword || !newPassword || !repeatPassword)
throw new UserError(`You must fill all the fields`);
if (newPassword != this.repeatPassword)
throw new UserError(`Passwords don't match`);
@ -38,11 +40,12 @@ export default class Controller {
Authorization: this.$state.params.id
};
this.$http.post('VnUsers/change-password',
this.$http.patch('Accounts/change-password',
{
id: userId,
oldPassword,
newPassword
newPassword,
code
},
{headers}
).then(() => {

View File

@ -2,6 +2,10 @@ Change password: Cambiar contraseña
Old password: Antigua contraseña
New password: Nueva contraseña
Repeat password: Repetir contraseña
Passwords don't match: Las contraseñas no coinciden
You must fill all the fields: Debes rellenar todos los campos
You can not use the same password: No puedes usar la misma contraseña
Verification code: Código de verificación
Password updated!: ¡Contraseña actualizada!
Password requirements: >
La contraseña debe tener al menos {{ length }} caracteres de longitud,

View File

@ -9,6 +9,7 @@ import './login';
import './outLayout';
import './recover-password';
import './reset-password';
import './validate-email';
import './change-password';
import './module-card';
import './module-main';

View File

@ -24,13 +24,23 @@ export default class Controller {
this.loading = false;
})
.catch(req => {
this.loading = false;
this.password = '';
this.focusUser();
if (req?.data?.error?.code === 'REQUIRES_2FA') {
alexm marked this conversation as resolved Outdated
Outdated
Review

Comprobar errores por código, nombre del error o código de respuesta HTTP

Comprobar errores por código, nombre del error o código de respuesta HTTP
this.outLayout.login = {
user: this.user,
password: this.password,
remember: this.remember
};
this.$state.go('validate-email');
return;
}
const err = req.data?.error;
if (err?.code == 'passExpired')
this.$state.go('change-password', err.details.token);
this.loading = false;
this.password = '';
this.focusUser();
throw req;
});
}
@ -44,5 +54,8 @@ Controller.$inject = ['$scope', '$element', '$state', 'vnAuth'];
ngModule.vnComponent('vnLogin', {
template: require('./index.html'),
controller: Controller
controller: Controller,
require: {
outLayout: '^vnOutLayout'
}
});

View File

@ -0,0 +1,10 @@
<h5 class="vn-mb-md vn-mt-lg" translate>Enter verification code</h5>
<span translate>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="Validate" ng-click="$ctrl.submit()"></vn-submit>
<div class="spinner-wrapper">
<vn-spinner enable="$ctrl.loading"></vn-spinner>
</div>
</div>

View File

@ -0,0 +1,43 @@
import ngModule from '../../module';
import './style.scss';
export default class Controller {
constructor($scope, $element, vnAuth, $state) {
Object.assign(this, {
$scope,
$element,
vnAuth,
user: localStorage.getItem('lastUser'),
remember: true,
$state
});
}
$onInit() {
this.loginData = this.outLayout.login;
if (!this.loginData)
this.$state.go('login');
}
submit() {
this.loading = true;
this.vnAuth.validateCode(this.loginData.user, this.loginData.password, this.code, this.loginData.remember)
.then(() => {
localStorage.setItem('lastUser', this.user);
this.loading = false;
})
.catch(error => {
this.loading = false;
throw error;
});
}
}
Controller.$inject = ['$scope', '$element', 'vnAuth', '$state'];
ngModule.vnComponent('vnValidateEmail', {
template: require('./index.html'),
controller: Controller,
require: {
outLayout: '^vnOutLayout'
}
});

View File

@ -0,0 +1,5 @@
Validate email auth: Autenticar email
Enter verification code: Introduce código de verificación
Code: Código
Please enter the verification code that we have sent to your email address within 5 minutes: Por favor, introduce el código de verificación que te hemos enviado a tu email en los próximos 5 minutos
Validate: Validar

View File

@ -0,0 +1,24 @@
@import "variables";
vn-validate-email {
.footer {
margin-top: 32px;
text-align: center;
position: relative;
& > .vn-submit {
display: block;
& > input {
display: block;
width: 100%;
}
}
& > .spinner-wrapper {
position: absolute;
width: 0;
top: 3px;
right: -8px;
overflow: visible;
}
}
}

View File

@ -37,9 +37,15 @@ function config($stateProvider, $urlRouterProvider) {
description: 'Reset password',
template: '<vn-reset-password></vn-reset-password>'
})
.state('validate-email', {
parent: 'outLayout',
url: '/validate-email',
description: 'Validate email auth',
template: '<vn-validate-email></vn-validate-email>'
})
.state('change-password', {
parent: 'outLayout',
url: '/change-password?id&userId',
url: '/change-password?id&userId&twoFactor',
description: 'Change password',
template: '<vn-change-password></vn-change-password>'
})

View File

@ -39,7 +39,7 @@ module.exports = Self => {
return [html, 'text/html', `filename=${fileName}.pdf"`];
};
Self.sendTemplate = async function(ctx, templateName) {
Self.sendTemplate = async function(ctx, templateName, force) {
const args = Object.assign({}, ctx.args);
const params = {
recipient: args.recipient,
@ -52,6 +52,6 @@ module.exports = Self => {
const email = new Email(templateName, params);
return email.send();
return email.send({force: force});
};
};

View File

@ -176,5 +176,6 @@
"Failed to upload delivery note": "Error to upload delivery note {{id}}",
"Mail not sent": "There has been an error sending the invoice to the client [{{clientId}}]({{{clientUrl}}}), please check the email address",
"The renew period has not been exceeded": "The renew period has not been exceeded",
"You can not use the same password": "You can not use the same password",
"Valid priorities": "Valid priorities: %d"
}

View File

@ -276,6 +276,8 @@
"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}}",

traduir

traduir
"Invalid auth code": "Código de verificación incorrecto",
"Invalid or expired verification code": "Código de verificación incorrecto o expirado",
"Cannot create a new claimBeginning from a different ticket": "No se puede crear una línea de reclamación de un ticket diferente al origen",
"company": "Compañía",
"country": "País",
@ -293,6 +295,8 @@
"Invalid NIF for VIES": "Invalid NIF for VIES",
"Ticket does not exist": "Este ticket no existe",
"Ticket is already signed": "Este ticket ya ha sido firmado",
"Authentication failed": "Autenticación fallida",
"You can't use the same password": "No puedes usar la misma contraseña",
"You can only add negative amounts in refund tickets": "Solo se puede añadir cantidades negativas en tickets abono",
"Fecha fuera de rango": "Fecha fuera de rango",
"Error while generating PDF": "Error al generar PDF",

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

@ -1,15 +1,12 @@
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => {
Self.remoteMethod('changePassword', {
Self.remoteMethodCtx('changePassword', {
description: 'Changes the user password',
accessType: 'WRITE',
accessScopes: ['changePassword'],
accepts: [
{
arg: 'id',
type: 'number',
description: 'The user id',
http: {source: 'path'}
}, {
arg: 'oldPassword',
type: 'string',
description: 'The old password',
@ -19,15 +16,35 @@ module.exports = Self => {
type: 'string',
description: 'The new password',
required: true
}, {
arg: 'code',
type: 'string',
description: 'The 2FA code'
}
],
http: {
path: `/:id/changePassword`,
path: `/change-password`,
verb: 'PATCH'
}
});
Self.changePassword = async function(id, oldPassword, newPassword) {
await Self.app.models.VnUser.changePassword(id, oldPassword, newPassword);
Self.changePassword = async function(ctx, oldPassword, newPassword, code, options) {
const userId = ctx.req.accessToken.userId;
const myOptions = {};
alexm marked this conversation as resolved Outdated
Outdated
Review

Comprobar que options es un objeto, puede ser el callback

Comprobar que options es un objeto, puede ser el callback
if (typeof options == 'object')
Object.assign(myOptions, options);
const {VnUser} = Self.app.models;
alexm marked this conversation as resolved Outdated
Outdated
Review

Validar contraseña antigua y credenciales del usuario antes de hacer cualquier comprobación/modificación

Validar contraseña antigua y credenciales del usuario antes de hacer cualquier comprobación/modificación
const user = await VnUser.findById(userId, {fields: ['name', 'twoFactor']}, myOptions);
await user.hasPassword(oldPassword);
if (oldPassword == newPassword)
throw new UserError(`You can not use the same password`);
if (user.twoFactor)
await VnUser.validateCode(user.name, code, myOptions);
await VnUser.changePassword(userId, oldPassword, newPassword, myOptions);
Review

Hacerlo todo en una transacción.

Hacerlo todo en una transacción.
};
};

View File

@ -1,5 +1,5 @@
module.exports = Self => {
Self.remoteMethod('login', {
Self.remoteMethodCtx('login', {
description: 'Login a user with username/email and password',
accepts: [
{
@ -23,5 +23,5 @@ module.exports = Self => {
}
});
Self.login = async(user, password) => Self.app.models.VnUser.signIn(user, password);
Self.login = async(ctx, user, password, options) => Self.app.models.VnUser.signIn(ctx, user, password, options);
Outdated
Review

Self.login = Self.app.models.VnUser.signin

`Self.login = Self.app.models.VnUser.signin`
Outdated
Review

Unhandled rejection TypeError: Cannot read properties of undefined (reading 'models')

Unhandled rejection TypeError: Cannot read properties of undefined (reading 'models')
};

View File

@ -21,7 +21,8 @@ module.exports = Self => {
}
});
Self.setPassword = async function(id, newPassword) {
await Self.app.models.VnUser.setPassword(id, newPassword);
Self.setPassword = async function(id, newPassword, options) {
options = typeof options == 'object' ? options : {};
await Self.app.models.VnUser.setPassword(id, newPassword, options);
Review

Self.setPassword = Self.app.models.VnUser.setPassword

`Self.setPassword = Self.app.models.VnUser.setPassword`
Review

Unhandled rejection TypeError: Cannot read properties of undefined (reading 'models')

Unhandled rejection TypeError: Cannot read properties of undefined (reading 'models')
};
};

View File

@ -1,22 +1,99 @@
const {models} = require('vn-loopback/server/server');
describe('account changePassword()', () => {
it('should throw an error when old password is wrong', async() => {
let error;
try {
await models.Account.changePassword(1, 'wrongPassword', 'nightmare.9999');
} catch (e) {
error = e.message;
}
const ctx = {req: {accessToken: {userId: 70}}};
const unauthCtx = {
req: {
headers: {},
connection: {
remoteAddress: '127.0.0.1'
},
getLocale: () => 'en'
},
args: {}
};
describe('Without 2FA', () => {
it('should throw an error when old password is wrong', async() => {
const tx = await models.Account.beginTransaction({});
expect(error).toContain('Invalid current password');
let error;
try {
const options = {transaction: tx};
await models.Account.changePassword(ctx, 'wrongPassword', 'nightmare.9999', null, options);
await tx.rollback();
} catch (e) {
await tx.rollback();
error = e.message;
}
expect(error).toContain('Invalid current password');
});
it('should throw an error when old and new password are the same', async() => {
const tx = await models.Account.beginTransaction({});
let error;
try {
const options = {transaction: tx};
await models.Account.changePassword(ctx, 'nightmare', 'nightmare.9999', null, options);
await models.Account.changePassword(ctx, 'nightmare.9999', 'nightmare.9999', null, options);
await tx.rollback();
} catch (e) {
await tx.rollback();
error = e.message;
}
expect(error).toContain('You can not use the same password');
});
it('should change password', async() => {
const tx = await models.Account.beginTransaction({});
try {
const options = {transaction: tx};
await models.Account.changePassword(ctx, 'nightmare', 'nightmare.9999', null, options);
await tx.rollback();
} catch (e) {
await tx.rollback();
expect(e).toBeUndefined();
}
});
});
it('should change password', async() => {
try {
await models.Account.changePassword(70, 'nightmare', 'nightmare.9999');
} catch (e) {
expect(e).toBeUndefined();
}
describe('With 2FA', () => {
it('should change password when code is correct', async() => {
const tx = await models.Account.beginTransaction({});
const yesterday = Date.vnNew();
yesterday.setDate(yesterday.getDate() - 1);
const options = {transaction: tx};
try {
await models.VnUser.updateAll(
{id: 70},
{
twoFactor: 'email',
passExpired: yesterday
}
, options);
await models.VnUser.signIn(unauthCtx, 'trainee', 'nightmare', options);
} catch (e) {
if (e.message != 'Pass expired')
throw e;
}
try {
const authCode = await models.AuthCode.findOne({where: {userFk: 70}}, options);
await models.Account.changePassword(ctx, 'nightmare', 'nightmare.9999', authCode.code, options);
await tx.rollback();
} catch (e) {
await tx.rollback();
expect(e).toBeUndefined();
}
});
});
});

View File

@ -8,8 +8,18 @@ describe('Account setPassword()', () => {
});
it('should update password when it passes requirements', async() => {
let req = models.Account.setPassword(1, 'Very$ecurePa22.');
const tx = await models.Account.beginTransaction({});
await expectAsync(req).toBeResolved();
let error;
try {
const options = {transaction: tx};
await models.Account.setPassword(1, 'Very$ecurePa22.', options);
await tx.rollback();
} catch (e) {
await tx.rollback();
error = e;
}
expect(error).not.toBeDefined();
});
});

View File

@ -37,6 +37,13 @@
"principalType": "ROLE",
"principalId": "$authenticated",
"permission": "ALLOW"
},
{
alexm marked this conversation as resolved
Review

A modo de curiositat, si gastes ScopeAccess has de possarli ACL sino sempre diu Acceso no permitido. Pero igualment soles funciona si tenen el token en scope

A modo de curiositat, si gastes ScopeAccess has de possarli ACL sino sempre diu Acceso no permitido. Pero igualment soles funciona si tenen el token en scope
"property": "changePassword",
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW"
}
]
}

View File

@ -28,7 +28,7 @@ module.exports = Self => {
const isAccount = await models.Account.findById(id);
if (isClient && !isAccount)
await models.Account.setPassword(id, newPassword);
await models.VnUser.setPassword(id, newPassword);
else
throw new UserError(`Modifiable password only via recovery or by an administrator`);
};

2
package-lock.json generated
View File

@ -6,7 +6,7 @@
"packages": {
"": {
"name": "salix-back",
"version": "23.24.01",
"version": "23.26.01",
"license": "GPL-3.0",
"dependencies": {
"axios": "^1.2.2",

View File

@ -84,7 +84,8 @@ class Email extends Component {
replyTo: this.args.replyTo || '',
subject: localeSubject,
html: rendered,
attachments: attachments
attachments: attachments,
force: options.force
};
return smtp.send(mailOptions);

View File

@ -10,16 +10,17 @@ module.exports = {
async send(options) {
options.from = `${config.app.senderName} <${config.app.senderEmail}>`;
if (!process.env.NODE_ENV)
alexm marked this conversation as resolved
Review

Revisam esta part que es delica

Revisam esta part que es delica
Review

Esta bè

Esta bè
options.to = config.app.senderEmail;
if (process.env.NODE_ENV !== 'production') {
if (process.env.NODE_ENV !== 'production' && !options.force) {
const notProductionError = {message: 'This not production, this email not sended'};
await this.mailLog(options, notProductionError);
if (!config.smtp.auth.user)
return Promise.resolve(true);
options.to = config.app.senderEmail;
}
if (!config.smtp.auth.user)
return Promise.resolve(true);
let res;
let error;
try {

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,23 @@
<email-body v-bind="$props">
<div class="grid-row">
<div class="grid-block vn-pa-ml">
<h1>{{ $t('title') }}</h1>
<p>{{ $t('description') }}</p>
<p>
{{ $t('device') }}: <strong>{{ device }}</strong>
alexm marked this conversation as resolved Outdated
Outdated
Review

El uso de la directiva v-html no es necesario, dar el estilo con CSS

El uso de la directiva `v-html` no es necesario, dar el estilo con CSS
</p>
<p>
{{$t('ip')}}: <strong>{{ ip }}</strong>
</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,21 @@
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
},
device: {
type: String
},
ip: {
type: String
}
}
};

View File

@ -0,0 +1,7 @@
subject: Verification code
title: Verification code
description: Somebody did request a verification code for login. If you didn't request it, please ignore this email.
device: 'Device'
ip: 'IP'
Enter the following code to continue to your account: Enter the following code to continue to your account
It expires in 5 minutes: It expires in 5 minutes

View File

@ -0,0 +1,7 @@
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.
device: 'Dispositivo'
ip: 'IP'
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

View File

@ -0,0 +1,7 @@
subject: Code de vérification
title: Code de vérification
description: Quelqu'un a demandé un code de vérification pour se connecter. Si ce n'était pas toi, ignore cet email.
device: 'Appareil'
ip: 'IP'
Enter the following code to continue to your account: Entrez le code suivant pour continuer avec votre compte
It expires in 5 minutes: Il expire dans 5 minutes.

View File

@ -0,0 +1,7 @@
subject: Código de verificação
title: Código de verificação
description: Alguém solicitou um código de verificação para entrar. Se você não fez essa solicitação, ignore este e-mail.
device: 'Dispositivo'
ip: 'IP'
Enter the following code to continue to your account: Insira o seguinte código para continuar com sua conta.
It expires in 5 minutes: Expira em 5 minutos.