refs #5475 feat(signin): use twoFactor and passExpired
gitea/salix/pipeline/head There was a failure building this commit Details

This commit is contained in:
Alex Moreno 2023-06-14 15:15:00 +02:00
parent aeb04efe82
commit 3a5e591cf0
21 changed files with 158 additions and 141 deletions

View File

@ -1,4 +1,5 @@
const ForbiddenError = require('vn-loopback/util/forbiddenError'); const ForbiddenError = require('vn-loopback/util/forbiddenError');
const UserError = require('vn-loopback/util/user-error');
module.exports = Self => { module.exports = Self => {
Self.remoteMethodCtx('signin', { Self.remoteMethodCtx('signin', {
@ -26,7 +27,6 @@ module.exports = Self => {
}); });
Self.signin = async function(ctx, user, password) { Self.signin = async function(ctx, user, password) {
const $ = Self.app.models;
const usesEmail = user.indexOf('@') !== -1; const usesEmail = user.indexOf('@') !== -1;
const where = usesEmail const where = usesEmail
@ -34,30 +34,70 @@ module.exports = Self => {
: {name: user}; : {name: user};
const vnUser = await Self.findOne({ const vnUser = await Self.findOne({
fields: ['id', 'active', 'email', 'password', 'twoFactor'], fields: ['id', 'name', 'password', 'active', 'email', 'passExpired', 'twoFactor'],
where where
}); });
if (vnUser && vnUser.twoFactor === 'email') { const validCredentials = vnUser
const code = String(Math.floor(Math.random() * 999999)); && await vnUser.hasPassword(password);
const maxTTL = ((60 * 1000) * 5); // 5 min
await $.AuthCode.upsertWithWhere({userFk: vnUser.id}, {
userFk: vnUser.id,
code: code,
expires: Date.now() + maxTTL
});
const params = { if (validCredentials) {
recipientId: vnUser.id, if (!vnUser.active)
recipient: vnUser.email, throw new UserError('User disabled');
code: code await Self.sendTwoFactor(ctx, vnUser);
}; await Self.passExpired(vnUser);
ctx.args = {...ctx.args, ...params};
await Self.sendTemplate(ctx, 'auth-code');
if (vnUser.twoFactor)
throw new ForbiddenError('REQUIRES_2FA'); throw new ForbiddenError('REQUIRES_2FA');
} }
return Self.validateLogin(user, password); return Self.validateLogin(user, password);
}; };
Self.passExpired = async vnUser => {
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: ['change-password'],
userId: vnUser.id
});
throw new UserError('Pass expired', 'passExpired', {
id: vnUser.id,
token: changePasswordToken.id,
twoFactor: vnUser.twoFactor ? true : false
});
}
};
Self.sendTwoFactor = async(ctx, vnUser) => {
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
});
const headers = ctx.req.headers;
let platform = headers['sec-ch-ua-platform']?.replace(/['"=]+/g, '');
let 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');
}
};
}; };

View File

