Skip to content

Authentication

Rubem Neto edited this page Aug 8, 2024 · 9 revisions

In this section, we'll delve into how authentication works on our website and how it is implemented in Spring.

Contents

Auth with JWT

Our authentication relies on JWTs as its core mechanism. JWTs are concise encoded strings used for user identification without maintaining any server-side state (completely stateless). These JWTs are signed using public/private keys (although symmetric encryption is also possible) and can optionally be encrypted to prevent user data leakage (refer to #189).

There are two types of tokens in use:

  • Access Token: This token contains the user's email and roles and serves as a bearer token in the Authorization header (for our implementation, check the frontend). It has a short and adjustable expiration time, currently set to 60 minutes, ensuring that invalid roles are not valid for an extended period after revocation. The roles are used for stateless authorizations and the email to identify the user if necessary.

  • Refresh Token: This token is utilized to generate a new access token with updated information. It has a long and customizable expiration time, currently set at 7 days. Whenever it expires, the user is forced to re-login with their credentials.

To find the endpoints that implement these features, please consult our API Specification. It's important to note that these tokens should be held in secure storage, such as HTTP-only cookies.

Auth Service

Our authentication implementation relies on Spring Security, a highly customizable authentication and access-control framework that provides significant help in implementing JWT authentication, saving both effort and potential security concerns.

The key authentication-related methods reside in the AuthService. Below, we will provide a detailed explanation of some of these methods and demonstrate how they leverage Spring Security to accomplish their objectives:

  • authenticate(email, password)

This method, as its name suggests, is employed during the user authentication process using their email and password. In practice, its purpose is to verify the provided credentials, and if they are valid, it records the authentication in Spring's context and returns the corresponding user account. The generateAuthorities() method will be explained below.

The current implementation is as follows:

fun authenticate(email: String, password: String): Account {
    val account = accountService.getAccountByEmail(email)
    if (!passwordEncoder.matches(password, account.password)) {
        throw InvalidBearerTokenException(ErrorMessages.invalidCredentials)
    }
    val authentication = UsernamePasswordAuthenticationToken(email, password, generateAuthorities(account))
    SecurityContextHolder.getContext().authentication = authentication
    return account
}
  • generateToken(account, expiration, isRefresh)

This method generates a JWT based on the provided account and expiration time. If it's a refresh token, it excludes information about the account's roles.

The current implementation is as follows:

private fun generateToken(account: Account, expiration: Duration, isRefresh: Boolean = false): String {
    val roles = if (isRefresh) emptyList() else generateAuthorities(account)
    val scope = roles
        .stream()
        .map(GrantedAuthority::getAuthority)
        .collect(Collectors.joining(" "))
    val currentInstant = Instant.now()
    val claims = JwtClaimsSet
        .builder()
        .issuer("self")
        .issuedAt(currentInstant)
        .expiresAt(currentInstant.plus(expiration))
        .subject(account.email)
        .claim("scope", scope)
        .build()
    return jwtEncoder.encode(JwtEncoderParameters.from(claims)).tokenValue
}

The roles were previously stored in Spring's context through GrantedAuthoritys granted by Spring Security. These roles are serialized and subsequently included in the scope claim.

  • refreshAccessToken(refreshToken)

This method accepts a refresh token, validates it, and then generates a new access token based on the account information provided within the refresh token.

The current implementation is as follows:

fun refreshAccessToken(refreshToken: String): String {
    val jwt =
        try {
            jwtDecoder.decode(refreshToken)
        } catch (e: Exception) {
            throw InvalidBearerTokenException(ErrorMessages.invalidRefreshToken)
        }
    if (jwt.expiresAt?.isBefore(Instant.now()) != false) {
        throw InvalidBearerTokenException(ErrorMessages.expiredRefreshToken)
    }
    val account = accountService.getAccountByEmail(jwt.subject)
    return generateAccessToken(account)
}
  • generateAuthorities(account)

This method serves to transform the list of roles associated with the provided account into a format that Spring Security can interpret. It accomplishes this by serializing all the roles into a space-separated format and converting them into SimpleGrantedAuthority instances.

The current implementation is as follows:

private fun generateAuthorities(account: Account): List<GrantedAuthority> {
    return account.roles.map {
        it.toString().split(" ")
    }
    .flatten()
    .distinct()
    .map {
        SimpleGrantedAuthority(it)
    }
}

Configuration

Having learned how JWT tokens work and how to implement them, the remaining aspect is understanding how they are configured at first. To properly understand this, please refer to the Configuration wiki page.

To begin, a set of configuration properties is utilized to set the public/private keys used and the expiration time of the tokens:

@ConfigurationProperties(prefix = "auth")
data class AuthConfigProperties(
    val publicKey: RSAPublicKey,
    val privateKey: RSAPrivateKey,
    val jwtAccessExpirationMinutes: Long,
    val jwtRefreshExpirationDays: Long
)
  • publicKey and privateKey serve as the keys used for decoding and encoding the JWTs, respectively.
  • jwtAccessExpirationMinutes and jwtRefreshExpirationDays determine the expiration times for access tokens and refresh tokens, respectively.

Additionally, a configuration class has been created to configure all the beans essential for authentication. While we will cover some of them here, you can always refer to src/config/security/AuthConfig.kt for more details.

It's noteworthy that there are two annotations applied to the class, enabling Spring Security features and method-level authorization (for more information, see Spring Docs). Another notable aspect in the class definition is the handlerExceptionResolver. As explained in this article, this configuration prevents Spring Security's exceptions from manifesting as uncatchable runtime exceptions.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class AuthConfig(
    val authConfigProperties: AuthConfigProperties,
    @Qualifier("handlerExceptionResolver") val exceptionResolver: HandlerExceptionResolver
) 
  • securityFilterChain(http)

This method plays a key role in configuring the filter chain employed by Spring Security to determine which filters should be executed upon receiving requests.

Apart from configuring other features like CSRF and CORS, it is responsible for setting up stateless authentication and handling JWTs.

The current implementation is as follows:

@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
    return http.csrf { csrf -> csrf.disable() }.cors().and()
        .oauth2ResourceServer().jwt()
        .jwtAuthenticationConverter(rolesConverter())
        .and().authenticationEntryPoint { request, response, exception ->
            exceptionResolver.resolveException(request, response, null, exception)
        }.and()
        .sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
        .httpBasic().disable().build()
}

Let's break down the most crucial components:

oauth2ResourceServer().jwt() and jwtAuthenticationConverter(rolesConverter()) enable JWT authentication using our custom converter.

authenticationEntryPoint enables the exception resolver defined in the constructor.

sessionManagement sets the server as stateless, thus never creating an HTTP session.

httpBasic().disable() disables Spring's Basic Authentication.

  • rolesConverter()

This method configures a JWT converter to prepend a specified prefix to all authorities, which will contain information about the user's roles.

The current implementation is as follows:

fun rolesConverter(): JwtAuthenticationConverter? {
    val authoritiesConverter = JwtGrantedAuthoritiesConverter()
    authoritiesConverter.setAuthorityPrefix("ROLE_")
    val converter = JwtAuthenticationConverter()
    converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter)
    return converter
}