25. Servicio con token

Versión para imprimir.

A. Introducción

B. Diagrama de despliegue

Diagrama de despliegue

C. Hazlo funcionar

  1. Revisa el proyecto en Replit con la URL https://replit.com/@GilbertoPachec5/srvtoken?v=1. Hazle fork al proyecto y córrelo. En el ambiente de desarrollo tienes la opción de descargar el proyecto en un zip.

  2. Usa o crea una cuenta de Google.

  3. Crea una cuenta de Replit usando la cuenta de Google.

  4. Crea un proyecto PHP Web Server en Replit y edita o sube los archivos de este proyecto.

  5. Depura el proyecto.

  6. Crea la cover page o página de spotlight del proyecto.

D. Archivos

Haz clic en los triángulos para expandir las carpetas

E. index.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>Uso de tokens</title>
10
11</head>
12
13<body>
14
15 <h1>Uso de tokens</h1>
16
17 <p>
18 <a href="formulario.html">Servicio que procesa un formulario con token.</a>
19 </p>
20
21 </form>
22
23</body>
24
25</html>

F. formulario.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>Servicio que procesa un formulario con token</title>
10
11 <script type="module" src="lib/js/invocaServicio.js"></script>
12 <script type="module" src="lib/js/submitForm.js"></script>
13 <script type="module" src="lib/js/muestraError.js"></script>
14
15 <script>
16
17 let token = ""
18
19 </script>
20
21</head>
22
23<!-- Al cargar, registra este formulario en la sesión y recibe un token. -->
24
25<body onload="invocaServicio('srv/srvRegistra.php')
26 .then(token => forma.token.value = token.body)
27 .catch(muestraError)">
28
29 <h1>Servicio que procesa un formulario con token</h1>
30
31 <p><a href="index.html">Cancela</a></p>
32
33 <form id="forma" onsubmit="submitForm('srv/srvProcesa.php', event)
34 .then(resultado => {
35 alert(resultado.body)
36 location.href = 'index.html'
37 })
38 .catch(muestraError)">
39
40 <!-- Al enviar la forma, se envía el token recibido. -->
41 <input type="hidden" name="token">
42
43 <p>
44 <label>
45 Saludo:
46 <input name="saludo">
47 </label>
48 </p>
49
50 <p>
51 <label>
52 Nombre:
53 <input name="nombre">
54 </label>
55 </p>
56
57 <p><button type="submit">Procesa</button></p>
58
59 </form>
60
61</body>
62
63</html>

G. Carpeta « srv »

Versión para imprimir.

A. srv / srvProcesa.php

1<?php
2
3require_once __DIR__ . "/../lib/php/ejecutaServicio.php";
4require_once __DIR__ . "/../lib/php/ProblemDetails.php";
5require_once __DIR__ . "/../lib/php/leeTexto.php";
6require_once __DIR__ . "/../lib/php/validaToken.php";
7
8ejecutaServicio(function () {
9
10 session_start();
11
12 $token = leeTexto("token");
13 if ($token === null) {
14 throw new ProblemDetails(
15 status: ProblemDetails::BadRequest,
16 type: "/error/faltatoken.html",
17 title: "Falta el token.",
18 );
19 }
20 validaToken("formulario", $token);
21
22 // Si el token se halló, precesa normalmente la forma.
23 $saludo = leeTexto("saludo");
24 $nombre = leeTexto("nombre");
25 if ($saludo === null) {
26 throw new ProblemDetails(
27 status: ProblemDetails::BadRequest,
28 type: "/error/faltasaludo.html",
29 title: "Falta el saludo.",
30 detail: "La solicitud no tiene el valor de saludo.",
31 );
32 }
33 if ($saludo === "") {
34 throw new ProblemDetails(
35 status: ProblemDetails::BadRequest,
36 type: "/error/saludoenblanco.html",
37 title: "El saludo está en blanco.",
38 detail: "Pon texto en el saludo.",
39 );
40 }
41 if ($nombre === null) {
42 throw new ProblemDetails(
43 status: ProblemDetails::BadRequest,
44 type: "/error/faltanombre.html",
45 title: "Falta el nombre.",
46 detail: "La solicitud no tiene el valor de nombre.",
47 );
48 }
49 if ($nombre === "") {
50 throw new ProblemDetails(
51 status: ProblemDetails::BadRequest,
52 type: "/error/nombreenblanco.html",
53 title: "El nombre está en blanco.",
54 detail: "Pon texto en el nombre.",
55 );
56 }
57 $resultado = "{$saludo} {$nombre}.";
58 return $resultado;
59});
60

