SIENEP · del navegador a la base

Cómo funciona el inicio de sesión

Vamos a seguir a una persona que abre SIENEP, ingresa y llega a las pantallas que tiene permitidas. Con nuestro código, paso por paso, como en el recorrido del alta de una instancia.

Lectura del código · 09/11/2026 · Frontend React + API Spring + PostgreSQL

Requisitos funcionales del recorrido

Incluye ingreso local, salida de sesión y el ingreso institucional con Google mediante Auth0 como ampliación de la autenticación.

Fuente oficial Requerimientos de Programación de UTEC, 2026 · p. 2

Podés recorrer el ingreso local o el ingreso con Google mediante Auth0. Los dos terminan en los controles de nuestra API, pero comprueban la identidad de manera diferente.

Es una guía de estudio. No pide credenciales, no se conecta a SIENEP y no modifica la app. Los botones «Copiar» copian los fragmentos del documento.

Ingreso local con usuario y contraseña

Formulario React → POST /auth/login → cuenta y BCrypt → JWT + sesiones_token → sesión en el navegador → capacidades y pantallas autorizadas

1. La persona abre SIENEP en el navegador

URL
https://sienep-pwa.pages.dev/ingresar

Cloudflare Pages entrega index.html y los archivos JavaScript y CSS del frontend. El navegador ejecuta ese JavaScript y React construye la pantalla.

En este primer pedido todavía no se está comprobando la contraseña ni consultando una cuenta en PostgreSQL. Si la PWA ya está instalada, el worker puede entregar los estáticos que tiene guardados; el ingreso sigue necesitando conexión con la API.

2. React monta la aplicación y encuentra la ruta de ingreso

main.tsx · fragmento del archivo

TSX
createRoot(contenedor).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

contenedor es el elemento root del HTML. createRoot prepara el montaje y render muestra App. Después, App comprueba la configuración y monta RouterProvider.

rutas.tsx · fragmento del archivo

TSX
path: '/ingresar',
        element: <RutaIngreso />,

path es la dirección que debe coincidir. RutaIngreso consulta el contexto de autenticación: si todavía se está restaurando una sesión, espera; si no hay una sesión, muestra PaginaInicioSesion.

Si ya existe una sesión válida para la interfaz, redirige al destino interno correspondiente. Si la persona llegó desde una ruta protegida, conserva ese destino de forma controlada. Por defecto vuelve al Inicio, /.

3. El formulario guarda lo que se escribe en su estado

FormularioInicioSesion.tsx · fragmento del archivo

TypeScript
const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')

Cada useState mantiene un valor de la pantalla. Al escribir el usuario se llama a setUsername; al escribir la contraseña, a setPassword. React actualiza los campos a partir de esos valores.

Todavía no se guardó una sesión ni se envió la contraseña. Los valores están en el formulario del navegador. El botón permanece deshabilitado cuando falta alguno.

4. Al confirmar, se valida y se evita un envío duplicado

FormularioInicioSesion.tsx · fragmento del archivo

TypeScript
const usernameValido = username.trim().length > 0
  const passwordValido = password.length > 0
  const formularioValido = usernameValido && passwordValido

FormularioInicioSesion.tsx · fragmento del archivo

TypeScript
const manejarEnvio = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()

    // Impide envíos duplicados si ya está en curso (guarda síncrona inmediata y reactiva)
    if (enviandoRef.current || cargando) {
      return
    }
  • preventDefault() evita que el formulario haga una navegación HTML tradicional. Nuestro código controla el envío.
  • enviandoRef es una guarda inmediata: otro clic o Enter durante la solicitud no inicia otro login.
  • formularioValido comprueba que haya usuario y contraseña. Todavía no decide si la contraseña es correcta.

FormularioInicioSesion.tsx · fragmento del archivo

TypeScript
await alEnviar({
          username: username.trim(),
          password,
        })

Al usuario se le aplica trim(). A la contraseña no: quitarle espacios podría cambiar la clave escrita. El formulario delega en el manejador de PaginaInicioSesion.

5. El frontend envía un POST con las credenciales

PaginaInicioSesion.tsx · fragmento del archivo

