utn-mcp 0.7.0 → 0.8.1
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/README.md +199 -180
- package/build/aula-virtual/client/client.js +2 -2
- package/build/aula-virtual/client/link.js +18 -11
- package/build/aula-virtual/client/local-session.js +21 -13
- package/build/aula-virtual/client/moodle-api.js +6 -5
- package/build/aula-virtual/client/regionals.js +112 -0
- package/build/aula-virtual/client/sso-login.js +277 -27
- package/build/aula-virtual/client/stored-session.js +24 -20
- package/build/aula-virtual/format/actions.js +1 -2
- package/build/aula-virtual/format/files.js +30 -11
- package/build/aula-virtual/login.js +25 -10
- package/build/aula-virtual/tools.js +11 -5
- package/build/http.js +57 -9
- package/build/index.js +12 -13
- package/build/oauth/access.js +3 -3
- package/build/oauth/admin.js +80 -13
- package/build/oauth/redirects.js +60 -0
- package/build/oauth/server.js +106 -37
- package/build/server.js +1 -1
- package/build/shared/input.js +60 -0
- package/build/shared/load-env.js +5 -0
- package/build/shared/pages.js +42 -37
- package/build/shared/paths.js +1 -1
- package/build/shared/store/sqlite-store.js +112 -40
- package/build/shared/uploads.js +9 -1
- package/package.json +58 -58
- package/build/aula-virtual/client/constants.js +0 -2
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Office 365: cada regional tiene su tenant, pero el login es siempre en este host. */
|
|
2
|
+
const MICROSOFT_SSO = { provider: "microsoft", host: "login.microsoftonline.com" };
|
|
3
|
+
/**
|
|
4
|
+
* El soporte de las que no son `verified` sale de `tool_mobile_get_public_config`, que Moodle contesta
|
|
5
|
+
* sin login (octubre de 2026). Falta Villa María (FRVM): no encontramos su aula.
|
|
6
|
+
*/
|
|
7
|
+
const REGIONALS = [
|
|
8
|
+
{ id: "fra", name: "Avellaneda (FRA)", baseUrl: "https://www.utnfravirtual.org.ar", support: "candidate" },
|
|
9
|
+
{ id: "frbb", name: "Bahía Blanca (FRBB)", baseUrl: "https://aulavirtual.frbb.utn.edu.ar", support: "candidate" },
|
|
10
|
+
{
|
|
11
|
+
id: "frba",
|
|
12
|
+
name: "Buenos Aires (FRBA)",
|
|
13
|
+
baseUrl: "https://aulasvirtuales.frba.utn.edu.ar",
|
|
14
|
+
sso: { provider: "keycloak", host: "auth.frba.utn.edu.ar" },
|
|
15
|
+
support: "verified",
|
|
16
|
+
},
|
|
17
|
+
{ id: "frch", name: "Chubut (FRCh)", baseUrl: "https://frch.cvg.utn.edu.ar", support: "candidate" },
|
|
18
|
+
{ id: "frcu", name: "Concepción del Uruguay (FRCU)", baseUrl: "https://virtual.frcu.utn.edu.ar", support: "candidate" },
|
|
19
|
+
{ id: "frcon", name: "Concordia (FRCon)", baseUrl: "https://frcon.cvg.utn.edu.ar", support: "candidate" },
|
|
20
|
+
{ id: "frc", name: "Córdoba (FRC)", baseUrl: "https://uv.frc.utn.edu.ar", support: "verified" },
|
|
21
|
+
{ id: "frd", name: "Delta (FRD)", baseUrl: "https://frd.cvg.utn.edu.ar", support: "candidate" },
|
|
22
|
+
{ id: "frgp", name: "General Pacheco (FRGP)", baseUrl: "https://frgp.cvg.utn.edu.ar", support: "candidate" },
|
|
23
|
+
{ id: "frh", name: "Haedo (FRH)", baseUrl: "https://frh.cvg.utn.edu.ar", sso: MICROSOFT_SSO, support: "candidate" },
|
|
24
|
+
{ id: "frlp", name: "La Plata (FRLP)", baseUrl: "https://frlp.cvg.utn.edu.ar", support: "candidate" },
|
|
25
|
+
{ id: "frlr", name: "La Rioja (FRLR)", baseUrl: "https://frlr.cvg.utn.edu.ar", support: "candidate" },
|
|
26
|
+
// Tiene también frmdp.cvg.utn.edu.ar, pero ese entra con Office 365 y no tiene el mobile web service
|
|
27
|
+
// habilitado, así que no puede emitir token aunque se resuelva el login.
|
|
28
|
+
{ id: "frmdp", name: "Mar del Plata (FRMDP)", baseUrl: "https://campus.mdp.utn.edu.ar", support: "candidate" },
|
|
29
|
+
// Sin el servicio habilitado.
|
|
30
|
+
{ id: "frm", name: "Mendoza (FRM)", baseUrl: "https://campusvirtual.frm.utn.edu.ar", support: "unsupported" },
|
|
31
|
+
{ id: "frn", name: "Neuquén (FRN)", baseUrl: "https://frn.cvg.utn.edu.ar", support: "candidate" },
|
|
32
|
+
{ id: "frp", name: "Paraná (FRP)", baseUrl: "https://frp.cvg.utn.edu.ar", support: "candidate" },
|
|
33
|
+
{ id: "frra", name: "Rafaela (FRRa)", baseUrl: "https://frra.cvg.utn.edu.ar", support: "candidate" },
|
|
34
|
+
{ id: "frrq", name: "Reconquista (FRRq)", baseUrl: "https://frrq.cvg.utn.edu.ar", support: "candidate" },
|
|
35
|
+
{ id: "frre", name: "Resistencia (FRRE)", baseUrl: "https://frre.cvg.utn.edu.ar", support: "verified" },
|
|
36
|
+
{ id: "frro", name: "Rosario (FRRo)", baseUrl: "https://frro.cvg.utn.edu.ar", support: "candidate" },
|
|
37
|
+
{ id: "frsfco", name: "San Francisco (FRSFco)", baseUrl: "https://frsfco.cvg.utn.edu.ar", support: "candidate" },
|
|
38
|
+
{ id: "frsn", name: "San Nicolás (FRSN)", baseUrl: "https://frsn.cvg.utn.edu.ar", sso: MICROSOFT_SSO, support: "candidate" },
|
|
39
|
+
{ id: "frsr", name: "San Rafael (FRSR)", baseUrl: "https://campus.frsr.utn.edu.ar/moodle", support: "candidate" },
|
|
40
|
+
{ id: "frsc", name: "Santa Cruz (FRSC)", baseUrl: "https://frsc.cvg.utn.edu.ar", support: "candidate" },
|
|
41
|
+
{ id: "frsf", name: "Santa Fe (FRSF)", baseUrl: "https://campusvirtual.frsf.utn.edu.ar", support: "candidate" },
|
|
42
|
+
// Sin el servicio habilitado.
|
|
43
|
+
{ id: "frtdf", name: "Tierra del Fuego (FRTDF)", baseUrl: "https://cvg.frtdf.utn.edu.ar", support: "unsupported" },
|
|
44
|
+
{ id: "frtl", name: "Trenque Lauquen (FRTL)", baseUrl: "https://frtl.cvg.utn.edu.ar", support: "candidate" },
|
|
45
|
+
{ id: "frt", name: "Tucumán (FRT)", baseUrl: "https://frt.cvg.utn.edu.ar", support: "verified" },
|
|
46
|
+
// No es una regional sino el aula de la Tecnicatura en Programación a distancia del SIED.
|
|
47
|
+
{ id: "tup", name: "Tecnicatura en Programación (SIED)", baseUrl: "https://tup.sied.utn.edu.ar", support: "verified" },
|
|
48
|
+
// Sin el servicio habilitado.
|
|
49
|
+
{ id: "frvt", name: "Venado Tuerto (FRVT)", baseUrl: "https://frvt.cvg.utn.edu.ar", support: "unsupported" },
|
|
50
|
+
];
|
|
51
|
+
const DEFAULT_REGIONAL = "frba";
|
|
52
|
+
/** `UTN_REGIONAL` no está en el registro: sin cortar acá, las credenciales irían al SSO de FRBA. */
|
|
53
|
+
export class UnknownRegionalError extends Error {
|
|
54
|
+
name = "UnknownRegionalError";
|
|
55
|
+
constructor(regional) {
|
|
56
|
+
super(`"${regional}" no es una regional conocida. Las válidas son: ${availableRegionals().map((r) => r.id).join(", ")}. ` +
|
|
57
|
+
"Si tu campus no está en la lista, borrá UTN_REGIONAL y definí UTN_AULA_BASE_URL con la URL del aula " +
|
|
58
|
+
"(y UTN_SSO_HOST si entra por SSO: el host del Keycloak, o login.microsoftonline.com si es Office 365).");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Su aula no tiene el servicio habilitado o entra por un SSO que no sabemos usar: ni se intenta entrar. */
|
|
62
|
+
export class UnsupportedRegionalError extends Error {
|
|
63
|
+
regional;
|
|
64
|
+
name = "UnsupportedRegionalError";
|
|
65
|
+
constructor(regional) {
|
|
66
|
+
super(`Todavía no hay soporte para ${regional.name}: su aula no tiene habilitado el servicio que usa ` +
|
|
67
|
+
"UTN-MCP o entra por un login que todavía no sabemos usar.");
|
|
68
|
+
this.regional = regional;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** El aula de UTN_AULA_BASE_URL, para un campus que no está en el registro. */
|
|
72
|
+
function customRegional() {
|
|
73
|
+
const raw = process.env.UTN_AULA_BASE_URL?.trim();
|
|
74
|
+
if (!raw)
|
|
75
|
+
return undefined;
|
|
76
|
+
const baseUrl = raw.replace(/\/+$/, "");
|
|
77
|
+
const host = new URL(baseUrl).host;
|
|
78
|
+
const ssoHost = process.env.UTN_SSO_HOST?.trim();
|
|
79
|
+
const sso = !ssoHost
|
|
80
|
+
? undefined
|
|
81
|
+
: ssoHost === MICROSOFT_SSO.host
|
|
82
|
+
? MICROSOFT_SSO
|
|
83
|
+
: { provider: "keycloak", host: ssoHost };
|
|
84
|
+
return { id: host, name: host, baseUrl, sso, support: "verified" };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Las que se pueden elegir al iniciar sesión: el catálogo y, si está definida, la de UTN_AULA_BASE_URL.
|
|
88
|
+
* `verifiedIds` son las `candidate` que ya se verificaron en el servidor (las guarda el store).
|
|
89
|
+
*/
|
|
90
|
+
export function availableRegionals(verifiedIds = []) {
|
|
91
|
+
const verified = new Set(verifiedIds);
|
|
92
|
+
const custom = customRegional();
|
|
93
|
+
return [...REGIONALS, ...(custom ? [custom] : [])].map((r) => r.support === "candidate" && verified.has(r.id) ? { ...r, support: "verified" } : r);
|
|
94
|
+
}
|
|
95
|
+
/** La regional con ese id; si no está entre las disponibles, `UnknownRegionalError`. */
|
|
96
|
+
export function findRegional(id) {
|
|
97
|
+
const wanted = id.trim().toLowerCase();
|
|
98
|
+
const found = availableRegionals().find((r) => r.id === wanted);
|
|
99
|
+
if (!found)
|
|
100
|
+
throw new UnknownRegionalError(id);
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* La que viene elegida si nadie dice otra: la de UTN_REGIONAL; si no, la de UTN_AULA_BASE_URL; si no,
|
|
105
|
+
* FRBA. Es la de quien usa UTN_USER/UTN_PASS sin `utn-mcp login`, y la opción por defecto al elegir.
|
|
106
|
+
*/
|
|
107
|
+
export function defaultRegional() {
|
|
108
|
+
const fromEnv = process.env.UTN_REGIONAL?.trim();
|
|
109
|
+
if (fromEnv)
|
|
110
|
+
return findRegional(fromEnv);
|
|
111
|
+
return customRegional() ?? findRegional(DEFAULT_REGIONAL);
|
|
112
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomInt } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { CookieJar } from "tough-cookie";
|
|
3
|
+
import { UnsupportedRegionalError } from "./regionals.js";
|
|
4
|
+
import { MoodleError } from "./moodle-api.js";
|
|
3
5
|
export class LoginRequiredError extends Error {
|
|
4
6
|
constructor(message = "Hace falta iniciar sesión: corré `utn-mcp login`.") {
|
|
5
7
|
super(message);
|
|
@@ -7,56 +9,286 @@ export class LoginRequiredError extends Error {
|
|
|
7
9
|
}
|
|
8
10
|
}
|
|
9
11
|
export class InvalidCredentialsError extends Error {
|
|
10
|
-
constructor() {
|
|
11
|
-
|
|
12
|
+
constructor(reason) {
|
|
13
|
+
const base = "El aula rechazó usuario o contraseña. Corré `utn-mcp login` para actualizarlos.";
|
|
14
|
+
super(reason ? `${base} Motivo del aula: ${reason}` : base);
|
|
12
15
|
this.name = "InvalidCredentialsError";
|
|
13
16
|
}
|
|
14
17
|
}
|
|
15
18
|
const MAX_REDIRECTS = 20;
|
|
16
19
|
const TIMEOUT_MS = 30_000;
|
|
20
|
+
const PROVIDERS = {
|
|
21
|
+
keycloak: {
|
|
22
|
+
link: /href="([^"]*\/auth\/oauth2\/login\.php[^"]*)"/,
|
|
23
|
+
submit: submitKeycloakForm,
|
|
24
|
+
loginError: extractLoginError,
|
|
25
|
+
},
|
|
26
|
+
microsoft: {
|
|
27
|
+
link: /href="([^"]*\/auth\/oidc\/[^"]*)"/,
|
|
28
|
+
submit: submitMicrosoftForm,
|
|
29
|
+
loginError: microsoftLoginError,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
17
32
|
/**
|
|
18
|
-
* Obtiene un token del web service
|
|
19
|
-
*
|
|
20
|
-
*
|
|
33
|
+
* Obtiene un token del web service. Dos estrategias según la regional:
|
|
34
|
+
* - Regionales con SSO (Keycloak como FRBA, Office 365 como FRH): flujo de launch.php, que usa la
|
|
35
|
+
* sesión del SSO. Si el SSO rechaza las credenciales, `login/token.php`: hay usuarios sin cuenta en
|
|
36
|
+
* el SSO.
|
|
37
|
+
* - Regionales con login directo de Moodle (ej. FRT): primero `login/token.php` (no depende del
|
|
38
|
+
* plugin de mobile launch, que puede estar deshabilitado); si falla, flujo de launch.php.
|
|
39
|
+
* Las cookies se usan solo durante el login y se descartan: las sesiones de Moodle y del SSO duran
|
|
40
|
+
* horas y el token, semanas, así que no sirven para renovarlo.
|
|
21
41
|
*/
|
|
22
|
-
export async function fetchToken(
|
|
23
|
-
|
|
42
|
+
export async function fetchToken(regional, credentials) {
|
|
43
|
+
if (regional.support === "unsupported")
|
|
44
|
+
throw new UnsupportedRegionalError(regional);
|
|
45
|
+
const { baseUrl, sso } = regional;
|
|
46
|
+
const jar = new CookieJar();
|
|
47
|
+
// Regionales con login directo: intentamos token.php primero (no depende de launch.php).
|
|
48
|
+
if (!sso) {
|
|
49
|
+
const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
|
|
50
|
+
if (viaTokenPhp)
|
|
51
|
+
return viaTokenPhp;
|
|
52
|
+
}
|
|
53
|
+
const launchUrl = `${baseUrl}/admin/tool/mobile/launch.php` +
|
|
24
54
|
`?service=moodle_mobile_app&passport=${randomInt(1e9)}&urlscheme=moodlemobile`;
|
|
25
|
-
// 1. Con sesión de Moodle viva, launch.php redirige directo al token.
|
|
26
55
|
let landing = await follow(jar, launchUrl);
|
|
27
56
|
if ("token" in landing)
|
|
28
57
|
return landing.token;
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
58
|
+
const provider = sso && PROVIDERS[sso.provider];
|
|
59
|
+
const ssoHref = provider && landing.html.match(provider.link)?.[1];
|
|
60
|
+
if (sso && provider && ssoHref) {
|
|
61
|
+
landing = await follow(jar, new URL(decodeHtml(ssoHref), landing.url).toString());
|
|
62
|
+
if ("token" in landing)
|
|
63
|
+
return landing.token;
|
|
64
|
+
if (new URL(landing.url).host === sso.host) {
|
|
65
|
+
landing = await provider.submit(jar, landing, credentials);
|
|
66
|
+
if ("token" in landing)
|
|
67
|
+
return landing.token;
|
|
68
|
+
if (new URL(landing.url).host === sso.host) {
|
|
69
|
+
// Aspirantes del ingreso y algunas tecnicaturas no tienen cuenta en el SSO: entran con el login de Moodle.
|
|
70
|
+
const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
|
|
71
|
+
if (viaTokenPhp)
|
|
72
|
+
return viaTokenPhp;
|
|
73
|
+
throw new InvalidCredentialsError(provider.loginError(landing.html));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const loginPageUrl = `${baseUrl}/login/index.php`;
|
|
79
|
+
const loginPage = await follow(jar, loginPageUrl);
|
|
80
|
+
if ("token" in loginPage)
|
|
81
|
+
return loginPage.token;
|
|
82
|
+
landing = await submitMoodleLoginForm(jar, loginPage.url, loginPage.html, credentials);
|
|
41
83
|
if ("token" in landing)
|
|
42
84
|
return landing.token;
|
|
43
|
-
|
|
44
|
-
|
|
85
|
+
const stillOnLogin = landing.url.includes("/login") ||
|
|
86
|
+
/alert-danger/i.test(landing.html) ||
|
|
87
|
+
/loginerrors/i.test(landing.html);
|
|
88
|
+
if (stillOnLogin) {
|
|
89
|
+
const reason = extractLoginError(landing.html);
|
|
90
|
+
throw new InvalidCredentialsError(reason);
|
|
91
|
+
}
|
|
45
92
|
}
|
|
46
|
-
// 4. Ya con sesión de Moodle (el SSO nos deja en el dashboard), pedimos el token de nuevo.
|
|
47
93
|
landing = await follow(jar, launchUrl);
|
|
48
94
|
if ("token" in landing)
|
|
49
95
|
return landing.token;
|
|
96
|
+
if (!sso) {
|
|
97
|
+
const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
|
|
98
|
+
if (viaTokenPhp)
|
|
99
|
+
return viaTokenPhp;
|
|
100
|
+
}
|
|
50
101
|
throw unexpected(landing.url);
|
|
51
102
|
}
|
|
52
|
-
|
|
53
|
-
|
|
103
|
+
export class MfaRequiredError extends Error {
|
|
104
|
+
constructor(reason) {
|
|
105
|
+
super("Tu cuenta de Microsoft pide un segundo factor (código, app o similar), que UTN-MCP todavía no puede " +
|
|
106
|
+
"completar solo." + (reason ? ` Microsoft dijo: ${reason}` : ""));
|
|
107
|
+
this.name = "MfaRequiredError";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Cantidad máxima de pasos de Microsoft entre la contraseña y la vuelta al aula (KMSI y similares). */
|
|
111
|
+
const MAX_MICROSOFT_STEPS = 6;
|
|
112
|
+
/**
|
|
113
|
+
* Formulario de Office 365 (Microsoft Entra ID). No es un `<form>`: los datos están en el `$Config` de
|
|
114
|
+
* la página. Después de la contraseña puede venir "¿Mantener la sesión iniciada?" antes del formulario
|
|
115
|
+
* que vuelve al aula con el `code`. No completa MFA ni logins federados (ADFS).
|
|
116
|
+
*/
|
|
117
|
+
async function submitMicrosoftForm(jar, page, { username, password }) {
|
|
118
|
+
const ssoHost = new URL(page.url).host;
|
|
119
|
+
const cfg = parseAadConfig(page.html);
|
|
120
|
+
if (!cfg?.urlPost || !cfg.sFT) {
|
|
121
|
+
throw new Error("No se encontró el formulario de Microsoft; puede que haya cambiado o que el login sea federado.");
|
|
122
|
+
}
|
|
123
|
+
let landing = await follow(jar, new URL(cfg.urlPost, page.url).toString(), {
|
|
124
|
+
method: "POST",
|
|
125
|
+
body: new URLSearchParams({
|
|
126
|
+
login: username,
|
|
127
|
+
loginfmt: username,
|
|
128
|
+
passwd: password,
|
|
129
|
+
type: "11",
|
|
130
|
+
LoginOptions: "3",
|
|
131
|
+
flowToken: cfg.sFT,
|
|
132
|
+
ctx: cfg.sCtx ?? "",
|
|
133
|
+
canary: cfg.canary ?? "",
|
|
134
|
+
i13: "0",
|
|
135
|
+
ps: "2",
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
for (let i = 0; i < MAX_MICROSOFT_STEPS; i++) {
|
|
139
|
+
if ("token" in landing)
|
|
140
|
+
return landing;
|
|
141
|
+
const form = findPostForm(landing.html);
|
|
142
|
+
const action = form && new URL(form.action, landing.url).toString();
|
|
143
|
+
if (form && action && new URL(action).host !== ssoHost) {
|
|
144
|
+
return follow(jar, action, { method: "POST", body: new URLSearchParams(form.inputs) });
|
|
145
|
+
}
|
|
146
|
+
const step = parseAadConfig(landing.html);
|
|
147
|
+
if (step?.arrUserProofs?.length)
|
|
148
|
+
throw new MfaRequiredError(step.strServiceExceptionMessage);
|
|
149
|
+
if (step?.sErrorCode)
|
|
150
|
+
return landing;
|
|
151
|
+
if (step?.urlPost) {
|
|
152
|
+
landing = await follow(jar, new URL(step.urlPost, landing.url).toString(), {
|
|
153
|
+
method: "POST",
|
|
154
|
+
body: new URLSearchParams({
|
|
155
|
+
LoginOptions: "1",
|
|
156
|
+
type: "28",
|
|
157
|
+
ctx: step.sCtx ?? "",
|
|
158
|
+
flowToken: step.sFT ?? "",
|
|
159
|
+
canary: step.canary ?? "",
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (form && action) {
|
|
165
|
+
landing = await follow(jar, action, { method: "POST", body: new URLSearchParams(form.inputs) });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
throw unexpected(landing.url);
|
|
169
|
+
}
|
|
170
|
+
throw new Error("Demasiados pasos en el login de Microsoft.");
|
|
171
|
+
}
|
|
172
|
+
/** El motivo del rechazo en una página de Microsoft (ej. AADSTS50126: usuario o contraseña incorrectos). */
|
|
173
|
+
function microsoftLoginError(html) {
|
|
174
|
+
const cfg = parseAadConfig(html);
|
|
175
|
+
if (!cfg?.sErrorCode)
|
|
176
|
+
return undefined;
|
|
177
|
+
return cfg.strServiceExceptionMessage || `AADSTS${cfg.sErrorCode}`;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Extrae el objeto `$Config={...}` que Microsoft embebe en sus páginas de login, recorriendo las
|
|
181
|
+
* llaves a mano (el objeto es grande y una regex no lo acota bien). Devuelve null si no está.
|
|
182
|
+
*/
|
|
183
|
+
function parseAadConfig(html) {
|
|
184
|
+
const at = html.indexOf("$Config=");
|
|
185
|
+
if (at < 0)
|
|
186
|
+
return null;
|
|
187
|
+
const start = html.indexOf("{", at);
|
|
188
|
+
if (start < 0)
|
|
189
|
+
return null;
|
|
190
|
+
let depth = 0;
|
|
191
|
+
let inStr = false;
|
|
192
|
+
let esc = false;
|
|
193
|
+
for (let i = start; i < html.length; i++) {
|
|
194
|
+
const c = html[i];
|
|
195
|
+
if (inStr) {
|
|
196
|
+
if (esc)
|
|
197
|
+
esc = false;
|
|
198
|
+
else if (c === "\\")
|
|
199
|
+
esc = true;
|
|
200
|
+
else if (c === '"')
|
|
201
|
+
inStr = false;
|
|
202
|
+
}
|
|
203
|
+
else if (c === '"')
|
|
204
|
+
inStr = true;
|
|
205
|
+
else if (c === "{")
|
|
206
|
+
depth++;
|
|
207
|
+
else if (c === "}" && --depth === 0) {
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(html.slice(start, i + 1));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
/** El primer `<form method="post">` de la página: su `action` y sus inputs ocultos. */
|
|
219
|
+
function findPostForm(html) {
|
|
220
|
+
const m = html.match(/<form\b[^>]*>([\s\S]*?)<\/form>/i);
|
|
221
|
+
if (!m || !/method\s*=\s*["']?post/i.test(m[0]))
|
|
222
|
+
return null;
|
|
223
|
+
const action = m[0].match(/action\s*=\s*["']([^"']*)["']/i)?.[1];
|
|
224
|
+
if (!action)
|
|
225
|
+
return null;
|
|
226
|
+
const inputs = {};
|
|
227
|
+
for (const input of m[1].matchAll(/<input\b[^>]*>/gi)) {
|
|
228
|
+
const name = input[0].match(/name\s*=\s*["']([^"']*)["']/i)?.[1];
|
|
229
|
+
if (name)
|
|
230
|
+
inputs[name] = decodeHtml(input[0].match(/value\s*=\s*["']([^"']*)["']/i)?.[1] ?? "");
|
|
231
|
+
}
|
|
232
|
+
return { action: decodeHtml(action), inputs };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Pide el token a /login/token.php (Moodle estándar). Devuelve null si el aula no lo expone
|
|
236
|
+
* (no contesta JSON), para seguir con launch.php; si contesta con un error, lo propaga.
|
|
237
|
+
*/
|
|
238
|
+
async function tryTokenPhp(baseUrl, credentials) {
|
|
239
|
+
const res = await fetch(`${baseUrl}/login/token.php`, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
242
|
+
body: new URLSearchParams({
|
|
243
|
+
username: credentials.username,
|
|
244
|
+
password: credentials.password,
|
|
245
|
+
service: "moodle_mobile_app",
|
|
246
|
+
}),
|
|
247
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
248
|
+
});
|
|
249
|
+
let data;
|
|
250
|
+
try {
|
|
251
|
+
data = await res.json();
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
if (data.token)
|
|
257
|
+
return data.token;
|
|
258
|
+
if (data.errorcode === "invalidlogin" || /invalid login/i.test(data.error ?? "")) {
|
|
259
|
+
throw new InvalidCredentialsError(data.error);
|
|
260
|
+
}
|
|
261
|
+
// Hay aulas (ej. la TUP) a las que se les rompe un plugin de autenticación con cada login
|
|
262
|
+
// rechazado: contestan `generalexceptionmessage`, y el formulario web muestra la misma excepción.
|
|
263
|
+
if (data.errorcode === "generalexceptionmessage") {
|
|
264
|
+
throw new InvalidCredentialsError(`${data.error} (es un error interno del aula, que en algunas aparece cuando el usuario o la contraseña están mal)`);
|
|
265
|
+
}
|
|
266
|
+
// El mismo servicio lo usa launch.php: si token.php lo rechaza, seguir no sirve.
|
|
267
|
+
throw new MoodleError(`El aula no dio el token (${data.errorcode ?? res.status}): ${data.error ?? "respuesta sin token"}. ` +
|
|
268
|
+
"Si dice que el servicio web no está disponible, lo tiene que habilitar el admin del aula.", data.errorcode);
|
|
269
|
+
}
|
|
270
|
+
/** Formulario de Keycloak: el `action` viene en el `loginAction` del JSON que arma la página. */
|
|
271
|
+
async function submitKeycloakForm(jar, { html }, { username, password }) {
|
|
54
272
|
const action = html.match(/"loginAction"\s*:\s*("(?:[^"\\]|\\.)*")/)?.[1];
|
|
55
273
|
if (!action)
|
|
56
|
-
throw new Error("No se encontró el formulario
|
|
274
|
+
throw new Error("No se encontró el formulario de Keycloak; puede que haya cambiado.");
|
|
57
275
|
const body = new URLSearchParams({ username, password, rememberMe: "on", credentialId: "" });
|
|
58
276
|
return follow(jar, JSON.parse(action), { method: "POST", body });
|
|
59
277
|
}
|
|
278
|
+
/** Login directo de Moodle (sin SSO): manda usuario y contraseña al formulario de /login/index.php. */
|
|
279
|
+
async function submitMoodleLoginForm(jar, pageUrl, html, { username, password }) {
|
|
280
|
+
const tokenMatch = html.match(/name="logintoken"\s+value="([^"]*)"/) ||
|
|
281
|
+
html.match(/value="([^"]*)"\s+name="logintoken"/);
|
|
282
|
+
const logintoken = tokenMatch?.[1] ?? "";
|
|
283
|
+
const actionMatch = html.match(/<form[^>]*action="([^"]*)"[^>]*>/i);
|
|
284
|
+
let action = actionMatch?.[1] ? decodeHtml(actionMatch[1]) : "";
|
|
285
|
+
if (!action)
|
|
286
|
+
action = pageUrl;
|
|
287
|
+
if (!action.startsWith("http"))
|
|
288
|
+
action = new URL(action, pageUrl).toString();
|
|
289
|
+
const body = new URLSearchParams({ username, password, logintoken, anchor: "" });
|
|
290
|
+
return follow(jar, action, { method: "POST", body });
|
|
291
|
+
}
|
|
60
292
|
/** Hace la request y sigue redirecciones a mano, guardando cookies en cada salto. */
|
|
61
293
|
async function follow(jar, url, init) {
|
|
62
294
|
for (let i = 0; i < MAX_REDIRECTS; i++) {
|
|
@@ -92,6 +324,24 @@ export function decodeLaunchToken(url) {
|
|
|
92
324
|
return parts[1];
|
|
93
325
|
}
|
|
94
326
|
const decodeHtml = (s) => s.replaceAll("&", "&");
|
|
327
|
+
/** Extrae el mensaje de error que muestra Moodle/Keycloak en la página de login. */
|
|
328
|
+
function extractLoginError(html) {
|
|
329
|
+
const patterns = [
|
|
330
|
+
/alert-danger[^>]*>([\s\S]*?)<\/div>/i,
|
|
331
|
+
/alert-error[^>]*>([\s\S]*?)<\/div>/i,
|
|
332
|
+
/class="[^"]*error[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
|
|
333
|
+
/<div[^>]*id="loginerrormessage"[^>]*>([\s\S]*?)<\/div>/i,
|
|
334
|
+
];
|
|
335
|
+
for (const re of patterns) {
|
|
336
|
+
const m = html.match(re);
|
|
337
|
+
if (m?.[1]) {
|
|
338
|
+
const text = m[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
339
|
+
if (text)
|
|
340
|
+
return text;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
95
345
|
const unexpected = (url) => {
|
|
96
346
|
const { host, pathname } = new URL(url);
|
|
97
347
|
return new Error(`Flujo de login inesperado: terminó en ${host}${pathname}`);
|
|
@@ -1,24 +1,34 @@
|
|
|
1
|
-
import { CookieJar } from "tough-cookie";
|
|
2
1
|
import { loadCredentials } from "./credentials.js";
|
|
2
|
+
import { defaultRegional, findRegional } from "./regionals.js";
|
|
3
3
|
import { fetchToken, LoginRequiredError } from "./sso-login.js";
|
|
4
4
|
/** Con qué nombre guarda el aula su sesión en el store. */
|
|
5
5
|
export const AULA_PLATFORM = "aula";
|
|
6
6
|
/**
|
|
7
|
-
* Sesión del modo HTTP: la de un usuario del store
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Sesión del modo HTTP: la de un usuario del store, en el aula de su regional. Renueva el token con la
|
|
8
|
+
* contraseña del keychain cuando es de este mismo usuario: eso pasa solo en local, porque el servidor
|
|
9
|
+
* hosteado no guarda contraseñas. Si no, el usuario vuelve a iniciar sesión.
|
|
10
10
|
*/
|
|
11
11
|
export class StoredAulaSession {
|
|
12
12
|
store;
|
|
13
13
|
userId;
|
|
14
14
|
loginUrl;
|
|
15
15
|
refreshing = null;
|
|
16
|
+
regional = null;
|
|
16
17
|
/** `loginUrl` es la página donde el usuario vuelve a iniciar sesión, si el servidor tiene una. */
|
|
17
18
|
constructor(store, userId, loginUrl) {
|
|
18
19
|
this.store = store;
|
|
19
20
|
this.userId = userId;
|
|
20
21
|
this.loginUrl = loginUrl;
|
|
21
22
|
}
|
|
23
|
+
/** La del usuario en el store; no cambia mientras dura la sesión, así que se lee una sola vez. */
|
|
24
|
+
getRegional() {
|
|
25
|
+
this.regional ??= this.store.findUser(this.userId).then((user) => {
|
|
26
|
+
if (!user)
|
|
27
|
+
throw this.loginRequired("No hay usuario para esta sesión.");
|
|
28
|
+
return findRegional(user.regional);
|
|
29
|
+
});
|
|
30
|
+
return this.regional;
|
|
31
|
+
}
|
|
22
32
|
async getToken() {
|
|
23
33
|
return (await this.read()).token;
|
|
24
34
|
}
|
|
@@ -29,23 +39,17 @@ export class StoredAulaSession {
|
|
|
29
39
|
return this.refreshing;
|
|
30
40
|
}
|
|
31
41
|
async renew() {
|
|
32
|
-
const stored = await this.read();
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
// entraríamos al aula de ella y guardaríamos su token en la sesión de este.
|
|
42
|
+
const [stored, regional] = await Promise.all([this.read(), this.getRegional()]);
|
|
43
|
+
// Las del keychain (o UTN_USER/UTN_PASS) son de una sola persona: con otro usuario (o el mismo
|
|
44
|
+
// nombre en otra regional) entraríamos al aula de ella y guardaríamos su token en la sesión de este.
|
|
36
45
|
const credentials = await loadCredentials();
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
throw this.loginRequired("La sesión del aula venció.");
|
|
45
|
-
}
|
|
46
|
-
throw e;
|
|
47
|
-
}
|
|
48
|
-
const data = { ...stored, token, cookies: await jar.serialize() };
|
|
46
|
+
const sameUser = credentials?.username.trim().toLowerCase() === stored.username &&
|
|
47
|
+
(credentials.regional ?? defaultRegional().id) === regional.id;
|
|
48
|
+
if (!credentials || !sameUser)
|
|
49
|
+
throw this.loginRequired("La sesión del aula venció.");
|
|
50
|
+
const token = await fetchToken(regional, credentials);
|
|
51
|
+
// Sin `...stored`: las sesiones guardadas antes traen cookies, que ya no se usan.
|
|
52
|
+
const data = { username: stored.username, token };
|
|
49
53
|
await this.store.saveSession(this.userId, AULA_PLATFORM, JSON.stringify(data));
|
|
50
54
|
return token;
|
|
51
55
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { textToHtml } from "../../shared/html.js";
|
|
2
2
|
import { addDiscussion, addDiscussionPost, createUserEvent, deleteChoiceResponses, deleteChoicegroupResponses, deletePost, getChoiceOptions, getChoicegroupOptions, getDiscussionPosts, getMemberName, sendInstantMessage, submitChoiceResponse, submitChoicegroupResponse, updateDiscussionPost, } from "../client/client.js";
|
|
3
|
-
import { AULA_BASE_URL } from "../client/constants.js";
|
|
4
3
|
import { toOption } from "./activities.js";
|
|
5
4
|
import { discussionAddr, formatAddress, parseAddress } from "./address.js";
|
|
6
5
|
import { isoDate } from "./fields.js";
|
|
@@ -154,6 +153,6 @@ export async function addCalendarEvent(session, event) {
|
|
|
154
153
|
inicio: isoDate(timestart),
|
|
155
154
|
fin: timeduration ? isoDate(timestart + timeduration) : undefined,
|
|
156
155
|
// Para que el usuario lo vea en el calendario del aula.
|
|
157
|
-
url: `${
|
|
156
|
+
url: `${(await session.getRegional()).baseUrl}/calendar/view.php?view=day&time=${timestart}`,
|
|
158
157
|
};
|
|
159
158
|
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { openDocument, UnsupportedFormat } from "../../shared/documents/index.js";
|
|
2
|
-
import { AULA_BASE_URL } from "../client/constants.js";
|
|
3
2
|
import { fetchFile } from "../client/moodle-api.js";
|
|
4
|
-
const ORIGIN = new URL(AULA_BASE_URL).origin;
|
|
5
3
|
const WS_PATH = "/webservice/pluginfile.php/";
|
|
6
4
|
const WEB_PATH = "/pluginfile.php/";
|
|
7
5
|
/** Cuánto se devuelve por llamada, en caracteres de texto. */
|
|
@@ -13,16 +11,28 @@ const MAX_BYTES = 30 * 1024 * 1024;
|
|
|
13
11
|
/**
|
|
14
12
|
* Moodle devuelve las URLs del web service, que piden token. Al usuario le sirve la misma ruta sin
|
|
15
13
|
* `/webservice`, que se abre con la sesión de su navegador. Es la única que mostramos: el token no
|
|
16
|
-
* sale nunca del servidor.
|
|
14
|
+
* sale nunca del servidor. `fileurl` viene de Moodle: ya es del aula del usuario.
|
|
17
15
|
*/
|
|
18
16
|
export function userFacingUrl(fileurl) {
|
|
19
|
-
|
|
17
|
+
let u;
|
|
18
|
+
try {
|
|
19
|
+
u = new URL(fileurl);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return fileurl;
|
|
23
|
+
}
|
|
24
|
+
// Hay aulas en una subruta (`/moodle`): el tramo a cambiar puede no estar al principio.
|
|
25
|
+
const at = u.pathname.indexOf(WS_PATH);
|
|
26
|
+
if (at < 0)
|
|
27
|
+
return fileurl;
|
|
28
|
+
u.pathname = u.pathname.slice(0, at) + WEB_PATH + u.pathname.slice(at + WS_PATH.length);
|
|
29
|
+
return u.href;
|
|
20
30
|
}
|
|
21
31
|
/**
|
|
22
|
-
* Acepta cualquiera de las dos formas y devuelve la del web service. Solo archivos del aula
|
|
23
|
-
* cualquier otra URL estaríamos mandando
|
|
32
|
+
* Acepta cualquiera de las dos formas y devuelve la del web service. Solo archivos del aula del
|
|
33
|
+
* usuario (`baseUrl`): con cualquier otra URL estaríamos mandando su token a donde diga el modelo.
|
|
24
34
|
*/
|
|
25
|
-
function downloadUrl(url) {
|
|
35
|
+
function downloadUrl(baseUrl, url) {
|
|
26
36
|
let u;
|
|
27
37
|
try {
|
|
28
38
|
u = new URL(url);
|
|
@@ -30,10 +40,18 @@ function downloadUrl(url) {
|
|
|
30
40
|
catch {
|
|
31
41
|
throw new Error(`URL inválida: "${url}". Usá la \`url\` de un archivo tal como la devuelve aula_open.`);
|
|
32
42
|
}
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
const root = new URL(baseUrl);
|
|
44
|
+
const prefix = root.pathname.replace(/\/+$/, "");
|
|
45
|
+
const rest = u.pathname.startsWith(prefix + WS_PATH)
|
|
46
|
+
? u.pathname.slice(prefix.length + WS_PATH.length)
|
|
47
|
+
: u.pathname.startsWith(prefix + WEB_PATH)
|
|
48
|
+
? u.pathname.slice(prefix.length + WEB_PATH.length)
|
|
49
|
+
: null;
|
|
50
|
+
// `origin` ignora `usuario@`: con eso `fetch` rechaza la URL y su error, con el token ya puesto, llega al modelo.
|
|
51
|
+
if (u.origin !== root.origin || u.username || u.password || rest === null) {
|
|
52
|
+
throw new Error(`Solo se pueden leer archivos del aula virtual (${root.origin}${prefix}${WEB_PATH}...).`);
|
|
36
53
|
}
|
|
54
|
+
const path = prefix + WS_PATH + rest;
|
|
37
55
|
u.pathname = path;
|
|
38
56
|
u.searchParams.delete("token");
|
|
39
57
|
u.searchParams.delete("wstoken");
|
|
@@ -41,7 +59,8 @@ function downloadUrl(url) {
|
|
|
41
59
|
}
|
|
42
60
|
/** Baja un archivo del aula y devuelve desde la parte `from` tantas como entren en el presupuesto. */
|
|
43
61
|
export async function readAulaFile(session, url, from = 1) {
|
|
44
|
-
const
|
|
62
|
+
const { baseUrl } = await session.getRegional();
|
|
63
|
+
const download = downloadUrl(baseUrl, url);
|
|
45
64
|
const fileName = decodeURIComponent(download.pathname.split("/").pop() ?? "");
|
|
46
65
|
const forUser = userFacingUrl(download.href);
|
|
47
66
|
const { bytes } = await fetchFile(session, download, MAX_BYTES);
|