B. srv / srvRegistra.php

1<?php
2
3require_once __DIR__ . "/../lib/php/ejecutaServicio.php";
4require_once __DIR__ . "/../lib/php/creaToken.php";
5require_once __DIR__ . "/../lib/php/leeTexto.php";
6
7ejecutaServicio(function () {
8 session_start();
9 // Crea un token para la página "formulario" que expira en 5 minutos.
10 return creaToken("formulario", 5);
11});
12

H. Carpeta « lib »

Versión para imprimir.

A. Carpeta « lib / js »

1. lib / js / invocaServicio.js

1import {
2 JsonResponse, JsonResponse_Created, JsonResponse_NoContent, JsonResponse_OK
3} from "./JsonResponse.js"
4import {
5 ProblemDetails, ProblemDetails_InternalServerError
6} from "./ProblemDetails.js"
7
8/**
9 * Espera a que la promesa de un fetch termine. Si
10 * hay error, lanza una excepción. Si no hay error,
11 * interpreta la respuesta del servidor como JSON y
12 * la convierte en una literal de objeto.
13 * @param { string | Promise<Response> } servicio
14 */
15export async function invocaServicio(servicio) {
16 let f = servicio
17 if (typeof servicio === "string") {
18 f = fetch(servicio, {
19 headers: { "Accept": "application/json, application/problem+json" }
20 })
21 } else if (!(f instanceof Promise)) {
22 throw new Error("Servicio de tipo incorrecto.")
23 }
24 const respuesta = await f
25 if (respuesta.ok) {
26 if (respuesta.status === JsonResponse_NoContent) {
27 return new JsonResponse(JsonResponse_NoContent)
28 }
29 const texto = await respuesta.text()
30 try {
31 const body = JSON.parse(texto)
32 if (respuesta.status === JsonResponse_Created) {
33 const location = respuesta.headers.get("location")
34 return new JsonResponse(JsonResponse_Created, body,
35 location === null ? undefined : location)
36 } else {
37 return new JsonResponse(JsonResponse_OK, body)
38 }
39 } catch (error) {
40 // El contenido no es JSON. Probablemente sea texto.
41 throw new ProblemDetails(ProblemDetails_InternalServerError,
42 "Problema interno en el servidor.", texto)
43 }
44 } else {
45 const texto = await respuesta.text()
46 try {
47 const { type, title, detail } = JSON.parse(texto)
48 throw new ProblemDetails(respuesta.status,
49 typeof title === "string" ? title : "",
50 typeof detail === "string" ? detail : undefined,
51 typeof type === "string" ? type : undefined)
52 } catch (error) {
53 if (error instanceof ProblemDetails) {
54 throw error
55 } else {
56 // El contenido no es JSON. Probablemente sea texto.
57 throw new ProblemDetails(respuesta.status, respuesta.statusText, texto)
58 }
59 }
60 }
61}
62
63// Permite que los eventos de html usen la función.
64window["invocaServicio"] = invocaServicio

2. lib / js / JsonResponse.js

1export const JsonResponse_OK = 200
2export const JsonResponse_Created = 201
3export const JsonResponse_NoContent = 204
4
5export class JsonResponse {
6
7 /**
8 * @param {number} status
9 * @param {any} [body]
10 * @param {string} [location]
11 */
12 constructor(status, body, location) {
13 /** @readonly */
14 this.status = status
15 /** @readonly */
16 this.body = body
17 /** @readonly */
18 this.location = location
19 }
20
21}

3. lib / js / muestraError.js

