aloux-iam 0.0.139 → 0.0.141
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/config/utils.js +75 -18
- package/lib/controllers/business.js +65 -0
- package/lib/controllers/company.js +35 -0
- package/lib/controllers/generatePassword.js +13 -0
- package/lib/controllers/log.js +102 -22
- package/lib/controllers/totp.js +46 -0
- package/lib/controllers/user.js +25 -43
- package/lib/router.js +15 -0
- package/lib/services/auth.js +37 -49
- package/lib/services/generatePassword.js +44 -0
- package/lib/services/totp.js +169 -0
- package/lib/swagger.yaml +209 -1
- package/package.json +4 -2
package/lib/config/utils.js
CHANGED
|
@@ -1,22 +1,79 @@
|
|
|
1
|
-
const
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const self = module.exports;
|
|
2
3
|
|
|
3
4
|
self.responseError = async (res, error) => {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
5
|
+
let obj = error;
|
|
6
|
+
if (!error.code) {
|
|
7
|
+
obj = {
|
|
8
|
+
code: 400,
|
|
9
|
+
title: "Error",
|
|
10
|
+
detail: error.message,
|
|
11
|
+
suggestion: "Revisar el detalle",
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
res.status(obj.code).send(obj);
|
|
15
|
+
};
|
|
15
16
|
|
|
16
17
|
self.generatePaginationResponse = async (count, page, itemsPerPage, items) => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
18
|
+
const totalPages = Math.ceil(count / itemsPerPage);
|
|
19
|
+
const currentPage = Math.max(1, Math.min(Number(page), totalPages));
|
|
20
|
+
const finalCurrentPage = totalPages === 0 ? 1 : currentPage;
|
|
21
|
+
const remainingPages = Math.max(0, totalPages - finalCurrentPage);
|
|
22
|
+
return { currentPage: finalCurrentPage, totalPages, perPage: Number(itemsPerPage), count, remainingPages, items };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
self.brand = {
|
|
26
|
+
_cfg: null,
|
|
27
|
+
|
|
28
|
+
init(config) {
|
|
29
|
+
this._cfg = config;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
_get(key) {
|
|
33
|
+
if (this._cfg) return this._cfg[key];
|
|
34
|
+
const APP = process.env.APP;
|
|
35
|
+
if (APP) return process.env[`${key}_${APP}`] || process.env[key];
|
|
36
|
+
return process.env[key];
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
name() {
|
|
40
|
+
return this._get("PROJECT_NAME");
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
template(key) {
|
|
44
|
+
const path = this._get(key);
|
|
45
|
+
if (!path) {
|
|
46
|
+
throw {
|
|
47
|
+
code: 500,
|
|
48
|
+
title: "Error de configuración",
|
|
49
|
+
detail: `Template no encontrado: ${key}`,
|
|
50
|
+
suggestion: "Verifica que la variable de entorno esté definida",
|
|
51
|
+
error: new Error(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
let file = fs.readFileSync(path, "utf8");
|
|
56
|
+
file = file.replace(/\+\+\+brandName\+\+\+/g, this.name() || "");
|
|
57
|
+
file = file.replace(/\+\+\+brandColor\+\+\+/g, this._get("BRAND_COLOR") || "");
|
|
58
|
+
file = file.replace(/\+\+\+brandLogo\+\+\+/g, this._get("BRAND_LOGO") || "");
|
|
59
|
+
return file;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
throw {
|
|
62
|
+
code: 500,
|
|
63
|
+
title: "Error al leer template",
|
|
64
|
+
detail: `No se pudo leer el archivo: ${path}`,
|
|
65
|
+
suggestion: "Verifica que el path del template sea correcto",
|
|
66
|
+
error: e,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Gkey
|
|
73
|
+
self.resolveGkey = (business) => {
|
|
74
|
+
const businessGkey = business.gkey || null;
|
|
75
|
+
const hasOwnKey = businessGkey?.status === true;
|
|
76
|
+
|
|
77
|
+
if (hasOwnKey) return { gkey: businessGkey, source: "business" };
|
|
78
|
+
return { gkey: null, source: null };
|
|
79
|
+
};
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
const Business = require("../models/Business");
|
|
2
|
+
const Company = require("../models/Company");
|
|
2
3
|
const utils = require("../config/utils");
|
|
3
4
|
const AlouxAWS = require("./operationsAWS");
|
|
4
5
|
const errorController = require("../config/utils");
|
|
6
|
+
const { resolveGkey } = require("../config/utils");
|
|
5
7
|
|
|
6
8
|
const self = module.exports;
|
|
7
9
|
|
|
@@ -11,16 +13,26 @@ self.create = async (req, res) => {
|
|
|
11
13
|
req.body.lastUpdate = req.body.createdAt;
|
|
12
14
|
req.body.status = "Activo";
|
|
13
15
|
|
|
16
|
+
let inheritedGkey = null;
|
|
17
|
+
if (req.body.data?.useCompanyKey && req.body._company) {
|
|
18
|
+
const company = await Company.findOne({ _id: req.body._company }).lean();
|
|
19
|
+
const companyGkey = company?.data?.gkey;
|
|
20
|
+
if (companyGkey?.status) inheritedGkey = companyGkey;
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
if (req.body.environment && req.body.environment.length > 1) {
|
|
15
24
|
for (let i in req.body.environment) {
|
|
16
25
|
const business = new Business(req.body);
|
|
17
26
|
business.environment = req.body.environment[i];
|
|
27
|
+
if (inheritedGkey) business.gkey = inheritedGkey;
|
|
18
28
|
await business.save();
|
|
19
29
|
}
|
|
20
30
|
} else {
|
|
21
31
|
const business = new Business(req.body);
|
|
32
|
+
if (inheritedGkey) business.gkey = inheritedGkey;
|
|
22
33
|
await business.save();
|
|
23
34
|
}
|
|
35
|
+
|
|
24
36
|
await res.status(201).send({});
|
|
25
37
|
} catch (error) {
|
|
26
38
|
await errorController.responseError(res, error);
|
|
@@ -250,3 +262,56 @@ self.identity = async (req, res) => {
|
|
|
250
262
|
await errorController.responseError(res, error);
|
|
251
263
|
}
|
|
252
264
|
};
|
|
265
|
+
|
|
266
|
+
self.setUseCompanyKey = async (req, res) => {
|
|
267
|
+
try {
|
|
268
|
+
const business = await Business.findOne({ _id: req.params.BUSINESS_ID }).lean();
|
|
269
|
+
if (!business) throw { code: 404, title: "No encontrado", detail: "No existe el negocio" };
|
|
270
|
+
|
|
271
|
+
await Business.updateOne(
|
|
272
|
+
{ _id: req.params.BUSINESS_ID },
|
|
273
|
+
{
|
|
274
|
+
$unset: { gkey: "" },
|
|
275
|
+
$set: {
|
|
276
|
+
"data.useCompanyKey": false,
|
|
277
|
+
lastUpdate: new Date().getTime(),
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
res.status(202).send({ ok: true });
|
|
283
|
+
} catch (error) {
|
|
284
|
+
await errorController.responseError(res, error);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
self.inheritKey = async (req, res) => {
|
|
289
|
+
try {
|
|
290
|
+
const business = await Business.findOne({ _id: req.params.BUSINESS_ID })
|
|
291
|
+
.populate("_company")
|
|
292
|
+
.lean();
|
|
293
|
+
if (!business) throw { code: 404, title: "No encontrado", detail: "No existe el negocio" };
|
|
294
|
+
|
|
295
|
+
const companyGkey = business._company?.data?.gkey;
|
|
296
|
+
if (!companyGkey?.status) throw {
|
|
297
|
+
code: 400,
|
|
298
|
+
title: "Sin llave",
|
|
299
|
+
detail: "La organización no tiene una llave de Google Cloud configurada",
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
await Business.updateOne(
|
|
303
|
+
{ _id: req.params.BUSINESS_ID },
|
|
304
|
+
{
|
|
305
|
+
$set: {
|
|
306
|
+
gkey: companyGkey,
|
|
307
|
+
"data.useCompanyKey": true,
|
|
308
|
+
lastUpdate: new Date().getTime(),
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
res.status(202).send({ ok: true });
|
|
314
|
+
} catch (error) {
|
|
315
|
+
await errorController.responseError(res, error);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
@@ -153,3 +153,38 @@ self.identity = async (req, res) => {
|
|
|
153
153
|
await errorController.responseError(res, error);
|
|
154
154
|
}
|
|
155
155
|
};
|
|
156
|
+
|
|
157
|
+
self.updateGkey = async (req, res) => {
|
|
158
|
+
try {
|
|
159
|
+
const company = await Company.findOne({ _id: req.params.COMPANY_ID });
|
|
160
|
+
if (!company) throw { code: 404, title: "No encontrado", detail: "No existe la organización" };
|
|
161
|
+
|
|
162
|
+
company.data = { ...(company.data || {}), gkey: req.body.gkey };
|
|
163
|
+
company.lastUpdate = new Date().getTime();
|
|
164
|
+
company.markModified("data");
|
|
165
|
+
await company.save();
|
|
166
|
+
|
|
167
|
+
res.status(202).send({ ok: true });
|
|
168
|
+
} catch (error) {
|
|
169
|
+
await errorController.responseError(res, error);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
self.deleteGkey = async (req, res) => {
|
|
174
|
+
try {
|
|
175
|
+
const company = await Company.findOne({ _id: req.params.COMPANY_ID });
|
|
176
|
+
if (!company) throw { code: 404, title: "No encontrado", detail: "No existe la organización" };
|
|
177
|
+
|
|
178
|
+
const newData = { ...(company.data || {}) };
|
|
179
|
+
delete newData.gkey;
|
|
180
|
+
|
|
181
|
+
company.data = newData;
|
|
182
|
+
company.lastUpdate = new Date().getTime();
|
|
183
|
+
company.markModified("data");
|
|
184
|
+
await company.save();
|
|
185
|
+
|
|
186
|
+
res.status(200).send({ ok: true });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
await errorController.responseError(res, error);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const passwordService = require("../services/generatePassword");
|
|
2
|
+
|
|
3
|
+
const self = module.exports;
|
|
4
|
+
|
|
5
|
+
self.generate = (req, res) => {
|
|
6
|
+
try {
|
|
7
|
+
const length = Number(req.query.length) || 12;
|
|
8
|
+
const password = passwordService.generatePassword(length);
|
|
9
|
+
return res.json({ password });
|
|
10
|
+
} catch (error) {
|
|
11
|
+
return res.status(500).json({ message: "Error al generar la contraseña", error });
|
|
12
|
+
}
|
|
13
|
+
};
|
package/lib/controllers/log.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const Log = require("../models/Log");
|
|
2
2
|
const Label = require("../models/Label");
|
|
3
|
+
const mongoose = require("mongoose");
|
|
3
4
|
const self = module.exports;
|
|
4
5
|
|
|
5
6
|
self.create = async (req, res) => {
|
|
@@ -33,6 +34,7 @@ self.update = async (req, resp) => {
|
|
|
33
34
|
resp.status(400).send({ error: error.message });
|
|
34
35
|
}
|
|
35
36
|
};
|
|
37
|
+
|
|
36
38
|
self.status = async (req, resp) => {
|
|
37
39
|
try {
|
|
38
40
|
const _id = req.params.LOG_ID;
|
|
@@ -46,22 +48,79 @@ self.status = async (req, resp) => {
|
|
|
46
48
|
resp.status(400).send({ error: error.message });
|
|
47
49
|
}
|
|
48
50
|
};
|
|
51
|
+
|
|
49
52
|
self.retrieve = async (req, res) => {
|
|
50
53
|
try {
|
|
51
54
|
const companyId =
|
|
52
55
|
req.header("Company") !== "undefined" ? req.header("Company") : null;
|
|
53
|
-
let query = { _company: companyId };
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
const matchStage = { _company: companyId };
|
|
58
|
+
|
|
59
|
+
if (req.body.users?.length) {
|
|
60
|
+
matchStage._user = {
|
|
61
|
+
$in: req.body.users.map((id) => new mongoose.Types.ObjectId(id)),
|
|
58
62
|
};
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
|
|
62
|
-
.
|
|
63
|
-
.
|
|
64
|
-
|
|
65
|
+
if (req.body.dateStart || req.body.dateEnd) {
|
|
66
|
+
matchStage.createdAt = {};
|
|
67
|
+
if (req.body.dateStart)
|
|
68
|
+
matchStage.createdAt.$gte = Number(req.body.dateStart);
|
|
69
|
+
if (req.body.dateEnd)
|
|
70
|
+
matchStage.createdAt.$lte = Number(req.body.dateEnd);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const [totalCount, byLabel, byDate, byUser] = await Promise.all([
|
|
74
|
+
Log.countDocuments(matchStage),
|
|
75
|
+
|
|
76
|
+
Log.aggregate([
|
|
77
|
+
{ $match: matchStage },
|
|
78
|
+
{ $group: { _id: "$label", count: { $sum: 1 } } },
|
|
79
|
+
{ $sort: { count: -1 } },
|
|
80
|
+
]),
|
|
81
|
+
|
|
82
|
+
Log.aggregate([
|
|
83
|
+
{ $match: matchStage },
|
|
84
|
+
{
|
|
85
|
+
$group: {
|
|
86
|
+
_id: {
|
|
87
|
+
$dateToString: {
|
|
88
|
+
format: "%Y-%m-%d",
|
|
89
|
+
date: { $toDate: "$createdAt" },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
count: { $sum: 1 },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{ $sort: { _id: 1 } },
|
|
96
|
+
]),
|
|
97
|
+
|
|
98
|
+
Log.aggregate([
|
|
99
|
+
{ $match: matchStage },
|
|
100
|
+
{ $group: { _id: "$_user", count: { $sum: 1 } } },
|
|
101
|
+
{
|
|
102
|
+
$lookup: {
|
|
103
|
+
from: "users",
|
|
104
|
+
localField: "_id",
|
|
105
|
+
foreignField: "_id",
|
|
106
|
+
as: "user",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{ $unwind: "$user" },
|
|
110
|
+
{
|
|
111
|
+
$project: {
|
|
112
|
+
name: { $concat: ["$user.name", " ", "$user.lastName"] },
|
|
113
|
+
count: 1,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{ $sort: { count: -1 } },
|
|
117
|
+
]),
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const topUsers = byUser.slice(0, 10);
|
|
121
|
+
const leastUsers = [...byUser]
|
|
122
|
+
.sort((a, b) => a.count - b.count)
|
|
123
|
+
.slice(0, 10);
|
|
65
124
|
|
|
66
125
|
// for (let i in consulta) {
|
|
67
126
|
// consulta[i].label = consulta[i]._label.label;
|
|
@@ -69,14 +128,36 @@ self.retrieve = async (req, res) => {
|
|
|
69
128
|
// }
|
|
70
129
|
|
|
71
130
|
const response = {
|
|
72
|
-
dataset0: { field: "Visualizaciones totales", count:
|
|
73
|
-
dataset1:
|
|
74
|
-
dataset2:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
131
|
+
dataset0: { field: "Visualizaciones totales", count: totalCount },
|
|
132
|
+
dataset1: [],
|
|
133
|
+
dataset2: {
|
|
134
|
+
field: "Distribución de acciones",
|
|
135
|
+
counts: byLabel.map((i) => i.count),
|
|
136
|
+
operations: byLabel.map((i) => i._id),
|
|
137
|
+
},
|
|
138
|
+
dataset3: {
|
|
139
|
+
field: "Distribución de acciones",
|
|
140
|
+
items: byLabel.map((i) => ({
|
|
141
|
+
addGroup: i._id,
|
|
142
|
+
totalResponse: i.count,
|
|
143
|
+
})),
|
|
144
|
+
},
|
|
145
|
+
dataset4: {
|
|
146
|
+
field: "Actividad en la plataforma",
|
|
147
|
+
counts: byDate.map((i) => i.count),
|
|
148
|
+
actionsName: byDate.map((i) => formatDate(i._id)),
|
|
149
|
+
},
|
|
150
|
+
dataset5: [],
|
|
151
|
+
dataset6: {
|
|
152
|
+
field: "Usuarios con mas actividad en la plataforma",
|
|
153
|
+
counts: topUsers.map((i) => i.count),
|
|
154
|
+
actionsName: topUsers.map((i) => i.name.split(" ")),
|
|
155
|
+
},
|
|
156
|
+
dataset7: {
|
|
157
|
+
field: "Usuarios con menos actividad en la plataforma",
|
|
158
|
+
counts: leastUsers.map((i) => i.count),
|
|
159
|
+
actionsName: leastUsers.map((i) => i.name.split(" ")),
|
|
160
|
+
},
|
|
80
161
|
};
|
|
81
162
|
|
|
82
163
|
res.status(200).send(response);
|
|
@@ -86,8 +167,8 @@ self.retrieve = async (req, res) => {
|
|
|
86
167
|
}
|
|
87
168
|
};
|
|
88
169
|
|
|
89
|
-
function formatDate(
|
|
90
|
-
const day =
|
|
170
|
+
function formatDate(isoDate) {
|
|
171
|
+
const [year, month, day] = isoDate.split("-");
|
|
91
172
|
const monthNames = [
|
|
92
173
|
"Ene",
|
|
93
174
|
"Feb",
|
|
@@ -102,10 +183,9 @@ function formatDate(date) {
|
|
|
102
183
|
"Nov",
|
|
103
184
|
"Dic",
|
|
104
185
|
];
|
|
105
|
-
|
|
106
|
-
const year = date.getFullYear();
|
|
107
|
-
return `${day} ${month} ${year}`;
|
|
186
|
+
return `${day} ${monthNames[parseInt(month) - 1]} ${year}`;
|
|
108
187
|
}
|
|
188
|
+
|
|
109
189
|
function processDataset1(consulta) {
|
|
110
190
|
return consulta.map((item) => {
|
|
111
191
|
return {
|
|
@@ -277,4 +357,4 @@ self.count = async (req, res) => {
|
|
|
277
357
|
} catch (error) {
|
|
278
358
|
res.status(400).send({ error: error.message });
|
|
279
359
|
}
|
|
280
|
-
};
|
|
360
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const Totp = require("../services/totp");
|
|
2
|
+
const utils = require("../config/utils");
|
|
3
|
+
|
|
4
|
+
const self = module.exports;
|
|
5
|
+
|
|
6
|
+
self.setup = async (req, res) => {
|
|
7
|
+
try {
|
|
8
|
+
const tempToken = req.query.tempToken || req.body?.tempToken;
|
|
9
|
+
const response = await Totp.setup(tempToken);
|
|
10
|
+
res.status(200).send(response);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
await utils.responseError(res, error);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
self.activate = async (req, res) => {
|
|
17
|
+
try {
|
|
18
|
+
const tempToken = req.headers["x-temp-token"] || req.body?.tempToken;
|
|
19
|
+
const response = await Totp.activate(tempToken, req.body.token);
|
|
20
|
+
res.status(200).send(response);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
await utils.responseError(res, error);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
self.checkLogin = async (req, res) => {
|
|
27
|
+
try {
|
|
28
|
+
const { token, tempToken } = req.body;
|
|
29
|
+
if (!token || !tempToken)
|
|
30
|
+
return res.status(400).send({ message: "Faltan parámetros" });
|
|
31
|
+
|
|
32
|
+
const response = await Totp.checkLogin(token, tempToken, res);
|
|
33
|
+
res.status(200).send(response);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
await utils.responseError(res, error);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
self.adminToggle = async (req, res) => {
|
|
40
|
+
try {
|
|
41
|
+
const response = await Totp.adminToggle(req.params.USER_ID, req.body.enabled);
|
|
42
|
+
res.status(200).send(response);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
await utils.responseError(res, error);
|
|
45
|
+
}
|
|
46
|
+
};
|
package/lib/controllers/user.js
CHANGED
|
@@ -6,23 +6,25 @@ const jwt = require("jsonwebtoken");
|
|
|
6
6
|
const dayjs = require("dayjs");
|
|
7
7
|
const serviceUser = require("../services/user");
|
|
8
8
|
const utils = require("../config/utils");
|
|
9
|
+
const _brand = utils.brand;
|
|
9
10
|
const mongoose = require("mongoose");
|
|
10
11
|
const AWS_SES = require("../services/ses");
|
|
11
12
|
const Business = require("../models/Business");
|
|
12
13
|
const self = module.exports;
|
|
13
14
|
|
|
14
15
|
self.create = async (req, res) => {
|
|
15
|
-
// Create a new user
|
|
16
16
|
try {
|
|
17
17
|
let user = await serviceUser.create(req.body);
|
|
18
|
-
// Send email to user
|
|
19
18
|
if (process.env.SEND_EMAIL_USER === "true") {
|
|
20
|
-
let file =
|
|
19
|
+
let file = _brand.template("TEMPLATE_ACCOUNT");
|
|
21
20
|
file = file.replace("{{user}}", user.name);
|
|
22
21
|
file = file.replace("{{email}}", req.body.email);
|
|
23
22
|
file = file.replace("{{password}}", req.body.pwd);
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
const app = process.env.APP;
|
|
24
|
+
const urlEmail = process.env[`URL_EMAIL_${app}`];
|
|
25
|
+
|
|
26
|
+
if (urlEmail) {
|
|
27
|
+
file = file.replaceAll("{{urlToken}}", urlEmail);
|
|
26
28
|
}
|
|
27
29
|
await AWS_SES.sendCustom(
|
|
28
30
|
user.email,
|
|
@@ -30,16 +32,9 @@ self.create = async (req, res) => {
|
|
|
30
32
|
process.env.SUBJECT_EMAIL || "bienvenido"
|
|
31
33
|
);
|
|
32
34
|
}
|
|
33
|
-
|
|
34
35
|
res.status(201).send(user);
|
|
35
36
|
} catch (error) {
|
|
36
|
-
utils.responseError(
|
|
37
|
-
res,
|
|
38
|
-
error,
|
|
39
|
-
400,
|
|
40
|
-
"Error al crear usuario",
|
|
41
|
-
"Revisa el detalle del error"
|
|
42
|
-
);
|
|
37
|
+
utils.responseError(res, error, 400, "Error al crear usuario", "Revisa el detalle del error");
|
|
43
38
|
}
|
|
44
39
|
};
|
|
45
40
|
|
|
@@ -77,22 +72,20 @@ self.status = async (req, resp) => {
|
|
|
77
72
|
|
|
78
73
|
self.updatepassword = async (req, resp) => {
|
|
79
74
|
try {
|
|
80
|
-
const result = await serviceUser.updatepassword(
|
|
81
|
-
|
|
82
|
-
req.params.USER_ID
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
if(req.body.sendEmail === true){
|
|
86
|
-
|
|
75
|
+
const result = await serviceUser.updatepassword(req.body, req.params.USER_ID);
|
|
76
|
+
if (req.body.sendEmail === true) {
|
|
87
77
|
const _id = req.params.USER_ID;
|
|
88
78
|
let user = await User.findOne({ _id }, { pwd: 0 }).lean();
|
|
89
79
|
if (process.env.SEND_EMAIL_USER === "true") {
|
|
90
|
-
let file =
|
|
91
|
-
file = file.replace("{{user}}",
|
|
92
|
-
file = file.replace("{{email}}",
|
|
80
|
+
let file = _brand.template("TEMPLATE_ACCOUNT");
|
|
81
|
+
file = file.replace("{{user}}", user.name);
|
|
82
|
+
file = file.replace("{{email}}", user.email);
|
|
93
83
|
file = file.replace("{{password}}", req.body.pwd);
|
|
94
|
-
|
|
95
|
-
|
|
84
|
+
const app = process.env.APP;
|
|
85
|
+
const urlEmail = process.env[`URL_EMAIL_${app}`];
|
|
86
|
+
|
|
87
|
+
if (urlEmail) {
|
|
88
|
+
file = file.replaceAll("{{urlToken}}", urlEmail);
|
|
96
89
|
}
|
|
97
90
|
await AWS_SES.sendCustom(
|
|
98
91
|
user.email,
|
|
@@ -101,7 +94,6 @@ self.updatepassword = async (req, resp) => {
|
|
|
101
94
|
);
|
|
102
95
|
}
|
|
103
96
|
}
|
|
104
|
-
|
|
105
97
|
resp.status(200).send(result);
|
|
106
98
|
} catch (error) {
|
|
107
99
|
resp.status(400).send({ error: error.message });
|
|
@@ -603,16 +595,10 @@ self.generatecode = async () => {
|
|
|
603
595
|
self.sendcodemail = async (email, code) => {
|
|
604
596
|
try {
|
|
605
597
|
let user = await User.findOne({ email: email }, { name: 1, email: 1 });
|
|
606
|
-
|
|
607
|
-
let file = fs.readFileSync(process.env.TEMPLATE_RECOVER_PASSWORD, "utf8");
|
|
598
|
+
let file = _brand.template("TEMPLATE_RECOVER_PASSWORD");
|
|
608
599
|
file = file.replace("+++user+++", user.name);
|
|
609
600
|
file = file.replace("+++code+++", code);
|
|
610
|
-
|
|
611
|
-
return await alouxAWS.sendCustom(
|
|
612
|
-
user.email,
|
|
613
|
-
file,
|
|
614
|
-
"Código de recuperación de contraseña"
|
|
615
|
-
);
|
|
601
|
+
return await alouxAWS.sendCustom(user.email, file, "Código de recuperación de contraseña");
|
|
616
602
|
} catch (error) {
|
|
617
603
|
throw new Error("Ocurrio un error al envìar el correo electronico");
|
|
618
604
|
}
|
|
@@ -829,18 +815,16 @@ self.validatePhone = async (req, res) => {
|
|
|
829
815
|
self.sendverifyToken = async (correo, token) => {
|
|
830
816
|
try {
|
|
831
817
|
let user = await User.findOne({ email: correo }, { name: 1, email: 1 });
|
|
832
|
-
|
|
833
|
-
let template = fs.readFileSync(process.env.TEMPLATE_VERIFY_EMAIL, "utf8");
|
|
818
|
+
let template = _brand.template("TEMPLATE_VERIFY_EMAIL");
|
|
834
819
|
template = template.replaceAll("{{name}}", user.name);
|
|
835
820
|
template = template.replaceAll(
|
|
836
821
|
"{{urlVerifyEmail}}",
|
|
837
822
|
process.env.URL_VERIFY_EMAIL + "/?token=" + token
|
|
838
823
|
);
|
|
839
|
-
|
|
840
824
|
return await alouxAWS.sendCustom(
|
|
841
825
|
user.email,
|
|
842
826
|
template,
|
|
843
|
-
"Verifica tu cuenta de " +
|
|
827
|
+
"Verifica tu cuenta de " + _brand.name()
|
|
844
828
|
);
|
|
845
829
|
} catch (error) {
|
|
846
830
|
throw new Error("Ocurrio un error al envìar el correo electronico");
|
|
@@ -897,14 +881,12 @@ self.sendVerifyMailAccountJob = async (data, ban) => {
|
|
|
897
881
|
self.sendValidateEmail = async (email) => {
|
|
898
882
|
try {
|
|
899
883
|
let user = await User.findOne({ email: email }, { name: 1, email: 1 });
|
|
900
|
-
|
|
901
|
-
let file = fs.readFileSync(process.env.TEMPLATE_WELCOME, "utf8");
|
|
884
|
+
let file = _brand.template("TEMPLATE_WELCOME");
|
|
902
885
|
file = file.replace("+++user+++", user.name);
|
|
903
|
-
|
|
904
|
-
return await sesSDK.sendCustom(
|
|
886
|
+
return await AWS_SES.sendCustom(
|
|
905
887
|
user.email,
|
|
906
888
|
file,
|
|
907
|
-
"Bienvenido a " +
|
|
889
|
+
"Bienvenido a " + _brand.name()
|
|
908
890
|
);
|
|
909
891
|
} catch (error) {
|
|
910
892
|
throw new Error("Ocurrio un error al envìar el correo electronico");
|
package/lib/router.js
CHANGED
|
@@ -6,6 +6,8 @@ router.use(fileupload());
|
|
|
6
6
|
|
|
7
7
|
const auth = require("./controllers/auth");
|
|
8
8
|
const user = require("./controllers/user");
|
|
9
|
+
const totp = require("./controllers/totp");
|
|
10
|
+
const generatePassword = require("./controllers/generatePassword");
|
|
9
11
|
const menu = require("./controllers/menu");
|
|
10
12
|
const permission = require("./controllers/permission");
|
|
11
13
|
const functions = require("./controllers/functions");
|
|
@@ -90,9 +92,18 @@ router.get("/iam/menu/count/all", middleware, menu.count);
|
|
|
90
92
|
router.post("/iam/retrieve/history", middleware, history.retrieve);
|
|
91
93
|
router.get("/iam/history/:HISTORY_ID", middleware, history.detail);
|
|
92
94
|
|
|
95
|
+
// IAM / GENERATE PASSWORD
|
|
96
|
+
router.get("/iam/generatePassword", generatePassword.generate);
|
|
97
|
+
|
|
93
98
|
// Utilities
|
|
94
99
|
router.patch("/iam/add/time/:TOKEN", user.addTimeToken);
|
|
95
100
|
|
|
101
|
+
// TOTP
|
|
102
|
+
router.post("/iam/totp/verify", totp.checkLogin);
|
|
103
|
+
router.get("/iam/totp/setup", totp.setup);
|
|
104
|
+
router.post("/iam/totp/activate", totp.activate);
|
|
105
|
+
router.put("/iam/user/:USER_ID/totp", middleware, totp.adminToggle);
|
|
106
|
+
|
|
96
107
|
// IAM / Label
|
|
97
108
|
router.post("/iam/label", middleware, label.create);
|
|
98
109
|
router.patch("/iam/label/:LABEL_ID", middleware, label.update);
|
|
@@ -136,6 +147,8 @@ router.patch(
|
|
|
136
147
|
business.favicon
|
|
137
148
|
);
|
|
138
149
|
router.get("/iam/business/:ID/identity", business.identity);
|
|
150
|
+
router.patch("/iam/business/:BUSINESS_ID/useCompanyKey", middleware, business.setUseCompanyKey);
|
|
151
|
+
router.post("/iam/business/:BUSINESS_ID/inheritKey", middleware, business.inheritKey);
|
|
139
152
|
|
|
140
153
|
//Company
|
|
141
154
|
router.post("/iam/company", middleware, company.create);
|
|
@@ -147,6 +160,8 @@ router.delete("/iam/company/:COMPANY_ID", middleware, company.delete);
|
|
|
147
160
|
router.patch("/iam/company/:COMPANY_ID/picture", middleware, company.picture);
|
|
148
161
|
router.patch("/iam/company/:COMPANY_ID/favicon", middleware, company.favicon);
|
|
149
162
|
router.get("/iam/company/:ID/identity", company.identity);
|
|
163
|
+
router.put("/iam/company/:COMPANY_ID/gkey", middleware, company.updateGkey);
|
|
164
|
+
router.delete("/iam/company/:COMPANY_ID/gkey", middleware, company.deleteGkey);
|
|
150
165
|
|
|
151
166
|
router.patch("/iam/company/:COMPANY_ID/picture", middleware, business.picture);
|
|
152
167
|
router.patch("/iam/company/:COMPANY_ID/favicon", middleware, business.favicon);
|
package/lib/services/auth.js
CHANGED
|
@@ -8,6 +8,8 @@ const bigQuery = require("../services/bigQuery");
|
|
|
8
8
|
const bcrypt = require("bcryptjs");
|
|
9
9
|
const dayjs = require("dayjs");
|
|
10
10
|
const fs = require("fs");
|
|
11
|
+
const utils = require("../config/utils");
|
|
12
|
+
const _brand = utils.brand;
|
|
11
13
|
const jwt = require("jsonwebtoken");
|
|
12
14
|
const mongoose = require("mongoose");
|
|
13
15
|
|
|
@@ -86,6 +88,7 @@ self.login = async (body, res) => {
|
|
|
86
88
|
status: 1,
|
|
87
89
|
}
|
|
88
90
|
).populate({ path: "_functions", select: { name: 1 } });
|
|
91
|
+
|
|
89
92
|
if (!userLogin) {
|
|
90
93
|
throw {
|
|
91
94
|
code: 401,
|
|
@@ -95,6 +98,7 @@ self.login = async (body, res) => {
|
|
|
95
98
|
error: new Error(),
|
|
96
99
|
};
|
|
97
100
|
}
|
|
101
|
+
|
|
98
102
|
const token = await userLogin.generateAuthToken();
|
|
99
103
|
|
|
100
104
|
res.cookie("token", token, {
|
|
@@ -123,10 +127,8 @@ self.login = async (body, res) => {
|
|
|
123
127
|
throw {
|
|
124
128
|
code: 401,
|
|
125
129
|
title: "Límite de sesiones alcanzado",
|
|
126
|
-
detail:
|
|
127
|
-
|
|
128
|
-
suggestion:
|
|
129
|
-
"Por favor, cierra una de las sesiones activas en dispositivos que no estés usando para iniciar una nueva sesión.",
|
|
130
|
+
detail: "Has alcanzado el número máximo de sesiones permitidas para esta cuenta.",
|
|
131
|
+
suggestion: "Por favor, cierra una de las sesiones activas en dispositivos que no estés usando para iniciar una nueva sesión.",
|
|
130
132
|
error: new Error(),
|
|
131
133
|
};
|
|
132
134
|
}
|
|
@@ -145,10 +147,8 @@ self.login = async (body, res) => {
|
|
|
145
147
|
throw {
|
|
146
148
|
code: 401,
|
|
147
149
|
title: "Usuario bloqueado",
|
|
148
|
-
detail:
|
|
149
|
-
|
|
150
|
-
suggestion:
|
|
151
|
-
"Para reactivar tu cuenta, utiliza la opción de recuperación de contraseña e intenta nuevamente.",
|
|
150
|
+
detail: "Tu cuenta ha sido bloqueada debido a múltiples intentos fallidos de inicio de sesión.",
|
|
151
|
+
suggestion: "Para reactivar tu cuenta, utiliza la opción de recuperación de contraseña e intenta nuevamente.",
|
|
152
152
|
error: new Error(),
|
|
153
153
|
status: userLogin.status,
|
|
154
154
|
};
|
|
@@ -158,7 +158,6 @@ self.login = async (body, res) => {
|
|
|
158
158
|
const isPasswordMatch = await bcrypt.compare(pwd, userLogin.pwd);
|
|
159
159
|
|
|
160
160
|
if (!isPasswordMatch) {
|
|
161
|
-
//conteo de inicios fallidos
|
|
162
161
|
if (userLogin.validateKey.failedAttempts === process.env.FAILED_ATTEMPS) {
|
|
163
162
|
await User.updateOne({ _id: userLogin._id }, { status: "Bloqueado" });
|
|
164
163
|
} else {
|
|
@@ -175,8 +174,23 @@ self.login = async (body, res) => {
|
|
|
175
174
|
error: new Error(),
|
|
176
175
|
};
|
|
177
176
|
} else {
|
|
177
|
+
// ── TOTP: si tiene 2FA activo, no emitir token real todavía ──────────────
|
|
178
|
+
if (userLogin?.data?.totp?.enabled === true) {
|
|
179
|
+
const tempToken = jwt.sign(
|
|
180
|
+
{ _id: userLogin._id, type: "totp_pending" },
|
|
181
|
+
process.env.AUTH_SECRET,
|
|
182
|
+
{ expiresIn: "5m" }
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const needsSetup = !userLogin?.data?.totp?.secret;
|
|
186
|
+
|
|
187
|
+
return { requires2FA: true, needsSetup, tempToken };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Flujo normal sin TOTP ─────────────────────────────────────────────────
|
|
178
191
|
const token = await userLogin.generateAuthToken();
|
|
179
192
|
let changePwd;
|
|
193
|
+
|
|
180
194
|
if (!userLogin?.data) {
|
|
181
195
|
userLogin.data.changePwd = false;
|
|
182
196
|
changePwd = false;
|
|
@@ -453,34 +467,24 @@ self.generatecode = async () => {
|
|
|
453
467
|
};
|
|
454
468
|
|
|
455
469
|
self.sendcodemail = async (email, code, req) => {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
} else {
|
|
461
|
-
file = fs.readFileSync(process.env[req.body.enviroment], "utf8");
|
|
462
|
-
}
|
|
470
|
+
const user = await User.findOne({ email }, { name: 1, email: 1 });
|
|
471
|
+
let file = !req.body.enviroment
|
|
472
|
+
? _brand.template("TEMPLATE_RECOVER_PASSWORD")
|
|
473
|
+
: _brand.template(req.body.enviroment);
|
|
463
474
|
file = file.replace("+++user+++", user.name);
|
|
464
475
|
file = file.replace("+++code+++", code);
|
|
465
|
-
await ses.sendCustom(
|
|
466
|
-
user.email,
|
|
467
|
-
file,
|
|
468
|
-
"Código de recuperación de contraseña"
|
|
469
|
-
);
|
|
470
|
-
|
|
476
|
+
await ses.sendCustom(user.email, file, "Código de recuperación de contraseña");
|
|
471
477
|
return true;
|
|
472
478
|
};
|
|
473
479
|
|
|
474
480
|
self.sendcodemailLogin = async (email, code, ban) => {
|
|
475
|
-
let file;
|
|
481
|
+
let file = _brand.template("TEMPLATE_LOGIN_CODE");
|
|
476
482
|
if (ban === true) {
|
|
477
|
-
file = fs.readFileSync(process.env.TEMPLATE_LOGIN_CODE, "utf8");
|
|
478
483
|
file = file.replace("+++user+++", email);
|
|
479
484
|
file = file.replace("+++code+++", code);
|
|
480
485
|
await ses.sendCustom(email, file, "Código inicio de sesión");
|
|
481
486
|
} else {
|
|
482
|
-
const user = await User.findOne({ email
|
|
483
|
-
file = fs.readFileSync(process.env.TEMPLATE_LOGIN_CODE, "utf8");
|
|
487
|
+
const user = await User.findOne({ email }, { name: 1, email: 1 });
|
|
484
488
|
file = file.replace("+++user+++", user.name);
|
|
485
489
|
file = file.replace("+++code+++", code);
|
|
486
490
|
await ses.sendCustom(user.email, file, "Código inicio de sesión");
|
|
@@ -649,16 +653,10 @@ self.resetPassword = async (req, res) => {
|
|
|
649
653
|
|
|
650
654
|
self.sendverifyToken = async (correo, token) => {
|
|
651
655
|
let user = await User.findOne({ email: correo }, { name: 1, email: 1 });
|
|
652
|
-
|
|
653
|
-
let file = fs.readFileSync(process.env.TEMPLATE_VERIFY_EMAIL, "utf8");
|
|
656
|
+
let file = _brand.template("TEMPLATE_VERIFY_EMAIL");
|
|
654
657
|
file = file.replace("+++user+++", user.name);
|
|
655
658
|
file = file.replace("+++token+++", token);
|
|
656
|
-
|
|
657
|
-
await ses.sendCustom(
|
|
658
|
-
user.email,
|
|
659
|
-
file,
|
|
660
|
-
"Verifica tu cuenta de " + process.env.PROJECT_NAME
|
|
661
|
-
);
|
|
659
|
+
await ses.sendCustom(user.email, file, "Verifica tu cuenta de " + _brand.name());
|
|
662
660
|
return true;
|
|
663
661
|
};
|
|
664
662
|
|
|
@@ -686,16 +684,10 @@ self.sendVerifyMailAccountJob = async (data, ban) => {
|
|
|
686
684
|
};
|
|
687
685
|
|
|
688
686
|
self.sendValidateEmail = async (email) => {
|
|
689
|
-
let user = await User.findOne({ email
|
|
690
|
-
|
|
691
|
-
let file = fs.readFileSync(process.env.TEMPLATE_WELCOME, "utf8");
|
|
687
|
+
let user = await User.findOne({ email }, { name: 1, email: 1 });
|
|
688
|
+
let file = _brand.template("TEMPLATE_WELCOME");
|
|
692
689
|
file = file.replace("+++user+++", user.name);
|
|
693
|
-
|
|
694
|
-
return await ses.sendCustom(
|
|
695
|
-
user.email,
|
|
696
|
-
file,
|
|
697
|
-
"Bienvenido a " + process.env.PROJECT_NAME
|
|
698
|
-
);
|
|
690
|
+
return await ses.sendCustom(user.email, file, "Bienvenido a " + _brand.name());
|
|
699
691
|
};
|
|
700
692
|
|
|
701
693
|
self.verifyMailTokenAccount = async (req, res) => {
|
|
@@ -896,23 +888,19 @@ self.createCustomer = async (req, res) => {
|
|
|
896
888
|
|
|
897
889
|
self.mailChange = async (newEmail, user) => {
|
|
898
890
|
const code = await self.generatecode();
|
|
899
|
-
|
|
900
891
|
let time = new Date();
|
|
901
892
|
const sumarMinutos = new Date(time.getTime() + 5 * 60000);
|
|
902
893
|
await User.updateOne(
|
|
903
|
-
{
|
|
904
|
-
_id: user._id,
|
|
905
|
-
},
|
|
894
|
+
{ _id: user._id },
|
|
906
895
|
{
|
|
907
896
|
"validateKey.resetPassword.resetCode": code,
|
|
908
897
|
"validateKey.limitCodeTime": new Date(sumarMinutos).getTime(),
|
|
909
898
|
}
|
|
910
899
|
);
|
|
911
|
-
let file =
|
|
900
|
+
let file = _brand.template("TEMPLATE_CHANGE_MAIL");
|
|
912
901
|
file = file.replace("+++user+++", user.name);
|
|
913
902
|
file = file.replace("+++code+++", code);
|
|
914
903
|
await ses.sendCustom(newEmail, file, "Código para cambio de correo");
|
|
915
|
-
|
|
916
904
|
return true;
|
|
917
905
|
};
|
|
918
906
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const self = module.exports;
|
|
2
|
+
|
|
3
|
+
self.generatePassword = (length = 12) => {
|
|
4
|
+
const lowercase = "abcdefghijklmnopqrstuvwxyz";
|
|
5
|
+
const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
6
|
+
const numbers = "0123456789";
|
|
7
|
+
const special = "!@#$%&*";
|
|
8
|
+
const allChars = lowercase + uppercase + numbers + special;
|
|
9
|
+
|
|
10
|
+
let availableChars = allChars.split("");
|
|
11
|
+
let password = "";
|
|
12
|
+
|
|
13
|
+
const getRandomChar = (chars) => {
|
|
14
|
+
const index = Math.floor(Math.random() * chars.length);
|
|
15
|
+
const char = chars[index];
|
|
16
|
+
availableChars = availableChars.filter((c) => c !== char);
|
|
17
|
+
return char;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
password += getRandomChar(
|
|
21
|
+
lowercase.split("").filter((c) => availableChars.includes(c)),
|
|
22
|
+
);
|
|
23
|
+
password += getRandomChar(
|
|
24
|
+
uppercase.split("").filter((c) => availableChars.includes(c)),
|
|
25
|
+
);
|
|
26
|
+
password += getRandomChar(
|
|
27
|
+
numbers.split("").filter((c) => availableChars.includes(c)),
|
|
28
|
+
);
|
|
29
|
+
password += getRandomChar(
|
|
30
|
+
special.split("").filter((c) => availableChars.includes(c)),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
for (let i = password.length; i < length; i++) {
|
|
34
|
+
if (availableChars.length === 0) break;
|
|
35
|
+
const index = Math.floor(Math.random() * availableChars.length);
|
|
36
|
+
password += availableChars[index];
|
|
37
|
+
availableChars.splice(index, 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return password
|
|
41
|
+
.split("")
|
|
42
|
+
.sort(() => Math.random() - 0.5)
|
|
43
|
+
.join("");
|
|
44
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
const speakeasy = require("speakeasy");
|
|
2
|
+
const QRCode = require("qrcode");
|
|
3
|
+
const jwt = require("jsonwebtoken");
|
|
4
|
+
const dayjs = require("dayjs");
|
|
5
|
+
const User = require("../models/User");
|
|
6
|
+
|
|
7
|
+
const self = module.exports;
|
|
8
|
+
|
|
9
|
+
// Helper
|
|
10
|
+
|
|
11
|
+
const verifyTotpToken = (secret, token) =>
|
|
12
|
+
speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 });
|
|
13
|
+
|
|
14
|
+
const verifyTempToken = (tempToken) => {
|
|
15
|
+
let payload;
|
|
16
|
+
try {
|
|
17
|
+
payload = jwt.verify(tempToken, process.env.AUTH_SECRET);
|
|
18
|
+
if (payload.type !== "totp_pending") throw new Error();
|
|
19
|
+
} catch {
|
|
20
|
+
throw {
|
|
21
|
+
code: 401,
|
|
22
|
+
title: "Sesión expirada.",
|
|
23
|
+
detail: "",
|
|
24
|
+
suggestion: "Vuelve a iniciar sesión",
|
|
25
|
+
error: new Error(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return payload;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// genera QR y guarda el secreto
|
|
32
|
+
self.setup = async (tempToken) => {
|
|
33
|
+
const payload = verifyTempToken(tempToken);
|
|
34
|
+
|
|
35
|
+
const user = await User.findOne({ _id: payload._id });
|
|
36
|
+
|
|
37
|
+
if (!user)
|
|
38
|
+
throw {
|
|
39
|
+
code: 404,
|
|
40
|
+
title: "Usuario no encontrado.",
|
|
41
|
+
detail: "",
|
|
42
|
+
suggestion: "",
|
|
43
|
+
error: new Error(),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (!user?.data?.totp?.enabled)
|
|
47
|
+
throw {
|
|
48
|
+
code: 403,
|
|
49
|
+
title: "2FA no habilitado.",
|
|
50
|
+
detail: "",
|
|
51
|
+
suggestion: "Contacta al administrador para habilitarlo",
|
|
52
|
+
error: new Error(),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const secret = speakeasy.generateSecret({
|
|
56
|
+
name: `${process.env.PROJECT_NAME} (${user.email})`,
|
|
57
|
+
issuer: process.env.PROJECT_NAME,
|
|
58
|
+
length: 20,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const qrCode = await QRCode.toDataURL(secret.otpauth_url);
|
|
62
|
+
|
|
63
|
+
await User.updateOne(
|
|
64
|
+
{ _id: user._id },
|
|
65
|
+
{ $set: { "data.totp.secret": secret.base32 } },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return { secret: secret.base32, qrCode };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// valida el código tras escanear el QR
|
|
72
|
+
self.activate = async (tempToken, token) => {
|
|
73
|
+
const payload = verifyTempToken(tempToken);
|
|
74
|
+
|
|
75
|
+
const user = await User.findOne({ _id: payload._id });
|
|
76
|
+
|
|
77
|
+
if (!user?.data?.totp?.secret)
|
|
78
|
+
throw {
|
|
79
|
+
code: 400,
|
|
80
|
+
title: "QR no generado.",
|
|
81
|
+
detail: "",
|
|
82
|
+
suggestion: "Primero genera el QR desde /totp/setup",
|
|
83
|
+
error: new Error(),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (!verifyTotpToken(user.data.totp.secret, token))
|
|
87
|
+
throw {
|
|
88
|
+
code: 400,
|
|
89
|
+
title: "Código inválido.",
|
|
90
|
+
detail: "",
|
|
91
|
+
suggestion:
|
|
92
|
+
"El código ingresado es incorrecto, verifica que sea el correcto e intenta de nuevo.",
|
|
93
|
+
error: new Error(),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return { message: "TOTP configurado correctamente" };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// valida código en login y emite token real
|
|
100
|
+
self.checkLogin = async (token, tempToken, res) => {
|
|
101
|
+
const payload = verifyTempToken(tempToken);
|
|
102
|
+
|
|
103
|
+
const user = await User.findOne({ _id: payload._id });
|
|
104
|
+
|
|
105
|
+
if (!user?.data?.totp?.enabled)
|
|
106
|
+
throw {
|
|
107
|
+
code: 400,
|
|
108
|
+
title: "2FA no está configurado.",
|
|
109
|
+
detail: "",
|
|
110
|
+
suggestion: "",
|
|
111
|
+
error: new Error(),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (!user?.data?.totp?.secret)
|
|
115
|
+
throw {
|
|
116
|
+
code: 400,
|
|
117
|
+
title: "TOTP no configurado.",
|
|
118
|
+
detail: "",
|
|
119
|
+
suggestion: "Configura tu app Authenticator primero",
|
|
120
|
+
error: new Error(),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (!verifyTotpToken(user.data.totp.secret, token))
|
|
124
|
+
throw {
|
|
125
|
+
code: 401,
|
|
126
|
+
title: "Código inválido o expirado.",
|
|
127
|
+
detail: "",
|
|
128
|
+
suggestion:
|
|
129
|
+
"El código ingresado es incorrecto, verifica que sea el correcto e intenta de nuevo.",
|
|
130
|
+
error: new Error(),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
user.data.changePwd = user?.data?.changePwd ?? false;
|
|
134
|
+
user.validateKey.failedAttempts = 0;
|
|
135
|
+
await user.save();
|
|
136
|
+
|
|
137
|
+
const authToken = await user.generateAuthToken();
|
|
138
|
+
|
|
139
|
+
res.cookie("token", authToken, {
|
|
140
|
+
secure: true,
|
|
141
|
+
httpOnly: true,
|
|
142
|
+
sameSite: "none",
|
|
143
|
+
expires: dayjs().add(30, "days").toDate(),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return { token: authToken, changePwd: user.data.changePwd };
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// admin habilita o deshabilita 2FA de un usuario
|
|
150
|
+
self.adminToggle = async (userId, enabled) => {
|
|
151
|
+
const user = await User.findOne({ _id: userId });
|
|
152
|
+
|
|
153
|
+
if (!user)
|
|
154
|
+
throw {
|
|
155
|
+
code: 404,
|
|
156
|
+
title: "Usuario no encontrado.",
|
|
157
|
+
detail: "",
|
|
158
|
+
suggestion: "",
|
|
159
|
+
error: new Error(),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const update = enabled
|
|
163
|
+
? { "data.totp.enabled": true }
|
|
164
|
+
: { "data.totp.enabled": false, "data.totp.secret": null };
|
|
165
|
+
|
|
166
|
+
await User.updateOne({ _id: user._id }, { $set: update });
|
|
167
|
+
|
|
168
|
+
return { message: enabled ? "2FA habilitado" : "2FA deshabilitado" };
|
|
169
|
+
};
|
package/lib/swagger.yaml
CHANGED
|
@@ -1389,4 +1389,212 @@ paths:
|
|
|
1389
1389
|
itemsPerPage: 10
|
|
1390
1390
|
responses:
|
|
1391
1391
|
'200':
|
|
1392
|
-
description: ok
|
|
1392
|
+
description: ok
|
|
1393
|
+
# Generar contraseña
|
|
1394
|
+
/iam/generatePassword:
|
|
1395
|
+
get:
|
|
1396
|
+
summary: Generar contraseña segura
|
|
1397
|
+
tags:
|
|
1398
|
+
- utilities
|
|
1399
|
+
description: >
|
|
1400
|
+
Genera una contraseña aleatoria segura. Garantiza al menos una letra
|
|
1401
|
+
minúscula, una mayúscula, un número y un carácter especial (!@#$%&*).
|
|
1402
|
+
No requiere autenticación.
|
|
1403
|
+
parameters:
|
|
1404
|
+
- name: length
|
|
1405
|
+
in: query
|
|
1406
|
+
required: false
|
|
1407
|
+
description: Longitud de la contraseña (default 12)
|
|
1408
|
+
schema:
|
|
1409
|
+
type: integer
|
|
1410
|
+
default: 12
|
|
1411
|
+
minimum: 4
|
|
1412
|
+
responses:
|
|
1413
|
+
'200':
|
|
1414
|
+
description: Contraseña generada
|
|
1415
|
+
content:
|
|
1416
|
+
application/json:
|
|
1417
|
+
schema:
|
|
1418
|
+
type: object
|
|
1419
|
+
properties:
|
|
1420
|
+
password:
|
|
1421
|
+
type: string
|
|
1422
|
+
example: "aB3!xKm9Zq#T"
|
|
1423
|
+
# TOTP
|
|
1424
|
+
/iam/totp/setup:
|
|
1425
|
+
get:
|
|
1426
|
+
summary: Generar QR y secreto TOTP
|
|
1427
|
+
tags:
|
|
1428
|
+
- totp
|
|
1429
|
+
description: >
|
|
1430
|
+
Genera un secreto TOTP y retorna un QR en base64 para escanear con una
|
|
1431
|
+
app Authenticator (Google Authenticator, Authy, etc.).
|
|
1432
|
+
Requiere un token temporal de tipo `totp_pending` obtenido tras el login
|
|
1433
|
+
cuando el usuario tiene 2FA habilitado.
|
|
1434
|
+
El secreto se guarda en el usuario hasta que sea activado con `/totp/activate`.
|
|
1435
|
+
parameters:
|
|
1436
|
+
- name: Authorization
|
|
1437
|
+
in: header
|
|
1438
|
+
required: true
|
|
1439
|
+
description: Bearer {tempToken} — token temporal tipo totp_pending
|
|
1440
|
+
schema:
|
|
1441
|
+
type: string
|
|
1442
|
+
responses:
|
|
1443
|
+
'200':
|
|
1444
|
+
description: Secreto y QR generados correctamente
|
|
1445
|
+
content:
|
|
1446
|
+
application/json:
|
|
1447
|
+
schema:
|
|
1448
|
+
type: object
|
|
1449
|
+
properties:
|
|
1450
|
+
secret:
|
|
1451
|
+
type: string
|
|
1452
|
+
description: Secreto en base32
|
|
1453
|
+
example: "JBSWY3DPEHPK3PXP"
|
|
1454
|
+
qrCode:
|
|
1455
|
+
type: string
|
|
1456
|
+
description: Imagen QR en formato Data URL (base64)
|
|
1457
|
+
example: "data:image/png;base64,iVBORw0KGgo..."
|
|
1458
|
+
'401':
|
|
1459
|
+
description: Token temporal expirado o inválido
|
|
1460
|
+
'403':
|
|
1461
|
+
description: El usuario no tiene 2FA habilitado
|
|
1462
|
+
'404':
|
|
1463
|
+
description: Usuario no encontrado
|
|
1464
|
+
|
|
1465
|
+
/iam/totp/activate:
|
|
1466
|
+
post:
|
|
1467
|
+
summary: Activar TOTP tras escanear el QR
|
|
1468
|
+
tags:
|
|
1469
|
+
- totp
|
|
1470
|
+
description: >
|
|
1471
|
+
Valida el código OTP generado por la app Authenticator para confirmar
|
|
1472
|
+
que el QR fue escaneado correctamente. Debe llamarse después de `/totp/setup`.
|
|
1473
|
+
Requiere token temporal tipo `totp_pending`.
|
|
1474
|
+
parameters:
|
|
1475
|
+
- name: Authorization
|
|
1476
|
+
in: header
|
|
1477
|
+
required: true
|
|
1478
|
+
description: Bearer {tempToken} — token temporal tipo totp_pending
|
|
1479
|
+
schema:
|
|
1480
|
+
type: string
|
|
1481
|
+
requestBody:
|
|
1482
|
+
content:
|
|
1483
|
+
application/json:
|
|
1484
|
+
schema:
|
|
1485
|
+
properties:
|
|
1486
|
+
token:
|
|
1487
|
+
description: Código OTP de 6 dígitos generado por la app Authenticator
|
|
1488
|
+
type: string
|
|
1489
|
+
example: "123456"
|
|
1490
|
+
required:
|
|
1491
|
+
- token
|
|
1492
|
+
responses:
|
|
1493
|
+
'200':
|
|
1494
|
+
description: TOTP configurado correctamente
|
|
1495
|
+
content:
|
|
1496
|
+
application/json:
|
|
1497
|
+
schema:
|
|
1498
|
+
type: object
|
|
1499
|
+
properties:
|
|
1500
|
+
message:
|
|
1501
|
+
type: string
|
|
1502
|
+
example: "TOTP configurado correctamente"
|
|
1503
|
+
'400':
|
|
1504
|
+
description: QR no generado previamente o código inválido
|
|
1505
|
+
'401':
|
|
1506
|
+
description: Token temporal expirado o inválido
|
|
1507
|
+
|
|
1508
|
+
/iam/totp/verify:
|
|
1509
|
+
post:
|
|
1510
|
+
summary: Verificar código TOTP en el login
|
|
1511
|
+
tags:
|
|
1512
|
+
- totp
|
|
1513
|
+
description: >
|
|
1514
|
+
Segundo paso del login cuando el usuario tiene 2FA activo.
|
|
1515
|
+
Valida el código OTP y, si es correcto, emite el token de sesión real
|
|
1516
|
+
y lo setea como cookie `httpOnly`.
|
|
1517
|
+
parameters:
|
|
1518
|
+
- name: Authorization
|
|
1519
|
+
in: header
|
|
1520
|
+
required: true
|
|
1521
|
+
description: Bearer {tempToken} — token temporal tipo totp_pending
|
|
1522
|
+
schema:
|
|
1523
|
+
type: string
|
|
1524
|
+
requestBody:
|
|
1525
|
+
content:
|
|
1526
|
+
application/json:
|
|
1527
|
+
schema:
|
|
1528
|
+
properties:
|
|
1529
|
+
token:
|
|
1530
|
+
description: Código OTP de 6 dígitos generado por la app Authenticator
|
|
1531
|
+
type: string
|
|
1532
|
+
example: "123456"
|
|
1533
|
+
required:
|
|
1534
|
+
- token
|
|
1535
|
+
responses:
|
|
1536
|
+
'200':
|
|
1537
|
+
description: Login completado, retorna token de sesión
|
|
1538
|
+
content:
|
|
1539
|
+
application/json:
|
|
1540
|
+
schema:
|
|
1541
|
+
type: object
|
|
1542
|
+
properties:
|
|
1543
|
+
token:
|
|
1544
|
+
type: string
|
|
1545
|
+
description: JWT de sesión
|
|
1546
|
+
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
|
1547
|
+
changePwd:
|
|
1548
|
+
type: boolean
|
|
1549
|
+
description: Indica si el usuario debe cambiar su contraseña
|
|
1550
|
+
example: false
|
|
1551
|
+
'400':
|
|
1552
|
+
description: 2FA no configurado o secreto faltante
|
|
1553
|
+
'401':
|
|
1554
|
+
description: Código inválido o expirado / token temporal inválido
|
|
1555
|
+
|
|
1556
|
+
/iam/user/{USER_ID}/totp:
|
|
1557
|
+
put:
|
|
1558
|
+
summary: Habilitar o deshabilitar 2FA de un usuario (admin)
|
|
1559
|
+
tags:
|
|
1560
|
+
- totp
|
|
1561
|
+
description: >
|
|
1562
|
+
Permite a un administrador habilitar o deshabilitar el 2FA de cualquier usuario.
|
|
1563
|
+
Al deshabilitar, también elimina el secreto TOTP guardado.
|
|
1564
|
+
security:
|
|
1565
|
+
- bearerAuth: []
|
|
1566
|
+
parameters:
|
|
1567
|
+
- name: USER_ID
|
|
1568
|
+
in: path
|
|
1569
|
+
required: true
|
|
1570
|
+
description: ID del usuario a modificar
|
|
1571
|
+
schema:
|
|
1572
|
+
type: string
|
|
1573
|
+
format: ObjectId
|
|
1574
|
+
example: "64371ab1b6dc1417c9c0b812"
|
|
1575
|
+
requestBody:
|
|
1576
|
+
content:
|
|
1577
|
+
application/json:
|
|
1578
|
+
schema:
|
|
1579
|
+
properties:
|
|
1580
|
+
enabled:
|
|
1581
|
+
type: boolean
|
|
1582
|
+
description: >
|
|
1583
|
+
`true` para habilitar 2FA, `false` para deshabilitar
|
|
1584
|
+
(también borra el secreto)
|
|
1585
|
+
example: true
|
|
1586
|
+
required:
|
|
1587
|
+
- enabled
|
|
1588
|
+
responses:
|
|
1589
|
+
'200':
|
|
1590
|
+
description: Estado de 2FA actualizado
|
|
1591
|
+
content:
|
|
1592
|
+
application/json:
|
|
1593
|
+
schema:
|
|
1594
|
+
type: object
|
|
1595
|
+
properties:
|
|
1596
|
+
message:
|
|
1597
|
+
type: string
|
|
1598
|
+
example: "2FA habilitado"
|
|
1599
|
+
'404':
|
|
1600
|
+
description: Usuario no encontrado
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aloux-iam",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.141",
|
|
4
4
|
"description": "Aloux IAM for APIs ",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
"method-override": "^3.0.0",
|
|
34
34
|
"mongodb": "^4.17.x",
|
|
35
35
|
"mongoose": "^6.12.x",
|
|
36
|
+
"qrcode": "^1.5.4",
|
|
37
|
+
"speakeasy": "^2.0.0",
|
|
36
38
|
"yamljs": "^0.3.0"
|
|
37
39
|
}
|
|
38
|
-
}
|
|
40
|
+
}
|