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

  1. 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.

  2. 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.

  3. 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).

  4. 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)

  5. 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

🔗 Ver SecurityConfig Completo

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 su uid. 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. Usa FirebaseAuthenticationFilter.

    • Cadena Protegida (@Order(2)): todas las demás rutas requieren JWT interno. Validación a través de JwtDecoder 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:

  1. Access Token (JWT interno)

    • Firmado con RSA.

    • Expira en 1h.

  2. 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 al uid 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