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.
Requisitos funcionales del recorrido
RF01Pantalla de Inicio de SesiónRF02Autenticación con CredencialesRF04Cierre de Sesión
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
1. La persona abre SIENEP en el navegador
https://sienep-pwa.pages.dev/ingresarCloudflare 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
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
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
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
const usernameValido = username.trim().length > 0
const passwordValido = password.length > 0
const formularioValido = usernameValido && passwordValidoFormularioInicioSesion.tsx · fragmento del archivo
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.enviandoRefes una guarda inmediata: otro clic o Enter durante la solicitud no inicia otro login.formularioValidocomprueba que haya usuario y contraseña. Todavía no decide si la contraseña es correcta.
FormularioInicioSesion.tsx · fragmento del archivo
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
const respuesta = await iniciarSesion(credenciales)
if (autenticacion) {
autenticacion.establecerSesion(respuesta)autenticacionApi.ts · fragmento del archivo
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.
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
.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
@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.@RequestBodyconvierte el JSON enLoginRequestDto.@Validaplica 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
Credencial inicial = transaccion(true, () -> credenciales.leer(username).orElse(null));
if (inicial == null) rechazar(username, null, null, "USUARIO_INEXISTENTE", false);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
if (!passwordEncoder.matches(password, inicial.password()))
rechazar(username, inicial.id(), null, "PASSWORD_INCORRECTA", false);passwordes la clave recibida en la petición.inicial.password()es el hash leído de la DB.matchescomprueba 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
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
String token = jwt.generarToken(actual.id(), actual.username());
sesiones.registrar(token);JwtUtil.java · fragmento del archivo
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 conid, distingue cada emisión mediante un UUID.sub, establecido consubject, contiene el username.userIdidentifica al usuario de SIENEP.iatyexpindican 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
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
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 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
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
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
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.
GET /auth/capacidades
Authorization: Bearer JWT_LOCAL_DE_EJEMPLOEn 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
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
1. La pantalla consulta si el ingreso externo está habilitado
GET /auth/auth0/configEl 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
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
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
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
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
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
GET /auth/capacidades
Authorization: Bearer ACCESS_TOKEN_AUTH0_DE_EJEMPLOAuth0JwtDecoderFactory 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
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
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
401en el login: credenciales inválidas o cuenta inactiva. El formulario conserva el usuario, limpia la contraseña y permite reintentar.401en una operación protegida: la credencial ya no autentica. El modo local termina la sesión; el camino Auth0 puede intentar una renovación controlada antes de cerrarla.403: la persona está autenticada, pero no está autorizada para esa operación.503del almacenamiento de sesiones: no se puede confirmar la operación de sesión. Se informa un error temporal; no se interpreta como contraseña incorrecta.
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.
frontend/src/main.tsxfrontend/src/aplicacion/rutas.tsxfrontend/src/modulos/autenticacion/componentes/FormularioInicioSesion.tsxfrontend/src/modulos/autenticacion/paginas/PaginaInicioSesion.tsxfrontend/src/modulos/autenticacion/api/autenticacionApi.tsbackend/src/main/java/edu/utec/sienep/config/SecurityConfig.javabackend/src/main/java/edu/utec/sienep/controller/AuthController.javabackend/src/main/java/edu/utec/sienep/service/LoginService.javabackend/src/main/java/edu/utec/sienep/repository/CredencialRepository.javabackend/src/main/java/edu/utec/sienep/dto/LoginRequestDto.javabackend/src/main/java/edu/utec/sienep/security/JwtUtil.javabackend/src/main/java/edu/utec/sienep/service/SesionTokenService.javafrontend/src/modulos/autenticacion/sesion.tsfrontend/src/aplicacion/rutasProtegidas.tsxbackend/src/main/java/edu/utec/sienep/security/JwtFilter.javafrontend/src/compartido/permisos/contextoPermisos.tsxbackend/src/main/java/edu/utec/sienep/security/UserDetailsServiceImpl.javafrontend/src/api/cliente.tsfrontend/src/modulos/autenticacion/contextoAutenticacion.tsxfrontend/src/modulos/autenticacion/auth0/adaptadorSpa.tsbackend/src/main/java/edu/utec/sienep/security/Auth0JwtFilter.javabackend/src/main/java/edu/utec/sienep/config/Auth0JwtDecoderFactory.javafrontend/src/modulos/autenticacion/auth0/ingreso.ts