1import { ProblemDetails } from "./ProblemDetails.js"
2
3/**
4 * Muestra un error en la consola y en un cuadro de
5 * alerta el mensaje de una excepción.
6 * @param { ProblemDetails | Error | null } error descripción del error.
7 */
8export function muestraError(error) {
9 if (error === null) {
10 console.log("Error")
11 alert("Error")
12 } else if (error instanceof ProblemDetails) {
13 let mensaje = error.title
14 if (error.detail) {
15 mensaje += `\n\n${error.detail}`
16 }
17 mensaje += `\n\nCódigo: ${error.status}`
18 if (error.type) {
19 mensaje += ` ${error.type}`
20 }
21 console.error(mensaje)
22 console.error(error)
23 alert(mensaje)
24 } else {
25 console.error(error)
26 alert(error.message)
27 }
28}
29
30// Permite que los eventos de html usen la función.
31window["muestraError"] = muestraError

4. lib / js / ProblemDetails.js

1export const ProblemDetails_BadRequest = 400
2export const ProblemDetails_NotFound = 404
3export const ProblemDetails_InternalServerError = 500
4
5export class ProblemDetails extends Error {
6
7 /**
8 * @param {number} status
9 * @param {string} title
10 * @param {string} [detail]
11 * @param {string} [type]
12 */
13 constructor(status, title, detail, type) {
14 super(title)
15 /** @readonly */
16 this.status = status
17 /** @readonly */
18 this.type = type
19 /** @readonly */
20 this.title = title
21 /** @readonly */
22 this.detail = detail
23 }
24
25}

5. lib / js / submitForm.js

1import { invocaServicio } from "./invocaServicio.js"
2
3/**
4 * Envía los datos de la forma a la url usando la codificación
5 * multipart/form-data.
6 * @param {string} url
7 * @param {Event} event
8 * @param { "GET" | "POST"| "PUT" | "PATCH" | "DELETE" | "TRACE" | "OPTIONS"
9 * | "CONNECT" | "HEAD" } metodoHttp
10 */
11export function submitForm(url, event, metodoHttp = "POST") {
12 event.preventDefault()
13 const form = event.target
14 if (!(form instanceof HTMLFormElement))
15 throw new Error("event.target no es una form.")
16 return invocaServicio(fetch(url, {
17 method: metodoHttp,
18 headers: { "Accept": "application/json, application/problem+json" },
19 body: new FormData(form)
20 }))
21}
22
23// Permite que los eventos de html usen la función.
24window["submitForm"] = submitForm

B. Carpeta « lib / php »

1. lib / php / creaToken.php

1<?php
2
3function creaToken(string $pagina, int $duracionEnMinutos)
4{
5 $criptografiaFuerte = true;
6
7 // Crea el token
8 $token = [
9 "expiracion" => time() + 60 * $duracionEnMinutos,
10 // El token es de 80 caracteres, criptográficamente fuerte.
11 "texto" => bin2hex(openssl_random_pseudo_bytes(80, $criptografiaFuerte))
12 ];
13
14 // Verifica que ya haya tokens $pagina.
15 if (isset($_SESSION[$pagina])) {
16
17 $tokensParaPagina = $_SESSION[$pagina];
18
19 // Como ya existe el arreglo, elimina los tokens expirados para esta pagina.
20 foreach ($tokensParaPagina as $llave => $tokenParaPagina) {
21 if ($tokenParaPagina["expiracion"] > time()) {
22 unset($tokensParaPagina[$llave]);
23 }
24 }
25
26 // Se puede usar uno o varios tokens por pagina.
27 $tokensParaPagina[] = $token;
28 $_SESSION[$pagina] = $tokensParaPagina;
29 } else {
30
31 // Se puede usar uno o varios tokens por pagina
32 $_SESSION[$pagina] = [$token];
33 }
34
35 return $token["texto"];
36}
37

2. lib / php / ejecutaServicio.php

