Skip to content

Commit

Permalink
refactor(jwt): add more value objects, do not use raw strings and mov…
Browse files Browse the repository at this point in the history
…e infrastructure code for working with JWT from Business Logic to infra package
  • Loading branch information
bas-kirill committed Jul 22, 2024
1 parent 9d65403 commit f0c4eb6
Show file tree
Hide file tree
Showing 24 changed files with 253 additions and 179 deletions.
2 changes: 2 additions & 0 deletions server/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ dependencies {
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-security")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
implementation("io.jsonwebtoken:jjwt-jackson:0.11.5")
Expand Down
27 changes: 27 additions & 0 deletions server/app/src/main/kotlin/mu/muse/application/jwt/JwtGenerator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package mu.muse.application.jwt

import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import mu.muse.domain.Jwt
import mu.muse.domain.User
import java.util.Date

class JwtGenerator(
private val jwtSecretKey: String,
private val jwtExpirationMillis: Long,
) {

fun execute(user: User): Jwt {
val claims = Jwts.claims().setSubject(user.id.toStringValue())
claims["role"] = user.role.toStringValue()

return Jwt.from(
Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date(System.currentTimeMillis()))
.setExpiration(Date(System.currentTimeMillis() + jwtExpirationMillis))
.signWith(SignatureAlgorithm.HS512, jwtSecretKey)
.compact(),
)
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
package mu.muse.application.muse
package mu.muse.application.jwt

import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import mu.muse.usecase.JwtUsernameExtractor
import mu.muse.usecase.JwtValidator
import mu.muse.domain.Jwt
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.web.filter.OncePerRequestFilter
Expand All @@ -25,21 +23,20 @@ class JwtTokenFilter(
return
}

val token = authHeader.substring("Bearer ".length)
val username = jwtUsernameExtractor.execute(token)
val jwt = Jwt.from(authHeader.substring("Bearer ".length))
val username = jwtUsernameExtractor.execute(jwt)

if (username != null && SecurityContextHolder.getContext().authentication == null) {
val userDetails: UserDetails = userDetailsService.loadUserByUsername(username.toStringValue())
if (SecurityContextHolder.getContext().authentication == null) {
val userDetails = userDetailsService.loadUserByUsername(username.toStringValue())

if (jwtValidator.execute(token)) {
if (jwtValidator.execute(jwt)) {
val authToken = UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.authorities,
)

authToken.details = WebAuthenticationDetailsSource().buildDetails(request)

SecurityContextHolder.getContext().authentication = authToken
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package mu.muse.application.jwt

import io.jsonwebtoken.Jwts
import mu.muse.domain.Jwt
import mu.muse.domain.Username

class JwtUsernameExtractor(private val jwtSecretKey: String) {

fun execute(jwt: Jwt): Username {
return Username.from(
Jwts.parser()
.setSigningKey(jwtSecretKey)
.parseClaimsJws(jwt.toStringValue())
.body
.subject,
)
}
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
package mu.muse.usecase.scenario
package mu.muse.application.jwt

import io.jsonwebtoken.Jwts
import mu.muse.domain.Jwt
import mu.muse.domain.Username
import mu.muse.usecase.JwtValidator
import mu.muse.usecase.access.UserExtractor
import org.springframework.security.core.userdetails.UserDetails
import java.util.*
import java.util.Date

class JwtValidatorUseCase(
class JwtValidator(
private val jwtSecretKey: String,
private val userRepository: UserExtractor
) : JwtValidator {
private val userRepository: UserExtractor,
) {

override fun execute(jwtRaw: String): Boolean {
fun execute(jwt: Jwt): Boolean {
val claims = Jwts.parser()
.setSigningKey(jwtSecretKey)
.parseClaimsJws(jwtRaw)
.parseClaimsJws(jwt.toStringValue())
.body

val username: Username = Username.from(claims.subject)
val user: UserDetails = userRepository.findByUsername(username) ?: return false

val today = Date()
val expiration = claims.expiration
return (username.toStringValue() == user.username) && (Date().before(expiration))
return (username.toStringValue() == user.username) && (today.before(expiration))
}
}
Original file line number Diff line number Diff line change
@@ -1,30 +1,34 @@
package mu.muse.application.muse

import mu.muse.common.types.Version
import mu.muse.domain.Password
import mu.muse.domain.Role
import mu.muse.domain.User
import mu.muse.domain.Username
import mu.muse.persistence.InMemoryUserRepository
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.crypto.factory.PasswordEncoderFactories

@Configuration
class PersistenceConfiguration {

@Bean
fun users(): Set<User> {
val passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()
val anonymous = User.create(
Username.from("anonymous"),
"{noop}123",
"ROLE_ANONYMOUS",
"Anonymous",
Username.from("user"),
Password.from(passwordEncoder.encode("123")),
Role.user(),
"Somebody Somebodynov",
Version.new(),
)

val editor = User.create(
Username.from("editor"),
"{noop}321",
"ROLE_EDITOR",
"Editor",
Password.from(passwordEncoder.encode("321")),
Role.editor(),
"Editor Editorov",
Version.new(),
)
return setOf(anonymous, editor)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
package mu.muse.application.muse

import mu.muse.usecase.JwtUsernameExtractor
import mu.muse.usecase.JwtValidator
import jakarta.servlet.http.HttpServletResponse
import mu.muse.application.jwt.JwtGenerator
import mu.muse.application.jwt.JwtTokenFilter
import mu.muse.application.jwt.JwtValidator
import mu.muse.usecase.access.UserExtractor
import mu.muse.application.jwt.JwtUsernameExtractor
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.core.session.SessionRegistryImpl
Expand All @@ -22,11 +25,29 @@ import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource


@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)
class SecurityConfiguration {

@Bean
fun jwtValidator(
@Value("\${security.jwt.secret-key}") jwtSecretKey: String,
userExtractor: UserExtractor,
) = JwtValidator(jwtSecretKey, userExtractor)

@Bean
fun jwtUsernameExtractor(@Value("\${security.jwt.secret-key}") jwtSecretKey: String): JwtUsernameExtractor {
return JwtUsernameExtractor(jwtSecretKey)
}

@Bean
fun jwtGenerator(
@Value("\${security.jwt.secret-key}") jwtSecretKey: String,
@Value("\${security.jwt.expiration-time}") expiration: Long,
) = JwtGenerator(jwtSecretKey, expiration)

@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val configuration = CorsConfiguration()
Expand Down Expand Up @@ -65,6 +86,13 @@ class SecurityConfiguration {
.anyRequest().authenticated()
}

http = http.exceptionHandling { exception ->
exception
.authenticationEntryPoint { _, response, _ ->
response.status = HttpServletResponse.SC_UNAUTHORIZED
}
}

http.addFilterBefore(
jwtTokenFilter,
UsernamePasswordAuthenticationFilter::class.java,
Expand All @@ -79,14 +107,14 @@ class SecurityConfiguration {
}

@Bean
fun userDetailsService(userExtractor: UserExtractor): UserDetailsServiceImpl {
fun userDetailsService(userExtractor: UserExtractor): UserDetailsService {
return UserDetailsServiceImpl(userExtractor)
}

@Bean
fun jwtTokenFilter(
jwtValidator: JwtValidator,
userDetailsService: UserDetailsServiceImpl,
userDetailsService: UserDetailsService,
jwtUsernameExtractor: JwtUsernameExtractor
): JwtTokenFilter {
return JwtTokenFilter(jwtValidator, userDetailsService, jwtUsernameExtractor)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,49 +1,19 @@
package mu.muse.application.muse

import mu.muse.usecase.JwtGenerator
import mu.muse.usecase.JwtUsernameExtractor
import mu.muse.usecase.JwtValidator
import mu.muse.usecase.BasicLogin
import mu.muse.application.jwt.JwtGenerator
import mu.muse.usecase.access.UserExtractor
import mu.muse.usecase.scenario.JwtGeneratorUseCase
import mu.muse.usecase.scenario.JwtUsernameExtractorUseCase
import mu.muse.usecase.scenario.JwtValidatorUseCase
import mu.muse.usecase.scenario.BasicLoginUseCase
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.AuthenticationManager

@Configuration
class UseCaseConfiguration {

@Bean
fun jwtValidator(
@Value("\${security.jwt.secret-key}") jwtSecretKey: String,
userExtractor: UserExtractor,
): JwtValidator {
return JwtValidatorUseCase(jwtSecretKey, userExtractor)
}

@Bean
fun jwtUsernameExtractor(@Value("\${security.jwt.secret-key}") jwtSecretKey: String): JwtUsernameExtractor {
return JwtUsernameExtractorUseCase(jwtSecretKey)
}

@Bean
fun jwtGenerator(
@Value("\${security.jwt.secret-key}") jwtSecretKey: String,
@Value("\${security.jwt.expiration-time}") expiration: Long,
): JwtGenerator {
return JwtGeneratorUseCase(jwtSecretKey, expiration)
}

@Bean
fun login(
userRepository: UserExtractor,
authenticationManager: AuthenticationManager,
jwtGenerator: JwtGenerator
): BasicLogin {
return BasicLoginUseCase(userRepository, authenticationManager, jwtGenerator)
}
userRepository: UserExtractor,
jwtGenerator: JwtGenerator,
) = BasicLoginUseCase(authenticationManager, userRepository, jwtGenerator)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package mu.muse.common.types

interface BusinessError
13 changes: 13 additions & 0 deletions server/app/src/main/kotlin/mu/muse/domain/Jwt.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package mu.muse.domain

data class Jwt internal constructor(private val value: String) {

companion object {
fun from(value: String): Jwt {
require(value.isNotBlank()) { "JWT cannot be blank" }
return Jwt(value)
}
}

fun toStringValue() = value
}
4 changes: 3 additions & 1 deletion server/app/src/main/kotlin/mu/muse/domain/Password.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package mu.muse.domain

import mu.muse.common.types.ValueObject

data class Password private constructor(val value: String) : ValueObject {
data class Password internal constructor(private val value: String) : ValueObject {

companion object {
fun from(value: String?): Password {
require(!value.isNullOrBlank()) { "Password `${value}` cannot be null or empty" }
return Password(value)
}
}

fun toPlainStringValue() = value
}
25 changes: 25 additions & 0 deletions server/app/src/main/kotlin/mu/muse/domain/Role.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package mu.muse.domain

import mu.muse.common.types.ValueObject

// used `class` instead of `enum`, because `@RolesAllowed` requires compile-time constants
data class Role internal constructor(private val value: String) : ValueObject {

companion object {
fun from(value: String): Role {
when (value) {
USER -> Role(USER)
EDITOR -> Role(EDITOR)
}
throw IllegalArgumentException("Unknown role: $value")
}

const val USER = "USER"
const val EDITOR = "EDITOR"
fun user() = Role(USER)
fun editor() = Role(EDITOR)
}

fun toStringValue() = value
fun toAuthority() = "ROLE_$value"
}
Loading

0 comments on commit f0c4eb6

Please sign in to comment.