Skip to content

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.

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.

  • 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
end
end

El 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)
end

3. 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'")
end
end
# 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")
end
end

ConceptoDecisión
AlmacenamientoRAM del proceso (GenServer)
TTL5 minutos (vía DateTime.add)
One-timeSe elimina al verificar
Escaladomax-instances: 1 en Cloud Run
DependenciasSolo Erlang/OTP + Plug