1<?php
2
3require_once __DIR__ . "/JsonResponse.php";
4require_once __DIR__ . "/ProblemDetails.php";
5
6/**
7 * Ejecuta una funcion que implementa un servicio.
8 */
9function ejecutaServicio($servicio)
10{
11 try {
12 $resultado = $servicio();
13 if (!($resultado instanceof JsonResponse)) {
14 $resultado = JsonResponse::ok($resultado);
15 }
16 procesa_json_response($resultado);
17 } catch (ProblemDetails $details) {
18 procesa_problem_details($details);
19 } catch (Throwable $throwable) {
20 procesa_problem_details(new ProblemDetails(
21 status: ProblemDetails::InternalServerError,
22 type: "/error/errorinterno.html",
23 title: "Error interno del servidor.",
24 detail: $throwable->getMessage()
25 ));
26 }
27}
28
29function procesa_json_response(JsonResponse $response)
30{
31 $json = "";
32 $body = $response->body;
33 if ($response->status !== JsonResponse_NoContent) {
34 $json = json_encode($body);
35 if ($json === false) {
36 no_puede_generar_json();
37 return;
38 }
39 }
40 http_response_code($response->status);
41 if ($response->location !== null) {
42 header("Location: {$response->location}");
43 }
44 if ($response->status !== JsonResponse_NoContent) {
45 header("Content-Type: application/json");
46 echo $json;
47 }
48}
49
50function procesa_problem_details(ProblemDetails $details)
51{
52 $body = ["title" => $details->title];
53 if ($details->type !== null) {
54 $body["type"] = $details->type;
55 }
56 if ($details->detail !== null) {
57 $body["detail"] = $details->detail;
58 }
59 $json = json_encode($body);
60 if ($json === false) {
61 no_puede_generar_json();
62 } else {
63 http_response_code($details->status);
64 header("Content-Type: application/problem+json");
65 echo $json;
66 }
67}
68
69function no_puede_generar_json()
70{
71 http_response_code(ProblemDetails::InternalServerError);
72 header("Content-Type: application/problem+json");
73 echo '{"type":"/error/nojson.html"'
74 . ',"title":"El valor devuelto no puede representarse como JSON."}';
75}
76

3. lib / php / JsonResponse.php

1<?php
2
3const JsonResponse_OK = 200;
4const JsonResponse_Created = 201;
5const JsonResponse_NoContent = 204;
6
7class JsonResponse
8{
9
10 public int $status;
11 public $body;
12 public ?string $location;
13
14 public function __construct(
15 int $status = JsonResponse_OK,
16 $body = null,
17 ?string $location = null
18 ) {
19 $this->status = $status;
20 $this->body = $body;
21 $this->location = $location;
22 }
23
24 public static function ok($body)
25 {
26 return new JsonResponse(body: $body);
27 }
28
29 public static function created(string $location, $body)
30 {
31 return new JsonResponse(JsonResponse_Created, $body, $location);
32 }
33
34 public static function noContent()
35 {
36 return new JsonResponse(JsonResponse_NoContent, null);
37 }
38}
39

4. lib / php / leeTexto.php

1<?php
2
3/**
4 * Recupera el texto de un parámetro enviado al
5 * servidor por medio de GET, POST o cookie.
6 * Si el parámetro no se recibe, devuelve null.
7 */
8function leeTexto(string $parametro): ?string
9{
10 /* Si el parámetro está asignado en $_REQUEST,
11 * devuelve su valor; de lo contrario,
12 * devuelve null. */
13 $valor = isset($_REQUEST[$parametro])
14 ? $_REQUEST[$parametro]
15 : null;
16 return $valor;
17}
18

5. lib / php / ProblemDetails.php

1<?php
2
3class ProblemDetails extends Exception
4{
5
6 public const BadRequest = 400;
7 public const NotFound = 404;
8 public const InternalServerError = 500;
9
10 public int $status;
11 public string $title;
12 public ?string $type;
13 public ?string $detail;
14
15 public function __construct(
16 int $status,
17 string $title,
18 ?string $type = null,
19 ?string $detail = null,
20 Throwable $previous = null
21 ) {
22 parent::__construct($title, $status, $previous);
23 $this->status = $status;
24 $this->type = $type;
25 $this->title = $title;
26 $this->detail = $detail;
27 }
28}
29

6. lib / php / validaToken.php

