In our previous discussion, we explored the interns of Spring Security, gaining insights into the orchestration of filters and the crucial function of the AuthenticationFilter
in user authentication. Now, we switch our attention to a practical skill: the generation of Json web tokens. This skill will enable us to tap into the advantages of token-based authentication, enabling a process of user authentication smooth when it requests the different API endpoints from our app.
This article is your guide to mastering three key aspects in three parts:
- Login via API: We'll kick things off by tackling the initial API login.
- Implementing a JWT for Persistence: Learn how to keep your users authenticated via API using the shiny new JWT.
- Connecting Database Users to Spring Security: Dive into the realm of syncing your database users seamlessly with the Spring Security system.
You might have observed that even after a successful login, the user is restricted from performing any subsequent actions. This limitation arises because we've configured all endpoints to require authentication, and with each HTTP request, the process starts anew, lacking persistence for the user. To address this, JSON web tokens(JWT) can be a valuable solution, allowing us to maintain user sessions for continued operations.
Our approach involves issuing a token with a validity period of up to 10 days. Beyond this duration, attempting further operations will result in a forbidden response, prompting the user to undergo the login process once again. To implement this, let's include the necessary dependencies in our pom.xml
.
pom.xml
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
In contrast to the Spring Security package, the dependencies include a specified version. The version mentioned here corresponds to the one in use at the time of making this article. However, feel free to check for the latest versions on the Maven Repository website and opt for the most up-to-date version.
Esuring secure encryption, a JWT incorporates a signature that involves both the encryption algorithm and a secret key influencing the encoded token's outcome. The process involves three crucial steps:
JWT Creation upon User Login: Generate a JWT when a user successfully logs in.
JWT Verification on Each Endpoint Request: Validate the JWT on every endpoint request.
Token Validation with Correct Signature and Expiry Time: Validate the token by ensuring it has the correct signature and has not expired.
Create a JWT when a user is logged in
Let's initiate the process by creating a new file within the security
package:
JwtGenerator.kt
@Component
class JwtGenerator() {
fun makeToken(username: String): String {
val secretWord = "This is my secret word for Dev.to"
val keySecretWord = Keys.hmacShaKeyFor(secretWord.toByteArray())
return Jwts.builder()
.subject(username)
.issuedAt(Date())
.expiration(Date(System.currentTimeMillis() + 864000000)) // Token for 10 days
.signWith(keySecretWord)
.compact()
}
}
We designate this class within the Spring Boot context using the @Component
annotation since it lacks Business Logic. While an @Service
alternative is plausible, the choice is flexible based on your preference. Although the use of an object
instead of a class is plausible, a future refactor will introduce the injection of the keySecretWord
variable rather than hard-coding it here.
The line val keySecretWord = Keys.hmacShaKeyFor(secretWord.toByteArray())
is crucial for creating a SecretKey
object. Here, the HMAC-SHA algorithm is employed, as detailed in the documentation. Should a different algorithm be more suitable for your needs, feel free to adjust it accordingly. The essential aspect is ensuring the presence of a SecretKey type.
It's important to note that the method Keys.hmacShaKeyFor(secretWord.toByteArray())
requires a ByteArray type as a parameter, necessitating the transformation from a String.
Transitioning to the second part of the code, its responsibility lies in generating a new JWT:
return Jwts.builder()
.subject(username)
.issuedAt(Date())
.expiration(Date(System.currentTimeMillis() + 864000000)) // Token for 10 days
.signWith(keySecretWord)
.compact()
- The
Jwts.builder()
starts the creation of a new JWT based on the information specified in the subsequent commands. - The
subject
command defines the payload content; in this instance, we set the subject as the username, a parameter passed when calling the method. - The
issuedAt
command generates a timestamp indicating when the token is generated. - The
expiration
command determines when the generated token will expire. In our scenario, we set it to +10 days, but you can choose any time duration in milliseconds. - The
signWith
command configures a signature using our encrypted secret word, enhancing the security of the JWT. - The
compact
statement serves as the final directive to the builder, telling our builder that it's time to assemble the instance.
With this file, we can now integrate it into our login controller.
LoginController.kt
@RestController
class LoginController(
val authenticationManager: AuthenticationManager,
val jwtGenerator: JwtGenerator) {
@PostMapping("/login")
fun login(@RequestBody loginRequest: LoginRequest): ResponseEntity<Void> {
val authenticationRequest = UsernamePasswordAuthenticationToken.unauthenticated(
loginRequest.username,
loginRequest.password
)
val authenticationResponse = authenticationManager.authenticate(authenticationRequest)
val token = jwtGenerator.makeToken(loginRequest.username)
return ResponseEntity.ok(LoginResponse(token))
}
data class LoginRequest(val username: String, val password: String)
data class LoginResponse(val token: String)
}
- The constructor injects the
JwtGenerator
. - A token is generated using
jwtGenerator.makeToken(loginRequest.username)
and stored in a variable. - If the token creation is successful, it returns the
LoginResponse
object. - The
LoginResponse
is a data class (data class LoginResponse(val token: String)
) designed for returning data in JSON format. The response contains the generated token.
This is the outcome of the login process now:
Verify JWT
To make use of the received JWT, we need a mechanism to inform Spring Security that the token is verified. If the verification is successful, the user can then access the requested resource. Let's implement a token filter within the security
package:
JwtTokenFilter.kt
@Component
class JwtTokenFilter() : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val secretWord = "This is my secret word for Dev.to"
val keySecretWord = Keys.hmacShaKeyFor(secretWord.toByteArray())
try {
extractToken(request.getHeader("Authorization"))?.let { token ->
Jwts.parser()
.verifyWith(keySecretWord)
.build()
.parseSignedClaims(token)
.payload
.subject
.let { user ->
SecurityContextHolder.getContext().authentication =
UsernamePasswordAuthenticationToken(user, null, emptyList())
}
}
} catch (e: SignatureException) {
SecurityContextHolder.clearContext()
}
filterChain.doFilter(request, response)
}
private fun extractToken(authorizationHeaders: String?): String? {
return authorizationHeaders?.takeIf { it.startsWith("Bearer ") }?.substring(7)
}
This class inherits from OncePerRequestFilter
, a subclass of GenericFilterBean
. Understanding the difference between these two is crucial:
GenericFilterBean
is a special class where you must implement thedoFilterInternal
function. The logic you put inside will be encapsulated and run arbitrarily through Spring Security filters. Spring Security determines when this logic runs in the filter chain but can apply it multiple times.OncePerRequestFilter
is similar to the above class but with a crucial distinction: the logic runs only once. It forces Spring Security to confirm that if the logic is validated, there is no need to revisit and re-check it.
The implementation convention for the doFilter
method is as follows:
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
Making sure that you include the parameters in this method is vital. At the end of our method, we execute filterChain.doFilter(request, response)
. This statement ensures that the filters from the filter chain continue to run. Forgetting this line may break the flow and result in unexpected behavior.
Regarding Keys.hmacShaKeyFor
, it is not necessary to provide an additional explanation. Just remember that it must exactly match the value used as a String previously. We will refactor it later to centralize this information, but for now, we'll proceed with a small proof of concept and make adjustments afterwards.
extractToken(request.getHeader("Authorization"))?.let { token ->
Jwts.parser()
.verifyWith(keySecretWord)
.build()
.parseSignedClaims(token)
.payload
.subject
.let { user ->
SecurityContextHolder.getContext().authentication =
UsernamePasswordAuthenticationToken(user, null, emptyList())
}
}
} catch (e: SignatureException) {
SecurityContextHolder.clearContext()
}
filterChain.doFilter(request, response)
}
- We extract the token based on the helper method mentioned at the end of the file. This small helper method takes the first 7 characters from the authentication in case it exists. Typically, for JWT authentication, a common convention is to add the prefix "Bearer" and then the token. You can use any prefix, but it's crucial to consider the character count for extracting the prefix and white space. In ThunderClient for VSCode, there's an option for "Bearer" token type authentication, allowing easy inclusion of the prefix.
If, for some reason, your API client lacks an authorization section, you can manually add it to the headers.
- Once the token is extracted from "Bearer," we pass the value into a new context named token using the
let
command. The?
before ensures that we have a value or return null otherwise, preventing the execution of the following block context if no value is present. - The JWT parser is invoked to transform the encrypted JWT into a proper payload for reading. The same
secretKeyword
signature is used to apply the algorithm. It's crucial to use the same string; otherwise, the process will fail. - The token is built, and its signature is verified with
parsedSignalClaims
, where it checks the algorithm and secret word inside the token against our own signature (HMAC-SHA algorithm and the secret word). - We extract the payload (the content of the returned token, where the
username
was placed in the subject during token generation). Thesubject
is extracted, holding the username. -
In the following critical process, if we have a username, a new context with
let
is invoked to pass the username as a value.SecurityContextHolder
is invoked manually. Remember, theSecurityContext
holds all the critical information. We're manually stating that this token corresponds to an authenticated user. We save the authentication as a newUsernamePasswordAuthenticationToken
object, holding our username. The three parameters passed are:- Principal, which holds the username.
- Credentials, holding the password. As we do not store a password in our JWT, it's set as null.
- Authorities, representing the roles of the user. This is related to the authorization topic, which we do not address here. If needed, you could store the user's role in the payload and set it here. In our case, we pass an empty list because we treat all users on the website as the same type.
With this file, we can filter and check the provided token from the user, ensuring its validity based on our JWT signature, and set our user as authenticated. One part is missing: ensuring that our JWT filter is applied before the authentication filters like BasicAuthenticationFilter
or UsernameAndPasswordFilter
. Let's update our security filter chain method to instruct the framework to apply our custom filter before the authentication filters.
SecurityConfig.kt
@Configuration
class SecurityConfig {
// Other methods....
@Bean
fun securityFilterChain(http: HttpSecurity, jwtTokenFilter: JwtTokenFilter): SecurityFilterChain {
http {
csrf { disable() }
addFilterBefore<UsernamePasswordAuthenticationFilter>(jwtTokenFilter)
authorizeHttpRequests {
authorize("/login", permitAll)
authorize(anyRequest, authenticated)
}
}
return http.build()
}
}
The JwtTokenFilter
is injected, and the method addFilterBefore
is specified. In our language, addFilterBefore<UsernamePasswordAuthenticationFilter>(jwtTokenFilter)
indicates: Before the UserNamePasswordAuthenticationFilter
, please apply the custom jwtTokenFilter
. Now our filter placement is not arbitrary; we have deliberately decided where to position it. This ensures that an authenticated user is created before all the necessary checks are applied, facilitating the framework's operation and allowing us access to the endpoints.
Validate JWT
This is already looking good, but currently, only the signature is being checked. What if we also want to verify the expiration date? Same as before, the validation logic will be implemented in another file in the security
package, where we set the same secret word and algorithm to transform it into a SecretKey and then validate.
JwtTokenValidator.kt
class JwtTokenValidator {
fun validateToken(token: String) {
val secretWord = "This is my secret word for Dev.to"
val keySecretWord = Keys.hmacShaKeyFor(secretWord.toByteArray())
try {
val jws: Jws<Claims> = Jwts.parser()
.verifyWith(keySecretWord)
.build()
.parseSignedClaims(token)
if (jws.payload.expiration.before(Date())) {
throw RuntimeException("Token is expired")
}
} catch (e: SignatureException) {
throw RuntimeException("Invalid Token Signature", e)
} catch (e: Exception) {
throw RuntimeException("Token Validation Failed", e)
}
}
}
In this case, I've chosen not to annotate it with @Component
since I will only use it in the JwtTokenFilter
, and I already have all the necessary information to make it work. Therefore, the Spring context is not needed to manage this file. Focusing on this snippet:
val jws: Jws<Claims> = Jwts.parser()
.verifyWith(keySecretWord)
.build()
.parseSignedClaims(token)
if (jws.payload.expiration.before(Date())) {
throw RuntimeException("Token is expired")
}
As in the filter, it is parsed from what is received as a token into an object that can be processed. In this case, we check again the signature with verifyWith and parseSignedClaims
. In reality, this step could be skipped since we are already checking it before in the filter, but I think it does not harm to check it out twice, and it's not computationally expensive if checked twice.
The interesting part is when we are extracting the expiration, and it is compared with a Date()
that will return your current system time. If it's expired, then an exception is thrown. We can test this validation if we change the expiring date from our JWT when it's generated.
Let's add the validation file to our filter:
JwtTokenFilter.kt
class JwtTokenFilter() : OncePerRequestFilter() {
private val tokenValidator = JwtTokenValidator()
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
// Secret words variables...
try {
extractToken(request.getHeader("Authorization"))?.let { token ->
tokenValidator.validateToken(token)
Jwts.parser()
.verifyWith(keySecretWord)
// rest of the method...
}
}
}
Notice how I added tokenValidator
as a property in the class, and then when the token is checked via let
, before proceeding, we apply our validateToken
by sending the token.
You can change your JWT generator milliseconds option to only last for 10 seconds; try to log in and check an endpoint constantly with the token for the following 10 seconds, and you will notice that eventually, it will return a 403 forbidden
.
Refactoring the secret word
It is not ideal to have the same secret word defined and transformed into SecretKey
up to three times. Let's add the word in our application.properties.
application.properties
jwt.secret-word=This is my secret word for Dev.to
In our SecurityConfig file, we can retrieve it as a @Value
and create a @Bean
for it.
SecurityConfig.kt
@Configuration
class SecurityConfig {
@Value("\${jwt.secret-word}")
val secretWord: String? = null
@Bean
fun jwtSecretKeyword(): SecretKey = Keys.hmacShaKeyFor(secretWord?.toByteArray())
// The other methods that we worked through this tutorial
}
@Value
injects into secretWord
the value of our application.properties
. Since this is Kotlin, the $
has a special meaning, and we need to escape it to make it work for the value. Now it's only a matter of injecting it into the constructor of the generator and the filter:
JwtGenerator.kt
@Component
class JwtGenerator(private val jwtSecretKeyword: SecretKey) {
fun makeToken(username: String): String {
// I removed the secretWord
return Jwts.builder()
// Methods from the builder
.signWith(jwtSecretKeyword) // injected secret word
// Rest of the method
}
}
JwtTokenFilter.kt
@Component
class JwtTokenFilter(private val jwtSecretKeyword: SecretKey) : OncePerRequestFilter() {
private val tokenValidator = JwtTokenValidator(jwtSecretKeyword) // I am sending the secret word from the constructor to the validator
// change .verifyWith(keySecretWord) with .verifyWith(jwtSecretKeyword)
In our validator too:
JwtTokenValidator.kt
class JwtTokenValidator(private val jwtSecretKeyword: SecretKey) {
// change .verifyWith(keySecretWord) with .verifyWith(jwtSecretKeyword)
}
This is why I wanted to go with a class since the beginning with this file. If we didn't have anything in the constructor, we could have an object
instead of a class, which would have ensured that this would be a singleton in our app. However, since this will only be used in the TokenFilter
and it will be already a bean singleton, our validator should be safe as well.
One last refactoring to apply: Our Controller is managing the login system from the previous section. We could move the authentication process to a service, and then our controller would only be able to receive the HTTP call and send the info to the different services that will manage the logic.
Let's create an AuthenticatorService
in our security
package:
AuthenticatorService.kt
@Service
class AuthenticatorService(val authenticationManager: AuthenticationManager) {
fun authenticate(username: String, password: String) {
val authenticationRequest =
UsernamePasswordAuthenticationToken.unauthenticated(
username, password
)
authenticationManager.authenticate(authenticationRequest)
}
}
And our LoginController
will look like this:
LoginController.kt
@RestController
class LoginController(
val jwtGenerator: JwtGenerator,
val authenticatorService: AuthenticatorService,
) {
@PostMapping("/login")
fun login(@RequestBody loginRequest: LoginRequest): ResponseEntity<LoginResponse> {
authenticatorService.authenticate(loginRequest.username, loginRequest.password)
val token = jwtGenerator.makeToken(loginRequest.username)
return ResponseEntity.ok(LoginResponse(token))
}
}
In this section, we learned:
- Creating our own JWT when a user logs in
- Know about how to add custom filters in the
FilterChain
and verify from there our JWT - Place our custom filter in a specific order
- Validate our token to make sure that the signature and expiry date are still valid
Top comments (0)