Token Cache en Memoria
Sistema de tokens SMS en Elixir usando GenServer + Cloud Run con max-instances: 1
Este documento describe cómo implementar un sistema de verificación por token SMS en Elixir sin base de datos, usando un GenServer en memoria RAM, diseñado para correr en una sola instancia de Google Cloud Run.
Arquitectura
Section titled “Arquitectura”Los tokens se guardan en la memoria del proceso (GenServer) en lugar de una base de datos. Esto es válido siempre que el servicio se configure con max-instances: 1 en Cloud Run, garantizando que todas las peticiones caigan en el mismo contenedor.
¿Por qué en RAM?
Section titled “¿Por qué en RAM?”- Rapidez: Sin I/O de disco ni red.
- Simplicidad: Sin Redis, sin tablas SQL.
- Contenedor limpio: Al reiniciar la instancia, los tokens expiran naturalmente.
- Seguridad: El token se elimina tras su primer uso exitoso (one-time).
¿Y si Cloud Run escala a varias instancias?
Section titled “¿Y si Cloud Run escala a varias instancias?”Si el servicio recibe tráfico en múltiples instancias, el token generado en la instancia A no existirá en la instancia B. La solución es forzar una sola instancia:
gcloud run deploy tlw-api --max-instances=1 ...1. Servidor en Memoria (lib/tlw/cache/token_cache.ex)
Section titled “1. Servidor en Memoria (lib/tlw/cache/token_cache.ex)”defmodule Tlw.Cache.TokenCache do use GenServer
# --- Interfaz Pública ---
def start_link(_opts) do GenServer.start_link(__MODULE__, %{}, name: __MODULE__) end
@doc "Guarda un token de 6 dígitos válido por 5 minutos." def guardar_token(telefono, token) do expiracion = DateTime.utc_now() |> DateTime.add(5, :minute) GenServer.cast(__MODULE__, {:guardar, telefono, token, expiracion}) end
@doc "Verifica si el token coincide y si aún no ha expirado." def verificar_token(telefono, token_recibido) do GenServer.call(__MODULE__, {:verificar, telefono, token_recibido}) end
# --- Callbacks de GenServer ---
@impl true def init(estado_inicial) do {:ok, estado_inicial} end
@impl true def handle_cast({:guardar, telefono, token, expiracion}, estado) do nuevo_estado = Map.put(estado, telefono, %{token: token, expiracion: expiracion}) {:noreply, nuevo_estado} end
@impl true def handle_call({:verificar, telefono, token_recibido}, _from, estado) do case Map.get(estado, telefono) do %{token: ^token_recibido, expiracion: expiracion} -> if DateTime.compare(DateTime.utc_now(), expiracion) == :lt do nuevo_estado = Map.delete(estado, telefono) {:reply, :ok, nuevo_estado} else {:reply, {:error, :expirado}, estado} end
_ -> {:reply, {:error, :invalido}, estado} end endendEl operador
^(pin) compara el valor guardado contra el de la variable sin reasignarla.
2. Registro en el Supervisor (lib/tlw/application.ex)
Section titled “2. Registro en el Supervisor (lib/tlw/application.ex)”def start(_type, _args) do puerto = String.to_integer(System.get_env("PORT") || "8700")
children = [ Tlw.Cache.TokenCache, {Plug.Cowboy, scheme: :http, plug: Tlw.Router, options: [port: puerto]} ]
opts = [strategy: :one_for_one, name: Tlw.Supervisor] Supervisor.start_link(children, opts)end3. Endpoints en el Router (lib/tlw/router.ex)
Section titled “3. Endpoints en el Router (lib/tlw/router.ex)”alias Tlw.Cache.TokenCache
# POST /api/auth/solicitar-token# Body: {"telefono": "56912345678"}post "/api/auth/solicitar-token" do case conn.body_params do %{"telefono" => telefono} -> token = Integer.to_string(:rand.uniform(900000) + 99999) TokenCache.guardar_token(telefono, token)
conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(%{status: "OK", data: "Token generado con éxito (Simulado: #{token})"}))
_ -> send_resp(conn, 400, "Falta el parámetro 'telefono'") endend
# POST /api/auth/verificar-token# Body: {"telefono": "56912345678", "token": "123456"}post "/api/auth/verificar-token" do case conn.body_params do %{"telefono" => telefono, "token" => token} -> case TokenCache.verificar_token(telefono, token) do :ok -> conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(%{status: "OK", data: "Token verificado con éxito"}))
{:error, :expirado} -> conn |> put_resp_content_type("application/json") |> send_resp(400, Jason.encode!(%{status: "NOOK", data: "El token ha expirado"}))
{:error, :invalido} -> conn |> put_resp_content_type("application/json") |> send_resp(400, Jason.encode!(%{status: "NOOK", data: "Token o teléfono incorrectos"})) end
_ -> send_resp(conn, 400, "Faltan parámetros") endendResumen
Section titled “Resumen”| Concepto | Decisión |
|---|---|
| Almacenamiento | RAM del proceso (GenServer) |
| TTL | 5 minutos (vía DateTime.add) |
| One-time | Se elimina al verificar |
| Escalado | max-instances: 1 en Cloud Run |
| Dependencias | Solo Erlang/OTP + Plug |