1<?php
2
3require_once __DIR__ . "/ProblemDetails.php";
4
5const FORBIDDEN = 403;
6
7function validaToken(string $pagina, string $token)
8{
9
10 if (!isset($_SESSION[$pagina]))
11 throw new ProblemDetails(
12 status: FORBIDDEN,
13 type: "/error/paginanoregistrada.html",
14 title: "Página no registrada.",
15 );
16
17 $tokensParaPagina = $_SESSION[$pagina];
18
19 if (!is_array($tokensParaPagina))
20 throw new ProblemDetails(
21 status: FORBIDDEN,
22 type: "/error/sintokens.html",
23 title: "No hay arereglo de tokens.",
24 );
25
26 $hallado = false;
27
28 // Valida que el token se haya registrado.
29 foreach ($tokensParaPagina as $llave => $tokenParaPagina) {
30
31 if (strcmp($token, $tokenParaPagina["texto"]) === 0) {
32
33 if ($tokenParaPagina["expiracion"] < time()) {
34 unset($tokensParaPagina[$llave]);
35 $_SESSION[$pagina] = $tokensParaPagina;
36 throw new ProblemDetails(
37 status: FORBIDDEN,
38 type: "/error/paginaexpirada.html",
39 title: "Tiempo de expiración excedido.",
40 );
41 }
42
43 $hallado = true;
44 } elseif ($tokenParaPagina["expiracion"] > time()) {
45
46 // Elimina tokens expirados
47 unset($tokensParaPagina[$llave]);
48 }
49 }
50
51 $_SESSION[$pagina] = $tokensParaPagina;
52
53 if ($hallado === false)
54 throw new ProblemDetails(
55 status: FORBIDDEN,
56 type: "/error/paginanoregistrada.html",
57 title: "Página no registrada.",
58 );
59}
60

I. Carpeta « error »

Versión para imprimir.

A. error / errorinterno.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>Error interno del servidor</title>
10
11</head>
12
13<body>
14
15 <h1>Error interno del servidor</h1>
16
17 <p>Se presentó de forma inesperada un error interno del servidor.</p>
18
19</body>
20
21</html>

B. error / faltanombre.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>Falta el nombre</title>
10
11</head>
12
13<body>
14
15 <h1>Falta el nombre</h1>
16
17</body>
18
19</html>

C. error / faltasaludo.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>Falta el saludo</title>
10
11</head>
12
13<body>
14
15 <h1>Falta el saludo</h1>
16
17 <p>La solicitud no tiene el valor de saludo.</p>
18
19</body>
20
21</html>

D. error / faltatoken.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>Falta el token</title>
10
11</head>
12
13<body>
14
15 <h1>Falta el token</h1>
16
17</body>
18
19</html>

E. error / nojson.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>El valor devuelto no puede representarse como JSON</title>
10
11</head>
12
13<body>
14
15 <h1>El valor devuelto no puede representarse como JSON</h1>
16
17 <p>
18 Debido a un error interno del servidor, la respuesta generada, no se puede
19 recuperar.
20 </p>
21
22</body>
23
24</html>

F. error / nombreenblanco.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>El nombre está en blanco</title>
10
11</head>
12
13<body>
14
15 <h1>El nombre está en blanco</h1>
16
17 <p>Pon texto en el nombre.</p>
18
19</body>
20
21</html>

G. error / paginaexpirada.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>Tiempo de expiración excedido</title>
10
11</head>
12
13<body>
14
15 <h1>Tiempo de expiración excedido</h1>
16
17</body>
18
19</html>

H. error / paginanoregistrada.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>Página no registrada</title>
10
11</head>
12
13<body>
14
15 <h1>Página no registrada</h1>
16
17</body>
18
19</html>

I. error / saludoenblanco.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>El saludo está en blanco</title>
10
11</head>
12
13<body>
14
15 <h1>El saludo está en blanco</h1>
16
17 <p>Pon texto en el saludo.</p>
18
19</body>
20
21</html>

J. error / sintokens.html

1<!DOCTYPE html>
2<html lang="es">
3
4<head>
5
6 <meta charset="UTF-8">
7 <meta name="viewport" content="width=device-width">
8
9 <title>No hay arereglo de tokens</title>
10
11</head>
12
13<body>
14
15 <h1>No hay arereglo de tokens</h1>
16
17</body>
18
19</html>

J. jsconfig.json

1{
2 "compilerOptions": {
3 "checkJs": true,
4 "strictNullChecks": true,
5 "target": "ES6",
6 "module": "ES6",
7 "moduleResolution": "classic",
8 "lib": [
9 "ES2017",
10 "WebWorker",
11 "DOM"
12 ]
13 }
14}

K. Resumen