Merge pull request '5475 - Autenticación de doble factor por email' (!1429) from 5475-email_2fa into dev
gitea/salix/pipeline/head This commit looks good
Details
gitea/salix/pipeline/head This commit looks good
Details
Reviewed-on: #1429 Reviewed-by: Javi Gallego <jgallego@verdnatura.es> Reviewed-by: Juan Ferrer <juan@verdnatura.es> Reviewed-by: Vicent Llopis <vicent@verdnatura.es>
This commit is contained in:
commit
48951cc5a1
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
|
@ -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};
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,7 +1,4 @@
|
||||||
{
|
{
|
||||||
"AccountingType": {
|
|
||||||
"dataSource": "vn"
|
|
||||||
},
|
|
||||||
"AccessTokenConfig": {
|
"AccessTokenConfig": {
|
||||||
"dataSource": "vn",
|
"dataSource": "vn",
|
||||||
"options": {
|
"options": {
|
||||||
|
@ -10,6 +7,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"AccountingType": {
|
||||||
|
"dataSource": "vn"
|
||||||
|
},
|
||||||
|
"AuthCode": {
|
||||||
|
"dataSource": "vn"
|
||||||
|
},
|
||||||
"Bank": {
|
"Bank": {
|
||||||
"dataSource": "vn"
|
"dataSource": "vn"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,11 +5,12 @@ const {Email} = require('vn-print');
|
||||||
module.exports = function(Self) {
|
module.exports = function(Self) {
|
||||||
vnModel(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/acl')(Self);
|
||||||
require('../methods/vn-user/recover-password')(Self);
|
require('../methods/vn-user/recover-password')(Self);
|
||||||
require('../methods/vn-user/validate-token')(Self);
|
require('../methods/vn-user/validate-token')(Self);
|
||||||
require('../methods/vn-user/privileges')(Self);
|
require('../methods/vn-user/privileges')(Self);
|
||||||
|
require('../methods/vn-user/validate-auth')(Self);
|
||||||
require('../methods/vn-user/renew-token')(Self);
|
require('../methods/vn-user/renew-token')(Self);
|
||||||
|
|
||||||
Self.definition.settings.acls = Self.definition.settings.acls.filter(acl => acl.property !== 'create');
|
Self.definition.settings.acls = Self.definition.settings.acls.filter(acl => acl.property !== 'create');
|
||||||
|
@ -111,6 +112,18 @@ module.exports = function(Self) {
|
||||||
return email.send();
|
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;
|
const _setPassword = Self.prototype.setPassword;
|
||||||
Self.prototype.setPassword = async function(newPassword, options, cb) {
|
Self.prototype.setPassword = async function(newPassword, options, cb) {
|
||||||
if (cb === undefined && typeof options === 'function') {
|
if (cb === undefined && typeof options === 'function') {
|
||||||
|
@ -143,8 +156,9 @@ module.exports = function(Self) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Self.sharedClass._methods.find(method => method.name == 'changePassword')
|
Self.sharedClass._methods.find(method => method.name == 'changePassword').ctor.settings.acls =
|
||||||
.accessScopes = ['change-password'];
|
Self.sharedClass._methods.find(method => method.name == 'changePassword').ctor.settings.acls
|
||||||
|
.filter(acl => acl.property != 'changePassword');
|
||||||
|
|
||||||
// FIXME: https://redmine.verdnatura.es/issues/5761
|
// FIXME: https://redmine.verdnatura.es/issues/5761
|
||||||
// Self.afterRemote('prototype.patchAttributes', async(ctx, instance) => {
|
// Self.afterRemote('prototype.patchAttributes', async(ctx, instance) => {
|
||||||
|
|
|
@ -59,7 +59,10 @@
|
||||||
},
|
},
|
||||||
"passExpired": {
|
"passExpired": {
|
||||||
"type": "date"
|
"type": "date"
|
||||||
}
|
},
|
||||||
|
"twoFactor": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"relations": {
|
"relations": {
|
||||||
"role": {
|
"role": {
|
||||||
|
@ -111,6 +114,13 @@
|
||||||
"principalId": "$authenticated",
|
"principalId": "$authenticated",
|
||||||
"permission": "ALLOW"
|
"permission": "ALLOW"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"property": "validateAuth",
|
||||||
|
"accessType": "EXECUTE",
|
||||||
|
"principalType": "ROLE",
|
||||||
|
"principalId": "$everyone",
|
||||||
|
"permission": "ALLOW"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"property": "privileges",
|
"property": "privileges",
|
||||||
"accessType": "*",
|
"accessType": "*",
|
||||||
|
|
|
@ -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
|
||||||
|
);
|
|
@ -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 ;
|
|
@ -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';
|
|
@ -77,7 +77,10 @@ INSERT INTO `account`.`user`(`id`,`name`, `nickname`, `role`,`active`,`email`, `
|
||||||
ORDER BY id;
|
ORDER BY id;
|
||||||
|
|
||||||
INSERT INTO `account`.`account`(`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`)
|
INSERT INTO `vn`.`educationLevel` (`id`, `name`)
|
||||||
VALUES
|
VALUES
|
||||||
|
@ -2849,8 +2852,8 @@ INSERT INTO `vn`.`profileType` (`id`, `name`)
|
||||||
|
|
||||||
INSERT INTO `salix`.`url` (`appName`, `environment`, `url`)
|
INSERT INTO `salix`.`url` (`appName`, `environment`, `url`)
|
||||||
VALUES
|
VALUES
|
||||||
('lilium', 'dev', 'http://localhost:9000/#/'),
|
('lilium', 'development', 'http://localhost:9000/#/'),
|
||||||
('salix', 'dev', 'http://localhost:5000/#!/');
|
('salix', 'development', 'http://localhost:5000/#!/');
|
||||||
|
|
||||||
INSERT INTO `vn`.`report` (`id`, `name`, `paperSizeFk`, `method`)
|
INSERT INTO `vn`.`report` (`id`, `name`, `paperSizeFk`, `method`)
|
||||||
VALUES
|
VALUES
|
||||||
|
|
|
@ -16,6 +16,7 @@ describe('ChangePassword path', async() => {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const badPassword = 'badpass';
|
||||||
const oldPassword = 'nightmare';
|
const oldPassword = 'nightmare';
|
||||||
const newPassword = 'newPass.1234';
|
const newPassword = 'newPass.1234';
|
||||||
describe('Bad login', async() => {
|
describe('Bad login', async() => {
|
||||||
|
@ -37,13 +38,22 @@ describe('ChangePassword path', async() => {
|
||||||
expect(message.text).toContain('Invalid current password');
|
expect(message.text).toContain('Invalid current password');
|
||||||
|
|
||||||
// Bad attempt: password not meet requirements
|
// 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, {
|
message = await page.sendForm($.form, {
|
||||||
oldPassword: oldPassword,
|
oldPassword: oldPassword,
|
||||||
newPassword: oldPassword,
|
newPassword: oldPassword,
|
||||||
repeatPassword: 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
|
// Correct attempt: change password
|
||||||
message = await page.sendForm($.form, {
|
message = await page.sendForm($.form, {
|
||||||
|
|
|
@ -24,7 +24,7 @@ export default class Auth {
|
||||||
initialize() {
|
initialize() {
|
||||||
let criteria = {
|
let criteria = {
|
||||||
to: state => {
|
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);
|
return !outLayout.some(ol => ol == state.name);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -60,7 +60,25 @@ export default class Auth {
|
||||||
};
|
};
|
||||||
|
|
||||||
const now = new Date();
|
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));
|
.then(json => this.onLoginOk(json, now, remember));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,6 @@ export default class Token {
|
||||||
remember
|
remember
|
||||||
});
|
});
|
||||||
this.vnInterceptor.setToken(token);
|
this.vnInterceptor.setToken(token);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (remember)
|
if (remember)
|
||||||
this.setStorage(localStorage, token, created, ttl);
|
this.setStorage(localStorage, token, created, ttl);
|
||||||
|
|
|
@ -21,6 +21,14 @@
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="false">
|
autocomplete="false">
|
||||||
</vn-textfield>
|
</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">
|
<div class="footer">
|
||||||
<vn-submit label="Change password" ng-click="$ctrl.submit()"></vn-submit>
|
<vn-submit label="Change password" ng-click="$ctrl.submit()"></vn-submit>
|
||||||
<div class="spinner-wrapper">
|
<div class="spinner-wrapper">
|
||||||
|
|
|
@ -26,11 +26,13 @@ export default class Controller {
|
||||||
|
|
||||||
submit() {
|
submit() {
|
||||||
const userId = this.$state.params.userId;
|
const userId = this.$state.params.userId;
|
||||||
const newPassword = this.newPassword;
|
|
||||||
const oldPassword = this.oldPassword;
|
const oldPassword = this.oldPassword;
|
||||||
|
const newPassword = this.newPassword;
|
||||||
|
const repeatPassword = this.repeatPassword;
|
||||||
|
const code = this.code;
|
||||||
|
|
||||||
if (!newPassword)
|
if (!oldPassword || !newPassword || !repeatPassword)
|
||||||
throw new UserError(`You must enter a new password`);
|
throw new UserError(`You must fill all the fields`);
|
||||||
if (newPassword != this.repeatPassword)
|
if (newPassword != this.repeatPassword)
|
||||||
throw new UserError(`Passwords don't match`);
|
throw new UserError(`Passwords don't match`);
|
||||||
|
|
||||||
|
@ -38,11 +40,12 @@ export default class Controller {
|
||||||
Authorization: this.$state.params.id
|
Authorization: this.$state.params.id
|
||||||
};
|
};
|
||||||
|
|
||||||
this.$http.post('VnUsers/change-password',
|
this.$http.patch('Accounts/change-password',
|
||||||
{
|
{
|
||||||
id: userId,
|
id: userId,
|
||||||
oldPassword,
|
oldPassword,
|
||||||
newPassword
|
newPassword,
|
||||||
|
code
|
||||||
},
|
},
|
||||||
{headers}
|
{headers}
|
||||||
).then(() => {
|
).then(() => {
|
||||||
|
|
|
@ -2,6 +2,10 @@ Change password: Cambiar contraseña
|
||||||
Old password: Antigua contraseña
|
Old password: Antigua contraseña
|
||||||
New password: Nueva contraseña
|
New password: Nueva contraseña
|
||||||
Repeat password: Repetir 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 updated!: ¡Contraseña actualizada!
|
||||||
Password requirements: >
|
Password requirements: >
|
||||||
La contraseña debe tener al menos {{ length }} caracteres de longitud,
|
La contraseña debe tener al menos {{ length }} caracteres de longitud,
|
||||||
|
|
|
@ -9,6 +9,7 @@ import './login';
|
||||||
import './outLayout';
|
import './outLayout';
|
||||||
import './recover-password';
|
import './recover-password';
|
||||||
import './reset-password';
|
import './reset-password';
|
||||||
|
import './validate-email';
|
||||||
import './change-password';
|
import './change-password';
|
||||||
import './module-card';
|
import './module-card';
|
||||||
import './module-main';
|
import './module-main';
|
||||||
|
|
|
@ -24,13 +24,23 @@ export default class Controller {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
})
|
})
|
||||||
.catch(req => {
|
.catch(req => {
|
||||||
this.loading = false;
|
if (req?.data?.error?.code === 'REQUIRES_2FA') {
|
||||||
this.password = '';
|
this.outLayout.login = {
|
||||||
this.focusUser();
|
user: this.user,
|
||||||
|
password: this.password,
|
||||||
|
remember: this.remember
|
||||||
|
};
|
||||||
|
this.$state.go('validate-email');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const err = req.data?.error;
|
const err = req.data?.error;
|
||||||
if (err?.code == 'passExpired')
|
if (err?.code == 'passExpired')
|
||||||
this.$state.go('change-password', err.details.token);
|
this.$state.go('change-password', err.details.token);
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
this.password = '';
|
||||||
|
this.focusUser();
|
||||||
throw req;
|
throw req;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -44,5 +54,8 @@ Controller.$inject = ['$scope', '$element', '$state', 'vnAuth'];
|
||||||
|
|
||||||
ngModule.vnComponent('vnLogin', {
|
ngModule.vnComponent('vnLogin', {
|
||||||
template: require('./index.html'),
|
template: require('./index.html'),
|
||||||
controller: Controller
|
controller: Controller,
|
||||||
|
require: {
|
||||||
|
outLayout: '^vnOutLayout'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
|
@ -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'
|
||||||
|
}
|
||||||
|
});
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,9 +37,15 @@ function config($stateProvider, $urlRouterProvider) {
|
||||||
description: 'Reset password',
|
description: 'Reset password',
|
||||||
template: '<vn-reset-password></vn-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', {
|
.state('change-password', {
|
||||||
parent: 'outLayout',
|
parent: 'outLayout',
|
||||||
url: '/change-password?id&userId',
|
url: '/change-password?id&userId&twoFactor',
|
||||||
description: 'Change password',
|
description: 'Change password',
|
||||||
template: '<vn-change-password></vn-change-password>'
|
template: '<vn-change-password></vn-change-password>'
|
||||||
})
|
})
|
||||||
|
|
|
@ -39,7 +39,7 @@ module.exports = Self => {
|
||||||
return [html, 'text/html', `filename=${fileName}.pdf"`];
|
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 args = Object.assign({}, ctx.args);
|
||||||
const params = {
|
const params = {
|
||||||
recipient: args.recipient,
|
recipient: args.recipient,
|
||||||
|
@ -52,6 +52,6 @@ module.exports = Self => {
|
||||||
|
|
||||||
const email = new Email(templateName, params);
|
const email = new Email(templateName, params);
|
||||||
|
|
||||||
return email.send();
|
return email.send({force: force});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -176,5 +176,6 @@
|
||||||
"Failed to upload delivery note": "Error to upload delivery note {{id}}",
|
"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",
|
"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",
|
"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"
|
"Valid priorities": "Valid priorities: %d"
|
||||||
}
|
}
|
||||||
|
|
|
@ -276,6 +276,8 @@
|
||||||
"Insert a date range": "Inserte un rango de fechas",
|
"Insert a date range": "Inserte un rango de fechas",
|
||||||
"Added observation": "{{user}} añadió esta observacion: {{text}}",
|
"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": "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",
|
"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",
|
"company": "Compañía",
|
||||||
"country": "País",
|
"country": "País",
|
||||||
|
@ -293,6 +295,8 @@
|
||||||
"Invalid NIF for VIES": "Invalid NIF for VIES",
|
"Invalid NIF for VIES": "Invalid NIF for VIES",
|
||||||
"Ticket does not exist": "Este ticket no existe",
|
"Ticket does not exist": "Este ticket no existe",
|
||||||
"Ticket is already signed": "Este ticket ya ha sido firmado",
|
"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",
|
"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",
|
"Fecha fuera de rango": "Fecha fuera de rango",
|
||||||
"Error while generating PDF": "Error al generar PDF",
|
"Error while generating PDF": "Error al generar PDF",
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,15 +1,12 @@
|
||||||
|
const UserError = require('vn-loopback/util/user-error');
|
||||||
|
|
||||||
module.exports = Self => {
|
module.exports = Self => {
|
||||||
Self.remoteMethod('changePassword', {
|
Self.remoteMethodCtx('changePassword', {
|
||||||
description: 'Changes the user password',
|
description: 'Changes the user password',
|
||||||
accessType: 'WRITE',
|
accessType: 'WRITE',
|
||||||
|
accessScopes: ['changePassword'],
|
||||||
accepts: [
|
accepts: [
|
||||||
{
|
{
|
||||||
arg: 'id',
|
|
||||||
type: 'number',
|
|
||||||
description: 'The user id',
|
|
||||||
http: {source: 'path'}
|
|
||||||
}, {
|
|
||||||
arg: 'oldPassword',
|
arg: 'oldPassword',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'The old password',
|
description: 'The old password',
|
||||||
|
@ -19,15 +16,35 @@ module.exports = Self => {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'The new password',
|
description: 'The new password',
|
||||||
required: true
|
required: true
|
||||||
|
}, {
|
||||||
|
arg: 'code',
|
||||||
|
type: 'string',
|
||||||
|
description: 'The 2FA code'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
http: {
|
http: {
|
||||||
path: `/:id/changePassword`,
|
path: `/change-password`,
|
||||||
verb: 'PATCH'
|
verb: 'PATCH'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.changePassword = async function(id, oldPassword, newPassword) {
|
Self.changePassword = async function(ctx, oldPassword, newPassword, code, options) {
|
||||||
await Self.app.models.VnUser.changePassword(id, oldPassword, newPassword);
|
const userId = ctx.req.accessToken.userId;
|
||||||
|
|
||||||
|
const myOptions = {};
|
||||||
|
if (typeof options == 'object')
|
||||||
|
Object.assign(myOptions, options);
|
||||||
|
|
||||||
|
const {VnUser} = Self.app.models;
|
||||||
|
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);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
module.exports = Self => {
|
module.exports = Self => {
|
||||||
Self.remoteMethod('login', {
|
Self.remoteMethodCtx('login', {
|
||||||
description: 'Login a user with username/email and password',
|
description: 'Login a user with username/email and password',
|
||||||
accepts: [
|
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);
|
||||||
};
|
};
|
||||||
|
|
|
@ -21,7 +21,8 @@ module.exports = Self => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.setPassword = async function(id, newPassword) {
|
Self.setPassword = async function(id, newPassword, options) {
|
||||||
await Self.app.models.VnUser.setPassword(id, newPassword);
|
options = typeof options == 'object' ? options : {};
|
||||||
|
await Self.app.models.VnUser.setPassword(id, newPassword, options);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,22 +1,99 @@
|
||||||
const {models} = require('vn-loopback/server/server');
|
const {models} = require('vn-loopback/server/server');
|
||||||
|
|
||||||
describe('account changePassword()', () => {
|
describe('account changePassword()', () => {
|
||||||
it('should throw an error when old password is wrong', async() => {
|
const ctx = {req: {accessToken: {userId: 70}}};
|
||||||
let error;
|
const unauthCtx = {
|
||||||
try {
|
req: {
|
||||||
await models.Account.changePassword(1, 'wrongPassword', 'nightmare.9999');
|
headers: {},
|
||||||
} catch (e) {
|
connection: {
|
||||||
error = e.message;
|
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() => {
|
describe('With 2FA', () => {
|
||||||
try {
|
it('should change password when code is correct', async() => {
|
||||||
await models.Account.changePassword(70, 'nightmare', 'nightmare.9999');
|
const tx = await models.Account.beginTransaction({});
|
||||||
} catch (e) {
|
const yesterday = Date.vnNew();
|
||||||
expect(e).toBeUndefined();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,8 +8,18 @@ describe('Account setPassword()', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update password when it passes requirements', async() => {
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -37,6 +37,13 @@
|
||||||
"principalType": "ROLE",
|
"principalType": "ROLE",
|
||||||
"principalId": "$authenticated",
|
"principalId": "$authenticated",
|
||||||
"permission": "ALLOW"
|
"permission": "ALLOW"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"property": "changePassword",
|
||||||
|
"accessType": "EXECUTE",
|
||||||
|
"principalType": "ROLE",
|
||||||
|
"principalId": "$everyone",
|
||||||
|
"permission": "ALLOW"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@ module.exports = Self => {
|
||||||
const isAccount = await models.Account.findById(id);
|
const isAccount = await models.Account.findById(id);
|
||||||
|
|
||||||
if (isClient && !isAccount)
|
if (isClient && !isAccount)
|
||||||
await models.Account.setPassword(id, newPassword);
|
await models.VnUser.setPassword(id, newPassword);
|
||||||
else
|
else
|
||||||
throw new UserError(`Modifiable password only via recovery or by an administrator`);
|
throw new UserError(`Modifiable password only via recovery or by an administrator`);
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "salix-back",
|
"name": "salix-back",
|
||||||
"version": "23.24.01",
|
"version": "23.26.01",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.2.2",
|
"axios": "^1.2.2",
|
||||||
|
|
|
@ -84,7 +84,8 @@ class Email extends Component {
|
||||||
replyTo: this.args.replyTo || '',
|
replyTo: this.args.replyTo || '',
|
||||||
subject: localeSubject,
|
subject: localeSubject,
|
||||||
html: rendered,
|
html: rendered,
|
||||||
attachments: attachments
|
attachments: attachments,
|
||||||
|
force: options.force
|
||||||
};
|
};
|
||||||
|
|
||||||
return smtp.send(mailOptions);
|
return smtp.send(mailOptions);
|
||||||
|
|
|
@ -10,16 +10,17 @@ module.exports = {
|
||||||
|
|
||||||
async send(options) {
|
async send(options) {
|
||||||
options.from = `${config.app.senderName} <${config.app.senderEmail}>`;
|
options.from = `${config.app.senderName} <${config.app.senderEmail}>`;
|
||||||
|
if (!process.env.NODE_ENV)
|
||||||
|
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'};
|
const notProductionError = {message: 'This not production, this email not sended'};
|
||||||
await this.mailLog(options, notProductionError);
|
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 res;
|
||||||
let error;
|
let error;
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
.code {
|
||||||
|
border: 2px dashed #8dba25;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-align: center
|
||||||
|
}
|
|
@ -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>
|
||||||
|
</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>
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
|
@ -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
|
|
@ -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
|
|
@ -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.
|
|
@ -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.
|
Loading…
Reference in New Issue