@ -1,7 +1,7 @@
const UserError = require('vn-loopback/util/user-error'); const UserError = require('vn-loopback/util/user-error');
module.exports = Self => { module.exports = Self => {
Self.remoteMethodCtx('validateAuth', { Self.remoteMethod('validateAuth', {
description: 'Login a user with username/email and password', description: 'Login a user with username/email and password',
accepts: [ accepts: [
{ {
@ -31,8 +31,13 @@ module.exports = Self => {
} }
}); });
Self.validateAuth = async function(ctx, username, password, code) { Self.validateAuth = async function(username, password, code) {
const {AuthCode, UserAccess} = Self.app.models; await Self.validateCode(code);
return Self.validateLogin(username, password);
};
Self.validateCode = async(username, code) => {
const {AuthCode} = Self.app.models;
const authCode = await AuthCode.findOne({ const authCode = await AuthCode.findOne({
where: { where: {
@ -47,27 +52,10 @@ module.exports = Self => {
const user = await Self.findById(authCode.userFk, { const user = await Self.findById(authCode.userFk, {
fields: ['name', 'twoFactor'] fields: ['name', 'twoFactor']
}); });
console.log(username, code);
if (user.name !== username) if (user.name !== username)
throw new UserError('Authentication failed'); throw new UserError('Authentication failed');
const headers = ctx.req.headers;
let platform = headers['sec-ch-ua-platform'];
let browser = headers['sec-ch-ua'];
if (platform) platform = platform.replace(/['"]+/g, '');
if (browser) browser = browser.split(';')[0].replace(/['"]+/g, '');
await UserAccess.upsertWithWhere({userFk: authCode.userFk}, {
userFk: authCode.userFk,
ip: ctx.req.connection.remoteAddress,
agent: headers['user-agent'],
platform: platform,
browser: browser
});
await authCode.destroy(); await authCode.destroy();
return Self.validateLogin(username, password);
}; };
}; };

View File

@ -116,9 +116,6 @@
"Town": { "Town": {
"dataSource": "vn" "dataSource": "vn"
}, },
"UserAccess": {
"dataSource": "vn"
},
"Url": { "Url": {
"dataSource": "vn" "dataSource": "vn"
}, },

View File

@ -1,36 +0,0 @@
{
"name": "UserAccess",
"base": "VnModel",
"options": {
"mysql": {
"table": "salix.userAccess"
}
},
"properties": {
"userFk": {
"type": "number",
"required": true,
"id": true
},
"ip": {
"type": "string",
"required": true
},
"agent": {
"type": "string"
},
"platform": {
"type": "string"
},
"browser": {
"type": "string"
}
},
"relations": {
"user": {
"type": "belongsTo",
"model": "Account",
"foreignKey": "userFk"
}
}
}

View File

@ -1,6 +1,7 @@
const vnModel = require('vn-loopback/common/models/vn-model'); const vnModel = require('vn-loopback/common/models/vn-model');
const LoopBackContext = require('loopback-context'); const LoopBackContext = require('loopback-context');
const {Email} = require('vn-print'); const {Email} = require('vn-print');
const UserError = require('vn-loopback/util/user-error');
module.exports = function(Self) { module.exports = function(Self) {
vnModel(Self); vnModel(Self);
@ -110,53 +111,11 @@ module.exports = function(Self) {
}); });
Self.validateLogin = async function(user, password) { Self.validateLogin = async function(user, password) {
const models = Self.app.models;
const usesEmail = user.indexOf('@') !== -1; const usesEmail = user.indexOf('@') !== -1;
let token;
const userInfo = usesEmail const userInfo = usesEmail
? {email: user} ? {email: user}
: {username: 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
});
throw new UserError('Pass expired', 'passExpired', {
id: vnUser.id,
token: changePasswordToken.id
});
}
try {
await models.Account.sync(instance.username, password);
} catch (err) {
console.warn(err);
}
}
let loginInfo = Object.assign({password}, userInfo); let loginInfo = Object.assign({password}, userInfo);
token = await Self.login(loginInfo, 'user'); token = await Self.login(loginInfo, 'user');
@ -197,6 +156,50 @@ module.exports = function(Self) {
Self.sharedClass._methods.find(method => method.name == 'changePassword') Self.sharedClass._methods.find(method => method.name == 'changePassword')
.accessScopes = ['change-password']; .accessScopes = ['change-password'];
Self.sharedClass._methods.find(method => method.name == 'changePassword').accepts.splice(3, 0, {
arg: 'verificationCode',
type: 'string'
});
const _changePassword = Self.changePassword;
Self.changePassword = async(userId, oldPassword, newPassword, verificationCode, options, cb) => {
if (oldPassword == newPassword)
throw new UserError(`You can't use the same password`);
const user = await this.findById(userId, {fields: ['name', 'twoFactor']});
if (user.twoFactor)
await Self.validateCode(user.name, verificationCode);
await _changePassword.call(this, userId, oldPassword, newPassword, options, cb);
};
const _prototypeChangePassword = Self.prototype.ChangePassword;
Self.prototype.changePassword = async function(oldPassword, newPassword, options, cb) {
if (cb === undefined && typeof options === 'function') {
cb = options;
options = undefined;
}
const myOptions = {};
let tx;
if (typeof options == 'object')
Object.assign(myOptions, options);
if (!myOptions.transaction) {
tx = await Self.beginTransaction({});
myOptions.transaction = tx;
}
options = myOptions;
try {
await _prototypeChangePassword.call(this, oldPassword, newPassword, options);
tx && await tx.commit();
cb && cb();
} catch (err) {
tx && await tx.rollback();
if (cb) cb(err); else throw err;
}
};
// 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) => {

View File

@ -11,17 +11,3 @@ create table `salix`.`authCode`
foreign key (userFk) references `account`.`user` (id) foreign key (userFk) references `account`.`user` (id)
on update cascade on delete cascade on update cascade on delete cascade
); );
create table `salix`.`userAccess`
(
userFk int UNSIGNED not null,
ip VARCHAR(25) not null,
agent text null,
platform VARCHAR(25) null,
browser VARCHAR(25) null,
constraint userAccess_pk
primary key (userFk),
constraint userAccess_user_null_fk
foreign key (userFk) references `account`.`user` (id)
)
auto_increment = 0;

View File

@ -2821,8 +2821,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

View File

@ -21,6 +21,14 @@
type="password" type="password"
autocomplete="false"> autocomplete="false">
</vn-textfield> </vn-textfield>
<vn-textfield
ng-if="$ctrl.$state.params.twoFactor"
label="Verification code"
ng-model="$ctrl.verificationCode"
vn-name="verificationCode"
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">

View File

@ -26,23 +26,33 @@ export default class Controller {
submit() { submit() {
const id = this.$state.params.id; const id = this.$state.params.id;
const newPassword = this.newPassword;
const oldPassword = this.oldPassword; const oldPassword = this.oldPassword;
const newPassword = this.newPassword;
const repeatPassword = this.repeatPassword;
const verificationCode = this.verificationCode;
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`);
if (newPassword == oldPassword)
throw new UserError(`You can't use the same password`);
const headers = { const headers = {
Authorization: this.$state.params.token Authorization: this.$state.params.token
}; };
console.log({
id,
oldPassword,
newPassword,
verificationCode
});
this.$http.post('VnUsers/change-password', this.$http.post('VnUsers/change-password',
{ {
id, id,
oldPassword, oldPassword,
newPassword newPassword,
verificationCode
}, },
{headers} {headers}
).then(() => { ).then(() => {

View File

@ -2,6 +2,9 @@ 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't use the same password: No puedes usar la misma contraseña
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,

View File

@ -44,7 +44,7 @@ function config($stateProvider, $urlRouterProvider) {
}) })
.state('change-password', { .state('change-password', {
parent: 'outLayout', parent: 'outLayout',
url: '/change-password?id&token', url: '/change-password?id&token&twoFactor',
description: 'Change password', description: 'Change password',
template: '<vn-change-password></vn-change-password>' template: '<vn-change-password></vn-change-password>'
}) })

View File

@ -295,5 +295,7 @@
"Pass expired": "La contraseña ha caducado, cambiela desde Salix", "Pass expired": "La contraseña ha caducado, cambiela desde Salix",
"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"
} }

View File

@ -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.validateLogin(user, password); Self.login = async(ctx, user, password) => Self.app.models.VnUser.signin(ctx, user, password);
}; };

View File

@ -3,6 +3,8 @@
<div class="grid-block vn-pa-ml"> <div class="grid-block vn-pa-ml">
<h1>{{ $t('title') }}</h1> <h1>{{ $t('title') }}</h1>
<p v-html="$t('description')"></p> <p v-html="$t('description')"></p>
<p v-html="$t('device', [device])"></p>
<p v-html="$t('ip', [ip])"></p>
</div> </div>
</div> </div>
<div class="grid-row"> <div class="grid-row">

View File

@ -10,6 +10,12 @@ module.exports = {
code: { code: {
type: String, type: String,
required: true required: true
},
device: {
type: String
},
ip: {
type: Number
} }
} }
}; };

View File

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

View File

@ -1,5 +1,7 @@
subject: Código de verificación subject: Código de verificación
title: 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. 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: <strong>{0}</strong>'
ip: 'IP: <strong>{0}</strong>'
Enter the following code to continue to your account: Introduce el siguiente código para poder continuar con tu cuenta 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 It expires in 5 minutes: Expira en 5 minutos

View File

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

View File

@ -1,5 +1,7 @@
subject: Código de verificação subject: Código de verificação
title: 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. 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: <strong>{0}</strong>'
ip: 'IP: <strong>{0}</strong>'
Enter the following code to continue to your account: Insira o seguinte código para continuar com sua conta. 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. It expires in 5 minutes: Expira em 5 minutos.