Skip to content

Commit

Permalink
Merge pull request #5 from Tiketeer/feat/DEV-245
Browse files Browse the repository at this point in the history
[DEV-245] Spring Security 설정(JWT) + WaitingController 내 반응형 로직 작업
  • Loading branch information
One-armed-boy authored May 6, 2024
2 parents 70dee31 + 31b90fa commit 439c66a
Show file tree
Hide file tree
Showing 12 changed files with 272 additions and 21 deletions.
22 changes: 18 additions & 4 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,33 @@ repositories {
}

dependencies {
// Default
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
implementation 'io.projectreactor.kotlin:reactor-kotlin-extensions'
testImplementation 'org.springframework.boot:spring-boot-starter-test'

implementation 'org.jetbrains.kotlin:kotlin-reflect'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-reactor'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'io.projectreactor.kotlin:reactor-kotlin-extensions'
testImplementation 'io.projectreactor:reactor-test'

// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
ext {
JJWT_VERSION = "0.12.5"
}
implementation "io.jsonwebtoken:jjwt-api:${JJWT_VERSION}"
runtimeOnly "io.jsonwebtoken:jjwt-gson:${JJWT_VERSION}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${JJWT_VERSION}"

// Util
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'

// DB
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation("it.ozimov:embedded-redis:0.7.2")

// logger
// Logger
implementation 'io.github.oshai:kotlin-logging-jvm:5.1.0'
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.tiketeer.TiketeerWaiting.auth.constant

enum class JwtMetadata(private val value: String) {
ACCESS_TOKEN("accessToken"), REFRESH_TOKEN("refreshToken");

fun value(): String {
return this.value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.tiketeer.TiketeerWaiting.auth.jwt

import io.jsonwebtoken.Jwts
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import javax.crypto.SecretKey

@Service
class AccessTokenService(@Value("\${jwt.secret-key}") secretKey: String) {
private val secretKey: SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey))

fun verifyToken(accessToken: String): Mono<AccessTokenPayload> {
return Mono.just(accessToken)
.map {token -> Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).payload}
.map {payload -> AccessTokenPayload(payload.subject, payload.get("role", String::class.java))}
}

data class AccessTokenPayload(val email: String, val role: String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.tiketeer.TiketeerWaiting.auth.jwt

import org.springframework.security.authentication.ReactiveAuthenticationManager
import org.springframework.security.core.Authentication
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono

@Component
class JwtAuthenticationManager: ReactiveAuthenticationManager {
override fun authenticate(authentication: Authentication?): Mono<Authentication> {
return Mono.justOrEmpty(authentication)
.filter { auth -> auth.principal is String && auth.principal != "" }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.tiketeer.TiketeerWaiting.auth.jwt

import com.tiketeer.TiketeerWaiting.auth.constant.JwtMetadata
import org.springframework.http.HttpCookie
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono

@Component
class JwtServerAuthenticationConverter(
private val accessTokenService: AccessTokenService
): ServerAuthenticationConverter {
override fun convert(exchange: ServerWebExchange): Mono<Authentication> {
return Mono.justOrEmpty(extractAccessToken(exchange))
.map { cookie -> cookie.value }
.flatMap(accessTokenService::verifyToken)
.map(this::createAuthentication)
}

private fun extractAccessToken(exchange: ServerWebExchange): HttpCookie? {
return exchange.request.cookies.getFirst(JwtMetadata.ACCESS_TOKEN.value())
}

private fun createAuthentication(payload: AccessTokenService.AccessTokenPayload): Authentication {
return UsernamePasswordAuthenticationToken(
payload.email,
null,
listOf(SimpleGrantedAuthority(payload.role))
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ import org.springframework.data.redis.serializer.RedisSerializationContext
@Configuration
@Profile("!test")
class RedisConfig {
@Value("\${spring.redis.host}")
@Value("\${spring.data.redis.host}")
private lateinit var host: String

@Value("\${spring.redis.port}")
@Value("\${spring.data.redis.port}")
private lateinit var port: Number

@Value("\${spring.redis.password}")
@Value("\${spring.data.redis.password}")
private lateinit var password: String

@Bean
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.tiketeer.TiketeerWaiting.configuration;

import com.tiketeer.TiketeerWaiting.auth.jwt.JwtAuthenticationManager
import com.tiketeer.TiketeerWaiting.auth.jwt.JwtServerAuthenticationConverter
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.web.server.SecurityWebFiltersOrder
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.config.web.server.ServerHttpSecurity.CsrfSpec
import org.springframework.security.config.web.server.ServerHttpSecurity.FormLoginSpec
import org.springframework.security.config.web.server.ServerHttpSecurity.HttpBasicSpec
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.AuthenticationWebFilter
import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository

@Configuration
@EnableWebFluxSecurity
class SecurityConfig {
@Bean
fun securityWebFilterChain(
http: ServerHttpSecurity,
authenticationManager: JwtAuthenticationManager,
serverAuthenticationConverter: JwtServerAuthenticationConverter
): SecurityWebFilterChain {
val authenticationWebFilter = AuthenticationWebFilter(authenticationManager)
authenticationWebFilter.setServerAuthenticationConverter(serverAuthenticationConverter)

return http
.csrf(CsrfSpec::disable)
.formLogin(FormLoginSpec::disable)
.httpBasic(HttpBasicSpec::disable)
.securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
.addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.authorizeExchange {
e -> e.anyExchange().authenticated()
}
.build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.tiketeer.TiketeerWaiting.domain.waiting.controller
import com.tiketeer.TiketeerWaiting.domain.waiting.controller.dto.GetRankAndTokenResponseDto
import com.tiketeer.TiketeerWaiting.domain.waiting.usecase.GetRankAndToken
import com.tiketeer.TiketeerWaiting.domain.waiting.usecase.dto.GetRankAndTokenCommandDto
import org.springframework.security.core.Authentication
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
Expand All @@ -16,10 +17,14 @@ class WaitingController(
private val getRankAndTokenUseCase: GetRankAndToken
) {
@GetMapping
fun getRankAndToken(@RequestParam(required = true) ticketingId: UUID): Mono<GetRankAndTokenResponseDto> {
// TODO: JWT 디코딩 필터 적용 후 JWT 내에서 가져오도록 수정
val email = "[email protected]"
val result = getRankAndTokenUseCase.getRankAndToken(GetRankAndTokenCommandDto(email, ticketingId, System.currentTimeMillis()))
return GetRankAndTokenResponseDto.convertFromDto(result)
fun getRankAndToken(
authentication: Mono<Authentication>,
@RequestParam(required = true) ticketingId: UUID
): Mono<GetRankAndTokenResponseDto> {
return authentication
.map { auth -> auth.name }
.map { email -> GetRankAndTokenCommandDto(email, ticketingId, System.currentTimeMillis()) }
.flatMap(getRankAndTokenUseCase::getRankAndToken)
.map(GetRankAndTokenResponseDto::convertFromDto)
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
package com.tiketeer.TiketeerWaiting.domain.waiting.controller.dto

import com.tiketeer.TiketeerWaiting.domain.waiting.usecase.dto.GetRankAndTokenResultDto
import reactor.core.publisher.Mono

data class GetRankAndTokenResponseDto(
val rank: Long,
val token: String? = null,
) {
companion object {
fun convertFromDto(dto: Mono<GetRankAndTokenResultDto>): Mono<GetRankAndTokenResponseDto> {
return dto.flatMap { r ->
Mono.just(GetRankAndTokenResponseDto(r.rank, r.token))
}
fun convertFromDto(dto: GetRankAndTokenResultDto): GetRankAndTokenResponseDto {
return GetRankAndTokenResponseDto(dto.rank, dto.token)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class GetRankAndTokenUseCase @Autowired constructor(private val redisTemplate: R

override fun getRankAndToken(dto: GetRankAndTokenCommandDto): Mono<GetRankAndTokenResultDto> {
val currentTime = dto.entryTime
val token = generateToken(dto.email, dto.ticketingId, currentTime)
val token = generateToken(dto.email, dto.ticketingId)
val mono = redisTemplate.opsForZSet().rank(dto.ticketingId.toString(), token)
.switchIfEmpty(
redisTemplate.opsForZSet().add(dto.ticketingId.toString(), token, currentTime.toDouble())
Expand All @@ -26,7 +26,7 @@ class GetRankAndTokenUseCase @Autowired constructor(private val redisTemplate: R
val ret: Mono<GetRankAndTokenResultDto> = mono
.flatMap { l ->
if (l < entrySize.toInt()) {
Mono.just(GetRankAndTokenResultDto(l, generateToken(dto.email, dto.ticketingId, currentTime)))
Mono.just(GetRankAndTokenResultDto(l, generateToken(dto.email, dto.ticketingId)))
} else {
Mono.just(GetRankAndTokenResultDto(l))
}
Expand All @@ -35,7 +35,7 @@ class GetRankAndTokenUseCase @Autowired constructor(private val redisTemplate: R
return ret
}

private fun generateToken(email: String, ticketingId: UUID, entryTime: Long) : String {
private fun generateToken(email: String, ticketingId: UUID) : String {
return "${email}:${ticketingId}"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.tiketeer.TiketeerWaiting.domain.waiting.controller

import com.tiketeer.TiketeerWaiting.auth.constant.JwtMetadata
import com.tiketeer.TiketeerWaiting.configuration.EmbeddedRedisConfig
import com.tiketeer.TiketeerWaiting.domain.waiting.usecase.GetRankAndTokenUseCase
import com.tiketeer.TiketeerWaiting.domain.waiting.usecase.dto.GetRankAndTokenCommandDto
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory
import org.springframework.test.web.reactive.server.WebTestClient
import java.util.Date
import java.util.UUID

@Import(EmbeddedRedisConfig::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class WaitingControllerTest {
@Autowired
lateinit var webTestClient: WebTestClient

@Autowired
lateinit var redisConnectionFactory: ReactiveRedisConnectionFactory

@Autowired
lateinit var getRankAndTokenUseCase: GetRankAndTokenUseCase

@Value("\${jwt.secret-key}")
lateinit var jwtSecretKey: String

@Value("\${waiting.entry-size}")
lateinit var entrySize: Number

@BeforeEach
fun init() {
val flushDb = redisConnectionFactory.reactiveConnection.serverCommands().flushDb()
flushDb.block()
}

@Test
fun `토큰이 없는 유저 - waiting 요청 - 호출 실패`() {
// given
val ticketingId = UUID.randomUUID()
// when
webTestClient.get().uri("/waiting?ticketingId=$ticketingId")
// then
.exchange()
.expectStatus().isUnauthorized()
}

@Test
fun `토큰이 있는 유저 - 빈 대기열에 waiting 요청 - 토큰 반환`() {
// given
val email = "[email protected]"
val role = "USER"
val ticketingId = UUID.randomUUID()

// when
webTestClient.get().uri("/waiting?ticketingId=$ticketingId")
.cookie(JwtMetadata.ACCESS_TOKEN.value(), createAccessToken(email, role, Date()))
// then
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("rank").isEqualTo(0L)
.jsonPath("token").isEqualTo(createPurchaseToken(email, ticketingId))
}

@Test
fun `토큰이 있는 유저 - 가득찬 대기열에 waiting 요청 - 토큰 반환 X`() {
// given
val ticketingId = UUID.randomUUID()
for (i in 1..entrySize.toInt()) {
val email = "test${i}@test.com"
val entryTime = System.currentTimeMillis()
val result = getRankAndTokenUseCase.getRankAndToken(GetRankAndTokenCommandDto(email, ticketingId, entryTime))
result.block()
}
val email = "[email protected]"
val role = "USER"

// when
webTestClient.get().uri("/waiting?ticketingId=$ticketingId")
.cookie(JwtMetadata.ACCESS_TOKEN.value(), createAccessToken(email, role, Date()))
// then
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("rank").isEqualTo(entrySize.toLong())
.jsonPath("token").isEmpty()
}

private fun createPurchaseToken(email: String, ticketingId: UUID): String {
return "$email:$ticketingId"
}

private fun createAccessToken(email: String, role: String, issuedAt: Date): String {
return Jwts.builder()
.subject(email)
.claim("role", role)
.issuer("tester")
.issuedAt(issuedAt)
.expiration(Date(issuedAt.time + 3 * 60 * 1000))
.signWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtSecretKey)))
.compact();
}
}
5 changes: 4 additions & 1 deletion src/test/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@ spring:
maxmemory: 10

waiting:
entry-size: 3
entry-size: 3

jwt:
secret-key: 68895db81e621a83a1ab3d9892c24e8c6478bfe8b23fa47d324a54770c081630ed270acaf45b76456b36935c46cdffdba2d22bee94126b43a015f82c36333d3c

0 comments on commit 439c66a

Please sign in to comment.