Autenticación y Seguridad
La API utiliza un sistema de autenticación híbrida que combina Firebase Authentication para la identificación inicial del usuario y un sistema interno de JWT firmado para la autorización y control de acceso dentro del backend. Esto permite una integración segura con aplicaciones cliente como apps móviles que ya usan Firebase, mientras se mantiene el control detallado de permisos en el backend.
Flujo de autenticación
Login en cliente (App) vía Firebase El usuario inicia sesión en la app cliente (con email y contraseña). Firebase devuelve un ID Token firmado que representa la identidad del usuario.
Envía token a la API El cliente envía este token en la cabecera
Authorization: Bearer <firebaseToken>
al llamar a/auth/login
o/auth/register
.Validación del token Firebase en backend La API intercepta estas rutas con un filtro personalizado que:
Verifica la validez del token contra Firebase, usando su SDK.
Busca el usuario en la base de datos mediante el
uid
de Firebase.Si el usuario existe, crea un token de autenticación interna, junto a un refresh token (explicado mas adelante).
Generación de un JWT interno (propio) La API genera y devuelve un JWT firmado con claves RSA, que incluye información extendida como:
uid
role
(USER o ADMIN)isPremium
(estado del usuario)
Acceso al resto de endpoints Para todas las rutas protegidas, el cliente debe utilizar este nuevo JWT interno, no el token original de Firebase.
Estructura Técnica
Filtros de seguridad
FirebaseAuthenticationFilter
: Solo se activa en las rutas/auth/register
y/auth/login
. Valida el token Firebase recibido en la cabecera y autentica al usuario usando suuid
. Código:
@Component
class FirebaseAuthenticationFilter() : OncePerRequestFilter() {
@Autowired
private lateinit var userRepository: UserRepository
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val path = request.servletPath
val authHeader = request.getHeader("Authorization")
if (authHeader != null && authHeader.startsWith("Bearer ")) {
val token = authHeader.substring(7)
// Si es un JWT válido (formato estándar), no hacer nada, deja que siga al filtro de JWT
if (isJwtToken(token) && !isFirebaseToken(token)) {
filterChain.doFilter(request, response)
return
}
if (isFirebaseToken(token) && (path == "/auth/register" || path == "/auth/login")){
// Si es un Firebase Token, validamos y autenticamos
try {
val firebaseToken = FirebaseAuth.getInstance().verifyIdToken(token)
val user = userRepository.findByUid(firebaseToken.uid)
user?.let {
val auth = FirebaseAuthenticationToken(
it.uid,
null,
listOf(SimpleGrantedAuthority("ROLE_${it.role ?: "USER"}"))
)
SecurityContextHolder.getContext().authentication = auth
}
} catch (e: Exception) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid Firebase Token")
return
}
// Continuar con el flujo de filtros
filterChain.doFilter(request, response)
return
}
}
// Si no es un JWT ni un token Firebase válido, continuar sin autenticar
filterChain.doFilter(request, response)
}
private fun isJwtToken(token: String): Boolean {
// JWT tokens tienen estructura de 3 partes separadas por '.'
return token.matches(Regex("^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\$"))
}
private fun isFirebaseToken(token: String): Boolean {
return try {
val parts = token.split(".")
if (parts.size != 3) return false
val payload = String(Base64.getUrlDecoder().decode(parts[1]))
payload.contains("\"iss\":\"https://securetoken.google.com/")
} catch (e: Exception) {
false
}
}
}
class FirebaseAuthenticationToken(
private val uid: String,
credentials: Any?,
authorities: Collection<GrantedAuthority>
) : AbstractAuthenticationToken(authorities) {
init {
isAuthenticated = true
}
override fun getCredentials() = null
override fun getPrincipal() = uid
}
SecurityFilterChain
: Se definen dos filtros distintos:Cadena Pública (
@Order(1)
): permite el acceso sin JWT interno a/auth/register
,/auth/login
,/auth/refresh
. UsaFirebaseAuthenticationFilter
.Cadena Protegida (
@Order(2)
): todas las demás rutas requieren JWT interno. Validación a través deJwtDecoder
con conversión de roles.
Código:
// Register Y Login-> FIREBASE. Refresh publico.
@Bean
@Order(1)
fun publicFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.securityMatcher("/auth/register", "/auth/login", "/auth/refresh")
.csrf { it.disable() }
.authorizeHttpRequests { auth ->
auth.anyRequest().permitAll()
}
.addFilterBefore(firebaseAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.build()
}
// Cadena para el resto de rutas (usando JWT local firmado)
@Bean
@Order(2)
fun securedRoutes(http: HttpSecurity): SecurityFilterChain {
return http
.csrf { it.disable() }
.authorizeHttpRequests { it ->
// Usuarios \\
it.requestMatchers(HttpMethod.GET, "/users/").hasRole("ADMIN")
it.requestMatchers(HttpMethod.GET, "/users/{id}").authenticated()
it.requestMatchers(HttpMethod.PUT, "/users/{id}").authenticated()
it.requestMatchers(HttpMethod.DELETE, "/users/{id}").hasRole("ADMIN")
it.requestMatchers(HttpMethod.PUT, "users/setPremium").authenticated()
it.requestMatchers(HttpMethod.GET, "users/isPremium").authenticated()
it.requestMatchers(HttpMethod.PUT, "users/changeSubscriptionAdmin/{id}").hasRole("ADMIN")
// Vehiculos \\
it.requestMatchers(HttpMethod.POST, "/vehicles/init").authenticated()
it.requestMatchers(HttpMethod.GET, "/vehicles").authenticated()
it.requestMatchers(HttpMethod.GET, "/vehicles/{type}").authenticated()
it.requestMatchers(HttpMethod.PUT, "/vehicles/{type}").authenticated()
// Rutas \\
it.requestMatchers(HttpMethod.POST, "/routes/").authenticated()
it.requestMatchers(HttpMethod.GET, "/routes/{id}").authenticated()
it.requestMatchers(HttpMethod.GET, "/routes/user").authenticated()
it.requestMatchers(HttpMethod.PUT, "/routes/{id}").authenticated()
it.requestMatchers(HttpMethod.DELETE, "/routes/{id}").authenticated()
// Imagenes ruta \\
it.requestMatchers(HttpMethod.POST, "/routes/{routeId}/images").authenticated()
it.requestMatchers(HttpMethod.GET, "/routes/{routeId}/images").authenticated()
it.requestMatchers(HttpMethod.DELETE, "/routes/{routeId}/images/{imageId}").authenticated()
// Imagenes perfil \\
it.requestMatchers(HttpMethod.PUT, "/users/profile-image").authenticated()
it.requestMatchers(HttpMethod.GET, "/users/profile-image").authenticated()
it.requestMatchers(HttpMethod.DELETE, "/users/profile-image").authenticated()
// Pines de rutas \\
it.requestMatchers(HttpMethod.POST, "/route-pins/").authenticated()
it.requestMatchers(HttpMethod.GET, "/route-pins/route/{routeId}").authenticated()
it.requestMatchers(HttpMethod.DELETE, "/route-pins/{id}").authenticated()
it.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth2 ->
oauth2.jwt { jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
}
}
//.addFilterBefore(firebaseAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.build()
}
Conversor de roles
La información de roles se extrae del JWT mediante JwtAuthenticationConverter
, mapeando el campo role
como prefijo ROLE_
para que Spring Security lo reconozca.
@Bean
fun jwtAuthenticationConverter(): JwtAuthenticationConverter {
val converter = JwtAuthenticationConverter()
val authoritiesConverter = JwtGrantedAuthoritiesConverter().apply {
setAuthorityPrefix("ROLE_")
setAuthoritiesClaimName("role")
}
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter)
return converter
}
Generación y validación de JWT
El backend genera un JWT interno firmado con RSA para representar la sesión del usuario luego de un login exitoso. Este token es utilizado para autenticar todas las rutas protegidas del sistema.
El JWT contiene claims personalizados como:
uid
: identificador único del usuario.role
: rol de seguridad (USER
,ADMIN
).isPremium
: estado de suscripción premium.
El token tiene una expiración de 1h y es validado en cada solicitud mediante el
JwtDecoder
.
Refresh Token: Renovación de Sesiones
Un Refresh Token es un token de larga duración que permite al cliente obtener un nuevo access token
(JWT interno) sin necesidad de que el usuario vuelva a iniciar sesión con Firebase.
Esto permite:
Mantener la sesión activa sin re-autenticación constante.
Minimizar exposición del token principal.
Mejorar la experiencia de usuario, ya que si marca "Remember Me" en el cliente, no tiene que autenticarse de nuevo mientras el refresh token sea válido.
Proceso de Emisión
Al realizar login o registro exitosamente, el servidor devuelve dos tokens:
Access Token (JWT interno)
Firmado con RSA.
Expira en 1h.
Refresh Token
Firmado y almacenado.
Expira en 7 días.
Solo se transmite desde el backend al cliente una vez por sesión.
Almacenamiento Seguro
El
refresh token
es almacenado en la base de datos asociado aluid
del usuario.Es de uso único, y cada vez que el usuario vuelve a iniciar sesión, se genera un nuevo token y se reemplaza en la base de datos por el anterior si existiera.
En cada logeo, se devuelve el refresh token para que el cliente pueda enviarlo de vuelta en caso de que el
Flujo Completo de Validación y refresco del "Refresh Token"
El cliente inicia sesión marcando "Remember Me":
Se valida los datos como ya se ha explicado antes. Si son válidos se devuelve el token JWT y el refresh Token, además de este último guardarse en la base de datos asociado al
uid
del usuario.Cuando vuelve a entrar a la App, se hace una llamada a
auth/validate
pasándole el token JWT.Si es válido, si inicia sesion normalmente, usanndo el mismo JWT, sin hace rnada nuevo.
Si no es válido (401 Unauthorized), entonces se llama al
auth/refresh
pasando el refreshtoken que tiene previamente almacenado.Si el refreshtoken es válido, se crea un nuevo JWT, se renueva el refreshtoken de la base de datos, ya que es de uso único, y se devuelve de nuevo el JWT y elñ refreshtoken para que el usuario lo vuelva a almacenar.
Si no es válido, devuelve (401 Unauthorized), y en el cliente se obliga a hacer login de nuevo, cerrando la sesión y borrando todos los datos que puediera tener alamcenados.
Para garantizar una autenticación segura y duradera, el sistema utiliza JWTs con expiración corta y refresh tokens almacenados de forma persistente. Los refresh tokens son de un solo uso, se invalidan al ser usados, y están asociados a un usuario específico. Dado que los refresh tokens solo se entregan inicialmente tras un login correcto, y se almacenan en la aplicación del cliente, el sistema asume que su posesión implica legitimidad del usuario, salvo que haya un robo o compromiso del dispositivo, lo cual excede el alcance del sistema actual. Para un entorno real, podrían implementarse controles adicionales como firma de dispositivos, validación del User-Agent, o cookies HttpOnly, pero para el objetivo y alcance del proyecto, he considerado que la protección actual es suficiente y razonablemente segura.
Última actualización