TypeScript
const respuesta = await iniciarSesion(credenciales)
    if (autenticacion) {
      autenticacion.establecerSesion(respuesta)

autenticacionApi.ts · fragmento del archivo

TypeScript
const respuesta = await clienteHttp.post<unknown>(
    '/auth/login',
    {
      username: credenciales.username,
      password: credenciales.password,
    },
    { notificarSesionExpirada: false },
  )

iniciarSesion conoce el endpoint del módulo. clienteHttp arma la URL con VITE_API_BASE_URL, serializa el objeto con JSON.stringify, agrega el tipo de contenido y ejecuta fetch.

HTTP · ejemplo ficticio
POST https://sienep-api-c8ml.onrender.com/auth/login
Content-Type: application/json

{
  "username": "profesional.ejemplo",
  "password": "CLAVE_DE_EJEMPLO"
}

El cuerpo contiene usuario y contraseña, protegidos en tránsito por HTTPS. El formulario no necesita un JWT anterior para iniciar sesión. La DB y la clave de firma del backend nunca se entregan al navegador.

notificarSesionExpirada: false evita tratar una contraseña incorrecta como si fuera el vencimiento de una sesión que ya estaba abierta. await espera la respuesta sin congelar toda la interfaz.

6. Spring acepta la ruta pública y valida el cuerpo

SecurityConfig.java · fragmento del archivo

Java
.requestMatchers(HttpMethod.POST, "/auth/login").permitAll()

permitAll() deja llegar al login sin una sesión previa. No significa que cualquier contraseña sea aceptada: esa comprobación ocurre dentro de la operación.

AuthController.java · fragmento del archivo

Java
@PostMapping("/login")
    public ResponseEntity<?> login(@Valid @RequestBody LoginRequestDto request) {
        try {
            return ResponseEntity.ok(loginService.autenticar(request.getUsername(), request.getPassword()));
  • @RequestMapping("/auth"), declarado en la clase, y @PostMapping("/login") forman la ruta completa.
  • @RequestBody convierte el JSON en LoginRequestDto.
  • @Valid aplica las restricciones del DTO. Aquí los campos tienen @NotBlank.
  • loginService.autenticar(...) delega la comprobación de la cuenta y la creación de la sesión.

El frontend valida para ayudar al usuario; el backend vuelve a validar porque también puede recibir peticiones hechas fuera de la app.

7. El backend busca la cuenta en PostgreSQL

LoginService.java · fragmento del archivo

Java
Credencial inicial = transaccion(true, () -> credenciales.leer(username).orElse(null));
        if (inicial == null) rechazar(username, null, null, "USUARIO_INEXISTENTE", false);
SQL · consulta del repositorio
SELECT id_usuario, username, password, terminos_aceptados
FROM usuarios
WHERE username = ?

CredencialRepository usa JdbcTemplate. En esta parte el acceso a la DB es JDBC; no hace falta pasar por una entidad JPA para leer estas credenciales.

El signo ? es un parámetro: el valor del usuario se pasa separado de la sentencia. La columna password contiene el hash de la contraseña local, no la clave original.

La primera lectura se hace en una transacción corta. Después se libera su conexión antes de comprobar BCrypt. Si no existe la cuenta, el camino termina con un rechazo y una auditoría del intento.

8. BCrypt compara la contraseña escrita con el hash

LoginService.java · fragmento del archivo

Java
if (!passwordEncoder.matches(password, inicial.password()))
                rechazar(username, inicial.id(), null, "PASSWORD_INCORRECTA", false);
  • password es la clave recibida en la petición.
  • inicial.password() es el hash leído de la DB.
  • matches comprueba si corresponden. No descifra el hash para recuperar una contraseña.

SecurityConfig configura un BCryptPasswordEncoder. Si la comparación falla, el controlador responde 401; no se emite un token.

Este recorrido sigue la verificación local. La copia del backend también contiene una rama condicional para cuentas de Active Directory, que solo interviene si su configuración y la lista de usuarios la habilitan. Esa rama no es el ingreso con Google/Auth0.

9. Se relee la cuenta y se comprueba que esté activa

LoginService.java · fragmento del archivo

Java
var actual = credenciales.leer(username).orElse(null);
                if (actual == null || !Objects.equals(actual.id(), inicial.id())
                        || !Objects.equals(actual.password(), inicial.password()))
                    throw new CredencialCambiadaException();
                if (!credenciales.activo(actual.id())) throw new CuentaInactivaException();
                if (!actual.terminos()) credenciales.aceptarTerminos(actual.id());

Después de verificar la clave, otra transacción relee las credenciales. Comprueba que el ID y el hash no hayan cambiado desde la primera lectura. Si cambiaron, el login se rechaza.

credenciales.activo consulta est_activo en el perfil de funcionario o estudiante. Ese estado no se toma de un campo editable del formulario ni de una afirmación del navegador.

También se conserva un comportamiento del código consultado: si los términos todavía no estaban aceptados, se actualiza terminos_aceptados. Esto describe la implementación; no representa un cuadro de aceptación nuevo en esta guía.

La relectura reduce inconsistencias, pero no se presenta como una garantía absoluta contra todas las operaciones concurrentes. Se mantiene el límite de concurrencia documentado para las sesiones.

10. Se genera el JWT y se registra su vigencia

LoginService.java · fragmento del archivo

Java
String token = jwt.generarToken(actual.id(), actual.username());
                sesiones.registrar(token);

JwtUtil.java · fragmento del archivo

Java
return Jwts.builder()
                .id(java.util.UUID.randomUUID().toString())
                .subject(username)
                .claim("userId", userId)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(key)
                .compact();
  • jti, generado con id, distingue cada emisión mediante un UUID.
  • sub, establecido con subject, contiene el username.
  • userId identifica al usuario de SIENEP.
  • iat y exp indican emisión y vencimiento.
  • signWith(key) firma el token con la clave del backend.

El JWT está firmado, no cifrado por ese método. Sus claims se pueden leer; la firma permite detectar alteraciones. Por eso no se ponen contraseñas en su contenido. Los roles y permisos no se agregan en este generador.

SesionTokenService.java · fragmento del archivo

Java
var sesion = new SesionToken();
        sesion.setIdUsuario(claims.get("userId", Integer.class));
        sesion.setHashToken(HashToken.calcular(token));
        sesion.setFecEmision(claims.getIssuedAt().toInstant());
        sesion.setFecExpiracion(claims.getExpiration().toInstant());
        TransaccionSesiones.participar(transactionManager, false, () -> sesiones.saveAndFlush(sesion));

En sesiones_token se guarda la huella SHA-256 del token, el usuario y las fechas. No se guarda el JWT completo. El registro participa en la transacción de esta fase del login; no se devuelve el token antes de que esa transacción se confirme.

Esta fila permite revocar una sesión local aunque la fecha de vencimiento del JWT todavía no haya llegado. Si falla el almacenamiento de sesiones, se informa indisponibilidad (503) en vez de aprobar una sesión sin registrar.

11. Se audita el ingreso y vuelve una respuesta JSON

LoginService.java · fragmento del archivo

Java
auditoriaService.registrarConActor("usuarios", "LOGIN", String.valueOf(respuesta.getUserId()),
                respuesta.getUserId(), Map.of("username", username, "origen", origen));
        return respuesta;

Esta parte ocurre después de confirmar la transacción de sesión. Se registra el usuario que ingresó y el origen. Los intentos rechazados tienen su propio registro LOGIN_FALLIDO.

HTTP · forma de respuesta, valores ficticios
HTTP 200 OK

{
  "token": "JWT_LOCAL_DE_EJEMPLO",
  "userId": 501,
  "username": "profesional.ejemplo"
}

El controlador devuelve LoginResponseDto como JSON. Este ejemplo muestra la forma del contrato: el texto del token es un marcador, no un JWT utilizable. La contraseña no vuelve en la respuesta.

12. React comprueba la respuesta y conserva la sesión

autenticacionApi.ts exige que la respuesta tenga un token y username no vacíos y un userId numérico. Después, el contexto llama a registrarSesionValidada.

sesion.ts · fragmento del archivo

TypeScript
export function registrarSesionValidada(
  sesion: SesionAutenticada,
  ahora = Date.now(),
): boolean {
  if (!validarSesion(sesion, ahora)) {
    return false
  }

  establecerSesionNucleo(sesion)
  persistirSesion(sesion)
  return true
}

La validación del cliente revisa estructura, claims mínimos, vencimiento y coherencia entre sub, username y userId. No verifica criptográficamente la firma ni decide la autorización: eso sigue en el backend.

sesion.ts · fragmento del archivo

TypeScript
const valor: SesionPersistida = {
    version: VERSION_SESION,
    token: sesion.token,
    userId: sesion.userId,
    username: sesion.username,
  }

  try {
    storage.setItem(CLAVE_SESION, JSON.stringify(valor))

CLAVE_SESION vale sienep.sesion.v1. La sesión se mantiene en memoria y se intenta persistir en sessionStorage para restaurarla al recargar esa pestaña. Si el almacenamiento no está disponible, el núcleo puede seguir en memoria; no se promete persistencia.

Aquí se conserva el token recibido, no la contraseña. El modo local también limpia las marcas de una sesión Auth0 anterior, para evitar mezclar identidades.

13. Se abre el destino y la API confirma los permisos

rutasProtegidas.tsx · fragmento del archivo

TSX
if (autenticacion.estaAutenticado) {
    return <Navigate replace to={destinoComoRuta(destino)} />
  }

Cuando cambia el contexto, RutaIngreso vuelve a renderizarse y dirige al destino interno. replace reemplaza esa entrada del historial. Dentro del área protegida, ProveedorPermisos consulta GET /auth/capacidades.

HTTP · esquema
GET /auth/capacidades
Authorization: Bearer JWT_LOCAL_DE_EJEMPLO

En esta petición interviene JwtFilter. El backend verifica firma y vencimiento, calcula la huella, comprueba que siga vigente para ese usuario y vuelve a cargar su estado y autoridades desde la DB.

JwtFilter.java · fragmento del archivo

Java
if (!sesiones.vigente(hash, userId)) {
                            rechazar(response, 401);
                            return;
                        }
                        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

UserDetailsServiceImpl obtiene las asignaciones efectivas y los permisos de sus roles. El endpoint devuelve las capacidades actuales. El frontend comprueba que el userId de esa respuesta sea el de su sesión y decide qué menús y rutas ofrecer.

Tener una sesión no habilita todas las acciones. La interfaz filtra por permisos, y la API debe exigirlos nuevamente cuando se consulta o modifica información. También aplica el alcance sobre los datos de cada persona.

“El navegador carga React, la persona completa sus credenciales y la app envía un POST. El backend busca la cuenta, compara la contraseña con BCrypt y comprueba su estado. Después genera un JWT y guarda su huella como sesión vigente. React recibe la respuesta, conserva la sesión y abre el destino. Las siguientes peticiones llevan el token: la API comprueba su vigencia y obtiene los permisos actuales de la base.”

Ingreso con Google mediante Auth0

SIENEP → Auth0 → Google → callback y SDK → access token → API de SIENEP → cuenta activa y capacidades

1. La pantalla consulta si el ingreso externo está habilitado

HTTP
GET /auth/auth0/config

El backend devuelve la configuración pública de Auth0. La app solo habilita los botones cuando recibe una configuración válida. Que el HTML del login cargue no demuestra que la API esté disponible.

Esta consulta no entrega una clave privada ni un Client Secret. El ingreso local sigue siendo otro camino.

2. El SDK prepara la redirección

adaptadorSpa.ts · fragmento del archivo

TypeScript
authorizationParams: {
      audience: configuracion.audience,
      redirect_uri: origen,
      scope: 'openid profile email',
    },
    cacheLocation: 'localstorage',
    useRefreshTokens: true,
    // La restauración usa el refresh token persistido y no depende de cookies de terceros.
    useRefreshTokensFallback: false,

domain y clientId identifican el tenant y la SPA. audience pide un access token para nuestra API. redirect_uri es el origen de la aplicación; ese retorno debe estar autorizado en Auth0.

El SDK @auth0/auth0-spa-js implementa Authorization Code con PKCE y gestiona state y nonce. localstorage es la ubicación de caché elegida en esta implementación; no es la persistencia del JWT local.

adaptadorSpa.ts · fragmento del archivo

TypeScript
await sdk.loginWithRedirect<EstadoAppAuth0>({
          appState: estadoApp,
          ...(opciones?.connection
            ? { authorizationParams: { connection: opciones.connection } }
            : {}),
        })

Para el botón de Google se puede pedir la conexión google-oauth2. El destino interno de SIENEP viaja en el estado de la operación y se valida antes de usarlo.

3. La persona se identifica fuera de SIENEP

SIENEP → Auth0 → Google → Auth0 → retorno a SIENEP

La contraseña de Google se introduce en el proveedor. No se envía en un POST /auth/login de SIENEP ni se compara con el hash BCrypt de nuestra DB.

Este permiso de ingreso tampoco autoriza al backend a enviar mensajes desde el Gmail personal. Calendar y Gmail del sistema tienen otra autorización OAuth.

4. React procesa el retorno y obtiene el access token

adaptadorSpa.ts · fragmento del archivo

TypeScript
const resultado = await sdk.handleRedirectCallback<EstadoAppAuth0>(url)
        return { estadoApp: resultado.appState }

Auth0 vuelve al origen con parámetros de la transacción. El SDK comprueba el retorno y gestiona el intercambio del código mediante PKCE. No se toma un usuario como autenticado por el solo hecho de leer code en la URL.

adaptadorSpa.ts · fragmento del archivo

TypeScript
token = await sdk.getTokenSilently({ cacheMode: forzarRenovacion ? 'off' : 'on' })

El adaptador obtiene un access token destinado a nuestra API. Un ID token describe la identidad para el cliente y no lo sustituye. Todavía falta que SIENEP acepte esa identidad externa.

5. La API valida el token y busca una cuenta de SIENEP

HTTP · esquema
GET /auth/capacidades
Authorization: Bearer ACCESS_TOKEN_AUTH0_DE_EJEMPLO

Auth0JwtDecoderFactory configura la validación RS256 con claves públicas del tenant, emisor, audience y tiempos del token. El filtro de Auth0 procesa la credencial que no fue autenticada como JWT local.

Auth0JwtFilter.java · fragmento del archivo

Java
List<Usuario> usuarios = usuarioRepository.findAllByCorreoIgnoreCase(correo.trim());
        if (usuarios.size() != 1) {
            // Correo ambiguo: dos cuentas que solo difieren en mayúsculas
            if (usuarios.size() > 1) log.warn("Auth0: correo asociado a {} cuentas, se rechaza", usuarios.size());
            return;
        }
        Usuario usuario = usuarios.getFirst();

El correo se obtiene del claim configurado de un token ya validado. Debe corresponder a una única cuenta registrada en SIENEP. Además, se exige un perfil de funcionario o estudiante y que su estado permita el ingreso.

Por eso una cuenta puede ingresar correctamente en Google y, aun así, ser rechazada por SIENEP. Google identifica a la persona; nuestra app decide si tiene una cuenta habilitada y qué puede hacer.

6. El contexto establece la identidad confirmada por la API

ingreso.ts · fragmento del archivo

TypeScript
tipo: 'sesion',
    sesion: {
      userId: capacidades.userId,
      administrador: capacidades.administrador,
      permisos: capacidades.permisos,
      nombreVisible: nombreVisible(perfil.nombre),
    },

El userId y los permisos efectivos salen de /auth/capacidades, no de un ID elegido en el navegador. El contexto pasa al modo auth0 y puede abrir las rutas protegidas.

La marca sienep.auth0.v1 de sessionStorage identifica el modo, pero no contiene los tokens ni toda la sesión. El SDK administra su caché en localStorage.

Este camino no emite un JWT local de SIENEP ni registra su access token en sesiones_token. La lista de sesiones locales no revoca automáticamente los tokens de Auth0.

7. Se entra al mismo sistema de permisos

Después de resolver la identidad, se usan las mismas reglas de autorización del backend y los controles del frontend. Autenticarse con Google no agrega permisos administrativos ni permite consultar los datos de otro estudiante.

La restauración y la renovación se gestionan con el SDK. Que el código esté preparado para renovar no demuestra por sí solo una prueba real después del vencimiento. El cierre institucional limpia la sesión de la app y sigue el logout de Auth0; no cierra la cuenta de Google.

“Con Google, SIENEP delega la identificación en Auth0. Al regresar, el SDK obtiene un access token para nuestra API. El backend verifica ese token, busca una cuenta activa de SIENEP por el correo configurado y devuelve sus capacidades. Recién entonces la app abre el área interna con los permisos de esa cuenta.”

Después de entrar: recarga, errores y cierre

Recargar con F5

Con el login local, el frontend intenta restaurar sienep.sesion.v1 y revisa su estructura y vencimiento. La siguiente petición protegida confirma si el servidor todavía acepta el token. Que el navegador conserve una copia no vuelve a dar de alta una sesión revocada.

Distinguir 401, 403 y 503

Cerrar una sesión local

La app llama a POST /auth/logout con el JWT. El backend revoca su fila de vigencia y responde 200 con un mensaje. El frontend limpia la sesión. Si el mismo token vuelve a usarse después de esa revocación, el servidor lo rechaza aunque su firma y su fecha todavía sean válidas.

Revocar no cancela una petición que ya había pasado el filtro. Si falla la comunicación al cerrar, borrar el almacenamiento del navegador por sí solo tampoco acredita una revocación en el servidor.

Tres piezas que no conviene mezclar

La contraseña sirve para comprobar el ingreso local. El JWT acredita las peticiones posteriores mientras siga válido y vigente. Los permisos y el alcance determinan qué puede consultar o modificar esa identidad. Son comprobaciones relacionadas, pero distintas.

Fuentes del código y alcance de esta explicación

Lectura realizada el 09/11/2026 sobre las copias conservadas en el paquete del 06/11/2026. Los bloques con nombre de archivo son fragmentos; los ejemplos HTTP usan marcadores ficticios. Esta página explica la implementación, no ejecuta un login ni certifica la configuración actual de producción.

Volver al principio ↑