From f0c4eb6e6b5f7873f307fa343de500311f72569f Mon Sep 17 00:00:00 2001 From: skywalker Date: Mon, 22 Jul 2024 17:21:21 +0300 Subject: [PATCH] refactor(jwt): add more value objects, do not use raw strings and move infrastructure code for working with JWT from Business Logic to infra package --- server/app/build.gradle.kts | 2 + .../mu/muse/application/jwt/JwtGenerator.kt | 27 ++++++++ .../{muse => jwt}/JwtTokenFilter.kt | 17 ++--- .../application/jwt/JwtUsernameExtractor.kt | 18 +++++ .../jwt/JwtValidator.kt} | 20 +++--- .../muse/PersistenceConfiguration.kt | 18 +++-- .../application/muse/SecurityConfiguration.kt | 38 +++++++++-- .../application/muse/UseCaseConfiguration.kt | 38 ++--------- .../mu/muse/common/types/BusinessError.kt | 3 + .../app/src/main/kotlin/mu/muse/domain/Jwt.kt | 13 ++++ .../main/kotlin/mu/muse/domain/Password.kt | 4 +- .../src/main/kotlin/mu/muse/domain/Role.kt | 25 +++++++ .../src/main/kotlin/mu/muse/domain/User.kt | 49 ++++---------- .../main/kotlin/mu/muse/domain/Username.kt | 2 +- .../kotlin/mu/muse/rest/BasicLoginEndpoint.kt | 4 +- .../main/kotlin/mu/muse/rest/HelloEndpoint.kt | 3 +- .../main/kotlin/mu/muse/usecase/BasicLogin.kt | 7 +- .../kotlin/mu/muse/usecase/JwtGenerator.kt | 8 --- .../mu/muse/usecase/JwtUsernameExtractor.kt | 8 --- .../kotlin/mu/muse/usecase/JwtValidator.kt | 6 -- .../usecase/scenario/BasicLoginUseCase.kt | 14 ++-- .../usecase/scenario/JwtGeneratorUseCase.kt | 25 ------- .../scenario/JwtUsernameExtractorUseCase.kt | 18 ----- .../test/kotlin/mu/muse/HelloEndpointTest.kt | 65 ++++++++++++++++++- 24 files changed, 253 insertions(+), 179 deletions(-) create mode 100644 server/app/src/main/kotlin/mu/muse/application/jwt/JwtGenerator.kt rename server/app/src/main/kotlin/mu/muse/application/{muse => jwt}/JwtTokenFilter.kt (73%) create mode 100644 server/app/src/main/kotlin/mu/muse/application/jwt/JwtUsernameExtractor.kt rename server/app/src/main/kotlin/mu/muse/{usecase/scenario/JwtValidatorUseCase.kt => application/jwt/JwtValidator.kt} (58%) create mode 100644 server/app/src/main/kotlin/mu/muse/common/types/BusinessError.kt create mode 100644 server/app/src/main/kotlin/mu/muse/domain/Jwt.kt create mode 100644 server/app/src/main/kotlin/mu/muse/domain/Role.kt delete mode 100644 server/app/src/main/kotlin/mu/muse/usecase/JwtGenerator.kt delete mode 100644 server/app/src/main/kotlin/mu/muse/usecase/JwtUsernameExtractor.kt delete mode 100644 server/app/src/main/kotlin/mu/muse/usecase/JwtValidator.kt delete mode 100644 server/app/src/main/kotlin/mu/muse/usecase/scenario/JwtGeneratorUseCase.kt delete mode 100644 server/app/src/main/kotlin/mu/muse/usecase/scenario/JwtUsernameExtractorUseCase.kt diff --git a/server/app/build.gradle.kts b/server/app/build.gradle.kts index f8c5c8e1..52f226a9 100644 --- a/server/app/build.gradle.kts +++ b/server/app/build.gradle.kts @@ -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") diff --git a/server/app/src/main/kotlin/mu/muse/application/jwt/JwtGenerator.kt b/server/app/src/main/kotlin/mu/muse/application/jwt/JwtGenerator.kt new file mode 100644 index 00000000..66d85f6b --- /dev/null +++ b/server/app/src/main/kotlin/mu/muse/application/jwt/JwtGenerator.kt @@ -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(), + ) + } +} diff --git a/server/app/src/main/kotlin/mu/muse/application/muse/JwtTokenFilter.kt b/server/app/src/main/kotlin/mu/muse/application/jwt/JwtTokenFilter.kt similarity index 73% rename from server/app/src/main/kotlin/mu/muse/application/muse/JwtTokenFilter.kt rename to server/app/src/main/kotlin/mu/muse/application/jwt/JwtTokenFilter.kt index 85cd0a05..e0080bb8 100644 --- a/server/app/src/main/kotlin/mu/muse/application/muse/JwtTokenFilter.kt +++ b/server/app/src/main/kotlin/mu/muse/application/jwt/JwtTokenFilter.kt @@ -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 @@ -25,13 +23,13 @@ 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, @@ -39,7 +37,6 @@ class JwtTokenFilter( ) authToken.details = WebAuthenticationDetailsSource().buildDetails(request) - SecurityContextHolder.getContext().authentication = authToken } } diff --git a/server/app/src/main/kotlin/mu/muse/application/jwt/JwtUsernameExtractor.kt b/server/app/src/main/kotlin/mu/muse/application/jwt/JwtUsernameExtractor.kt new file mode 100644 index 00000000..da83a625 --- /dev/null +++ b/server/app/src/main/kotlin/mu/muse/application/jwt/JwtUsernameExtractor.kt @@ -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, + ) + } +} diff --git a/server/app/src/main/kotlin/mu/muse/usecase/scenario/JwtValidatorUseCase.kt b/server/app/src/main/kotlin/mu/muse/application/jwt/JwtValidator.kt similarity index 58% rename from server/app/src/main/kotlin/mu/muse/usecase/scenario/JwtValidatorUseCase.kt rename to server/app/src/main/kotlin/mu/muse/application/jwt/JwtValidator.kt index 7123b041..8c5745ce 100644 --- a/server/app/src/main/kotlin/mu/muse/usecase/scenario/JwtValidatorUseCase.kt +++ b/server/app/src/main/kotlin/mu/muse/application/jwt/JwtValidator.kt @@ -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)) } } diff --git a/server/app/src/main/kotlin/mu/muse/application/muse/PersistenceConfiguration.kt b/server/app/src/main/kotlin/mu/muse/application/muse/PersistenceConfiguration.kt index 82baf5cb..d290ab1d 100644 --- a/server/app/src/main/kotlin/mu/muse/application/muse/PersistenceConfiguration.kt +++ b/server/app/src/main/kotlin/mu/muse/application/muse/PersistenceConfiguration.kt @@ -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 { + 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) diff --git a/server/app/src/main/kotlin/mu/muse/application/muse/SecurityConfiguration.kt b/server/app/src/main/kotlin/mu/muse/application/muse/SecurityConfiguration.kt index dd03da45..cbe07f63 100644 --- a/server/app/src/main/kotlin/mu/muse/application/muse/SecurityConfiguration.kt +++ b/server/app/src/main/kotlin/mu/muse/application/muse/SecurityConfiguration.kt @@ -1,8 +1,12 @@ 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 @@ -10,7 +14,6 @@ import org.springframework.security.config.annotation.authentication.configurati 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 @@ -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() @@ -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, @@ -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) diff --git a/server/app/src/main/kotlin/mu/muse/application/muse/UseCaseConfiguration.kt b/server/app/src/main/kotlin/mu/muse/application/muse/UseCaseConfiguration.kt index 251e5e23..27397713 100644 --- a/server/app/src/main/kotlin/mu/muse/application/muse/UseCaseConfiguration.kt +++ b/server/app/src/main/kotlin/mu/muse/application/muse/UseCaseConfiguration.kt @@ -1,15 +1,8 @@ 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 @@ -17,33 +10,10 @@ 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) } diff --git a/server/app/src/main/kotlin/mu/muse/common/types/BusinessError.kt b/server/app/src/main/kotlin/mu/muse/common/types/BusinessError.kt new file mode 100644 index 00000000..b1859a4e --- /dev/null +++ b/server/app/src/main/kotlin/mu/muse/common/types/BusinessError.kt @@ -0,0 +1,3 @@ +package mu.muse.common.types + +interface BusinessError diff --git a/server/app/src/main/kotlin/mu/muse/domain/Jwt.kt b/server/app/src/main/kotlin/mu/muse/domain/Jwt.kt new file mode 100644 index 00000000..6b246349 --- /dev/null +++ b/server/app/src/main/kotlin/mu/muse/domain/Jwt.kt @@ -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 +} diff --git a/server/app/src/main/kotlin/mu/muse/domain/Password.kt b/server/app/src/main/kotlin/mu/muse/domain/Password.kt index 41beee73..f37c1597 100644 --- a/server/app/src/main/kotlin/mu/muse/domain/Password.kt +++ b/server/app/src/main/kotlin/mu/muse/domain/Password.kt @@ -2,7 +2,7 @@ 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 { @@ -10,4 +10,6 @@ data class Password private constructor(val value: String) : ValueObject { return Password(value) } } + + fun toPlainStringValue() = value } diff --git a/server/app/src/main/kotlin/mu/muse/domain/Role.kt b/server/app/src/main/kotlin/mu/muse/domain/Role.kt new file mode 100644 index 00000000..df59a8a4 --- /dev/null +++ b/server/app/src/main/kotlin/mu/muse/domain/Role.kt @@ -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" +} diff --git a/server/app/src/main/kotlin/mu/muse/domain/User.kt b/server/app/src/main/kotlin/mu/muse/domain/User.kt index 31f5683d..f48cb7cd 100644 --- a/server/app/src/main/kotlin/mu/muse/domain/User.kt +++ b/server/app/src/main/kotlin/mu/muse/domain/User.kt @@ -2,55 +2,34 @@ package mu.muse.domain import mu.muse.common.types.AggregateRoot import mu.muse.common.types.Version -import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.userdetails.UserDetails -class User private constructor( +class User internal constructor( id: Username, - private val password: String, - internal val authority: String, - private val fullName: String, + val password: Password, + val role: Role, + val fullName: String, version: Version, ) : AggregateRoot(id, version), UserDetails { companion object { fun create( id: Username, - password: String, - authority: String, + password: Password, + role: Role, fullName: String, version: Version, ): User { - return User(id, password, authority, fullName, version) + return User(id, password, role, fullName, version) } } - override fun getAuthorities(): Collection { - return listOf(SimpleGrantedAuthority(authority)) - } - - override fun getPassword(): String { - return password - } - - override fun getUsername(): String { - return id.toStringValue() - } - - override fun isAccountNonExpired(): Boolean { - return true - } - - override fun isAccountNonLocked(): Boolean { - return true - } - - override fun isCredentialsNonExpired(): Boolean { - return true - } - - override fun isEnabled(): Boolean { - return true - } + override fun getAuthorities() = listOf(SimpleGrantedAuthority(role.toAuthority())) + override fun getPassword() = password.toPlainStringValue() + override fun getUsername() = id.toStringValue() + override fun isAccountNonExpired() = true + override fun isAccountNonLocked() = true + override fun isCredentialsNonExpired() = true + override fun isEnabled() = true } diff --git a/server/app/src/main/kotlin/mu/muse/domain/Username.kt b/server/app/src/main/kotlin/mu/muse/domain/Username.kt index 207d9f78..5e41e7f7 100644 --- a/server/app/src/main/kotlin/mu/muse/domain/Username.kt +++ b/server/app/src/main/kotlin/mu/muse/domain/Username.kt @@ -2,7 +2,7 @@ package mu.muse.domain import mu.muse.common.types.ValueObject -data class Username internal constructor(internal val value: String) : ValueObject { +data class Username internal constructor(private val value: String) : ValueObject { fun toStringValue() = value diff --git a/server/app/src/main/kotlin/mu/muse/rest/BasicLoginEndpoint.kt b/server/app/src/main/kotlin/mu/muse/rest/BasicLoginEndpoint.kt index 92a1507b..5967eee0 100644 --- a/server/app/src/main/kotlin/mu/muse/rest/BasicLoginEndpoint.kt +++ b/server/app/src/main/kotlin/mu/muse/rest/BasicLoginEndpoint.kt @@ -16,8 +16,8 @@ class BasicLoginEndpoint(private val basicLogin: BasicLogin) { fun login(@RequestBody request: Request): Response { val id = Username.from(request.username) val password = Password.from(request.password) - val jwtToken = basicLogin.execute(id, password) - return Response(jwtToken) + val jwt = basicLogin.execute(id, password) + return Response(jwt.toStringValue()) } data class Request(val username: String, val password: String) diff --git a/server/app/src/main/kotlin/mu/muse/rest/HelloEndpoint.kt b/server/app/src/main/kotlin/mu/muse/rest/HelloEndpoint.kt index a6511b96..a2b6c1c8 100644 --- a/server/app/src/main/kotlin/mu/muse/rest/HelloEndpoint.kt +++ b/server/app/src/main/kotlin/mu/muse/rest/HelloEndpoint.kt @@ -1,6 +1,7 @@ package mu.muse.rest import jakarta.annotation.security.RolesAllowed +import mu.muse.domain.Role import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController @@ -8,7 +9,7 @@ import org.springframework.web.bind.annotation.RestController @RestController class HelloEndpoint { - @RolesAllowed("EDITOR") + @RolesAllowed(Role.EDITOR) @GetMapping("/hello") fun hello(): String { return "Hello, World!" diff --git a/server/app/src/main/kotlin/mu/muse/usecase/BasicLogin.kt b/server/app/src/main/kotlin/mu/muse/usecase/BasicLogin.kt index 1d4d054b..20936c8c 100644 --- a/server/app/src/main/kotlin/mu/muse/usecase/BasicLogin.kt +++ b/server/app/src/main/kotlin/mu/muse/usecase/BasicLogin.kt @@ -1,9 +1,14 @@ package mu.muse.usecase +import mu.muse.common.types.BusinessError +import mu.muse.domain.Jwt import mu.muse.domain.Password import mu.muse.domain.Username fun interface BasicLogin { + fun execute(username: Username, password: Password): Jwt +} - fun execute(username: Username, password: Password): String +sealed class BasicLoginError : BusinessError { + data class UserNotFound(val username: Username) : RuntimeException("User `${username.toStringValue()}` not found") } diff --git a/server/app/src/main/kotlin/mu/muse/usecase/JwtGenerator.kt b/server/app/src/main/kotlin/mu/muse/usecase/JwtGenerator.kt deleted file mode 100644 index 48ecad8a..00000000 --- a/server/app/src/main/kotlin/mu/muse/usecase/JwtGenerator.kt +++ /dev/null @@ -1,8 +0,0 @@ -package mu.muse.usecase - -import mu.muse.domain.User - -fun interface JwtGenerator { - - fun execute(user: User): String -} diff --git a/server/app/src/main/kotlin/mu/muse/usecase/JwtUsernameExtractor.kt b/server/app/src/main/kotlin/mu/muse/usecase/JwtUsernameExtractor.kt deleted file mode 100644 index 726ba059..00000000 --- a/server/app/src/main/kotlin/mu/muse/usecase/JwtUsernameExtractor.kt +++ /dev/null @@ -1,8 +0,0 @@ -package mu.muse.usecase - -import mu.muse.domain.Username - -fun interface JwtUsernameExtractor { - - fun execute(jwtRaw: String?): Username? -} diff --git a/server/app/src/main/kotlin/mu/muse/usecase/JwtValidator.kt b/server/app/src/main/kotlin/mu/muse/usecase/JwtValidator.kt deleted file mode 100644 index ec5bf1aa..00000000 --- a/server/app/src/main/kotlin/mu/muse/usecase/JwtValidator.kt +++ /dev/null @@ -1,6 +0,0 @@ -package mu.muse.usecase - -fun interface JwtValidator { - - fun execute(jwtRaw: String): Boolean -} diff --git a/server/app/src/main/kotlin/mu/muse/usecase/scenario/BasicLoginUseCase.kt b/server/app/src/main/kotlin/mu/muse/usecase/scenario/BasicLoginUseCase.kt index 2911981b..5c4b5bfb 100644 --- a/server/app/src/main/kotlin/mu/muse/usecase/scenario/BasicLoginUseCase.kt +++ b/server/app/src/main/kotlin/mu/muse/usecase/scenario/BasicLoginUseCase.kt @@ -1,27 +1,29 @@ package mu.muse.usecase.scenario +import mu.muse.application.jwt.JwtGenerator +import mu.muse.domain.Jwt import mu.muse.domain.Password import mu.muse.domain.Username -import mu.muse.usecase.JwtGenerator import mu.muse.usecase.BasicLogin +import mu.muse.usecase.BasicLoginError import mu.muse.usecase.access.UserExtractor import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.UsernamePasswordAuthenticationToken class BasicLoginUseCase( - private val userRepository: UserExtractor, private val authenticationManager: AuthenticationManager, + private val userRepository: UserExtractor, private val jwtGenerator: JwtGenerator, ) : BasicLogin { - override fun execute(username: Username, password: Password): String { + override fun execute(username: Username, password: Password): Jwt { authenticationManager.authenticate( UsernamePasswordAuthenticationToken( - username.value, - password.value, + username.toStringValue(), + password.toPlainStringValue(), ), ) - val user = userRepository.findByUsername(username) ?: throw IllegalArgumentException("User not found") + val user = userRepository.findByUsername(username) ?: throw BasicLoginError.UserNotFound(username) return jwtGenerator.execute(user) } } diff --git a/server/app/src/main/kotlin/mu/muse/usecase/scenario/JwtGeneratorUseCase.kt b/server/app/src/main/kotlin/mu/muse/usecase/scenario/JwtGeneratorUseCase.kt deleted file mode 100644 index 27139e86..00000000 --- a/server/app/src/main/kotlin/mu/muse/usecase/scenario/JwtGeneratorUseCase.kt +++ /dev/null @@ -1,25 +0,0 @@ -package mu.muse.usecase.scenario - -import io.jsonwebtoken.Jwts -import io.jsonwebtoken.SignatureAlgorithm -import mu.muse.domain.User -import mu.muse.usecase.JwtGenerator -import java.util.* - -class JwtGeneratorUseCase( - private val jwtSecretKey: String, - private val jwtExpiration: Long, -) : JwtGenerator { - - override fun execute(user: User): String { - val claims = Jwts.claims().setSubject(user.id.toStringValue()) - claims["role"] = user.authority - - return Jwts.builder() - .setClaims(claims) - .setIssuedAt(Date(System.currentTimeMillis())) - .setExpiration(Date(System.currentTimeMillis() + jwtExpiration)) - .signWith(SignatureAlgorithm.HS512, jwtSecretKey) - .compact() - } -} diff --git a/server/app/src/main/kotlin/mu/muse/usecase/scenario/JwtUsernameExtractorUseCase.kt b/server/app/src/main/kotlin/mu/muse/usecase/scenario/JwtUsernameExtractorUseCase.kt deleted file mode 100644 index a70637d6..00000000 --- a/server/app/src/main/kotlin/mu/muse/usecase/scenario/JwtUsernameExtractorUseCase.kt +++ /dev/null @@ -1,18 +0,0 @@ -package mu.muse.usecase.scenario - -import io.jsonwebtoken.Jwts -import mu.muse.domain.Username -import mu.muse.usecase.JwtUsernameExtractor - -class JwtUsernameExtractorUseCase(private val jwtSecretKey: String) : JwtUsernameExtractor { - - override fun execute(jwtRaw: String?): Username { - return Username.from( - Jwts.parser() - .setSigningKey(jwtSecretKey) - .parseClaimsJws(jwtRaw) - .body - .subject, - ) - } -} diff --git a/server/app/src/test/kotlin/mu/muse/HelloEndpointTest.kt b/server/app/src/test/kotlin/mu/muse/HelloEndpointTest.kt index 3c3620c3..4c498023 100644 --- a/server/app/src/test/kotlin/mu/muse/HelloEndpointTest.kt +++ b/server/app/src/test/kotlin/mu/muse/HelloEndpointTest.kt @@ -1,23 +1,57 @@ package mu.muse +import mu.muse.application.muse.SecurityConfiguration +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 mu.muse.rest.HelloEndpoint import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.security.crypto.factory.PasswordEncoderFactories +import org.springframework.security.test.context.support.WithAnonymousUser +import org.springframework.security.test.context.support.WithUserDetails import org.springframework.test.context.ContextConfiguration import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get @WebMvcTest -@ContextConfiguration(classes = [HelloEndpointTest.TestConfiguration::class]) +@ContextConfiguration(classes = [HelloEndpointTest.TestConfiguration::class, SecurityConfiguration::class]) internal class HelloEndpointTest { + @Autowired lateinit var mockMvc: MockMvc @Test - fun `get 'Hello, world!' body`() { + @WithAnonymousUser + fun `anonymous user -- unauthorized`() { + mockMvc + .get("/hello") + .andDo { print() } + .andExpect { + status { isUnauthorized() } + } + } + + @Test + @WithUserDetails(value = "user") + fun `authenticated user with role 'USER' -- forbidden`() { + mockMvc + .get("/hello") + .andDo { print() } + .andExpect { + status { isForbidden() } + } + } + + @Test + @WithUserDetails(value = "editor") + fun `authenticated user with role 'EDITOR' -- success`() { mockMvc .get("/hello") .andDo { print() } @@ -31,7 +65,34 @@ internal class HelloEndpointTest { @Configuration class TestConfiguration { + @Bean fun helloEndpoint() = HelloEndpoint() + + @Bean + fun users(): Set { + val passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder() + val testUser = User.create( + Username.from("user"), + Password.from(passwordEncoder.encode("123")), + Role.user(), + "Anonymous", + Version.new(), + ) + + val testEditor = User.create( + Username.from("editor"), + Password.from(passwordEncoder.encode("321")), + Role.editor(), + "Editor", + Version.new(), + ) + return setOf(testUser, testEditor) + } + + @Bean + fun userRepository(users: Set): InMemoryUserRepository { + return InMemoryUserRepository(users.associateBy { it.id }.toMutableMap()) + } } }