Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DEV-245] Spring Security 설정(JWT) + WaitingController 내 반응형 로직 작업 #5

Merged
merged 16 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
}
punkryn marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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 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): AccessTokenPayload {
val payload = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(accessToken).payload
val role = payload.get("role", String::class.java)
return AccessTokenPayload(payload.subject, role)
}

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 != "" }
claycat marked this conversation as resolved.
Show resolved Hide resolved
}
}
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 }
.map(accessTokenService::verifyToken)
punkryn marked this conversation as resolved.
Show resolved Hide resolved
.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)
claycat marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
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
Loading