Skip to content
This repository was archived by the owner on Aug 13, 2022. It is now read-only.
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import kr.flab.wiki.app.components.authentication.LoginUserService
import kr.flab.wiki.app.type.annotation.ApiHandler
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
Expand All @@ -21,4 +22,29 @@ class LoginUserApi(
?: return ResponseEntity.status(HttpStatus.BAD_REQUEST).build()
return ResponseEntity.ok().body(loginResponse)
}

@GetMapping("/test")
fun test(): ResponseEntity<Any> {
return ResponseEntity.ok().body("test")
}

@GetMapping("/test1")
fun test1(): ResponseEntity<Any> {
return ResponseEntity.ok().body("test")
}

@GetMapping("/test2")
fun test2(): ResponseEntity<Any> {
return ResponseEntity.ok().body("test")
}

@GetMapping("/test3")
fun test3(): ResponseEntity<Any> {
return ResponseEntity.ok().body("test")
}

@GetMapping("/test4")
fun test4(): ResponseEntity<Any> {
return ResponseEntity.ok().body("test")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package kr.flab.wiki.app.appconfig.security
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.flab.wiki.app.components.authentication.AuthenticationProviderImpl
import kr.flab.wiki.app.components.authentication.JwsAuthenticationFilter
import kr.flab.wiki.app.components.authentication.UserAuthentication
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
Expand All @@ -19,7 +20,7 @@ import org.springframework.security.crypto.password.PasswordEncoder
class SecurityBeansDefinition {

@Bean
fun objectMapper() : ObjectMapper {
fun objectMapper(): ObjectMapper {
return jacksonObjectMapper()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버 실행할때 jackson-kotlin 어쩌고 경고 뜨진 않던가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서버 시작을 아직 안해봤는데, 확인해보고 말씀드리겠습니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음.. 로컬에 wsl, docker, mysql 세팅하고 서버실행해봤는데, 말씀하신 경고는 뜨지 않고 있습니다.

}

Expand All @@ -39,4 +40,9 @@ class SecurityBeansDefinition {
fun authenticationManager(webSecurityConfig: WebSecurityConfig): AuthenticationManager {
return webSecurityConfig.authenticationManagerBean()
}

@Bean
fun jwsAuthenticationFilter(): JwsAuthenticationFilter {
return JwsAuthenticationFilter()
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
package kr.flab.wiki.app.appconfig.security

import org.springframework.context.annotation.Bean
import kr.flab.wiki.app.components.authentication.JwsAuthenticationFilter
import kr.flab.wiki.app.components.authentication.UnauthorizedEntryPoint
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.AuthenticationProvider
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
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.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

/**
* 스프링 시큐리티의 설정을 담은 클래스입니다.
Expand All @@ -19,7 +20,9 @@ import org.springframework.security.config.http.SessionCreationPolicy
@Configuration
@EnableWebSecurity
class WebSecurityConfig(
private val authenticationProvider: AuthenticationProvider
private val authenticationProvider: AuthenticationProvider,
private val jwsAuthenticationFilter: JwsAuthenticationFilter,
private val unauthorizedEntryPoint: UnauthorizedEntryPoint
) : WebSecurityConfigurerAdapter() {
/**
* 스프링에서 기본 제공해주는 UserDetailsService 와 UserDetails 를 사용하지 않았습니다.
Expand Down Expand Up @@ -48,11 +51,18 @@ class WebSecurityConfig(
*/
http.csrf()
.disable()
.exceptionHandling().authenticationEntryPoint(unauthorizedEntryPoint)
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/login").permitAll()
.anyRequest().authenticated()

http.addFilterBefore(
jwsAuthenticationFilter,
UsernamePasswordAuthenticationFilter::class.java
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package kr.flab.wiki.app.components.authentication

import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jws
import kr.flab.wiki.app.utils.JwtUtils
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.filter.OncePerRequestFilter
import javax.servlet.FilterChain
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

class JwsAuthenticationFilter : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val jws: String? = JwtUtils.parseJws(request.getHeader("Authorization") ?: "")
var result: Jws<Claims>?
if (jws != null) {
result = JwtUtils.validateJws(jws)
if (result != null) {
val email = JwtUtils.getEmailFromJws(jws)
SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken(email, null)
}
}
filterChain.doFilter(request, response)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package kr.flab.wiki.app.components.authentication

import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.stereotype.Component
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

@Component
class UnauthorizedEntryPoint : AuthenticationEntryPoint {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거 네이밍 좋습니다.

override fun commence(
request: HttpServletRequest?,
response: HttpServletResponse?,
authException: AuthenticationException?
) {
response?.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Access Denied")
}
}
45 changes: 40 additions & 5 deletions app-main/src/main/kotlin/kr/flab/wiki/app/utils/JwtUtils.kt
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
package kr.flab.wiki.app.utils

import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jws
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.security.Keys
import org.springframework.security.core.Authentication
import org.springframework.util.StringUtils
import java.security.KeyPair
import java.util.Date

class JwtUtils private constructor() {
companion object {
private const val EXPIRATION_TIME: Long = 1 * 1 * 30 * 60 * 1000

/**
* 해당 키페어는 서버 시작할 때마다 재생성됩니다.
* 추후에는 미리 생성한 후 설정파일(ex : application-*.yml)에서 읽어들일 예정입니다.
*/
private val KEY_PAIR: KeyPair = Keys.keyPairFor(SignatureAlgorithm.RS256)
private const val BEARER_LENGTH: Int = 7
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 은 무슨 의미인가요? Substring 의도로 만드신것 같은데 (Bearer )(<JSON_WEB_TOKEN>) 형식으로 정규표현식 선언한 다음, 뒤쪽 capturing group 을 take 하도록 구현하는게 좀더 가독성 높은 구현이 될 것 같습니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

substring을 활용했는데, 아무래도 split을 활용하는게 더 간단할 것 같습니다.


fun generateJws(authentication: Authentication): String {
val email = authentication.name
Expand All @@ -30,17 +35,47 @@ class JwtUtils private constructor() {
.signWith(KEY_PAIR.private)
.compact()
}
// fun validateJws(jwsString: String): Jws<Claims>? {

fun getEmailFromJws(jwsString: String): String {
return Jwts.parserBuilder()
.setSigningKey(KEY_PAIR.public)
.build()
.parseClaimsJws(jwsString)
.body["email"]
.toString()
}

fun validateJws(jwsString: String): Jws<Claims>? {
// val jws: Jws<Claims>
// try {
// jws = Jwts.parserBuilder()
// .setSigningKey(KEY_PAIR.public)
// .build()
// .parseClaimsJws(jwsString)
// } catch (e: JwtException) {
// } catch (e: ExpiredJwtException) {
// return null
// } catch (e: SignatureException) {
// return null
// } catch (e: MalformedJwtException) {
// return null
// } catch (e: UnsupportedJwtException) {
// return null
// } catch (e: IllegalArgumentException) {
// return null
// } catch (e: UnsupportedEncodingException) {
// return null
// }
// return jws
// }
return Jwts.parserBuilder()
.setSigningKey(KEY_PAIR.public)
.build()
.parseClaimsJws(jwsString)
}

fun parseJws(token: String): String? {
if (StringUtils.hasText(token) and token.startsWith("Bearer ")) {
return token.substring(BEARER_LENGTH, token.length)
}
return null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import io.restassured.module.kotlin.extensions.Then
import io.restassured.module.kotlin.extensions.When
import io.restassured.specification.RequestSpecification
import kr.flab.wiki.TAG_TEST_E2E
import kr.flab.wiki.app.api.user.request.LoginRequest
import kr.flab.wiki.app.components.authentication.LoginUserService
import kr.flab.wiki.app.components.authentication.UserAuthentication
import kr.flab.wiki.core.testlib.user.Users
import org.mockito.MockitoAnnotations
Expand Down Expand Up @@ -48,14 +50,18 @@ class LoginWithSpringSecurityAndJwtTest {
* 로그인 이후
* 1. 발행한 JWT 토큰을 매 요청마다 검증한다. (스프링 시큐리티에서 인증이 필요하다고 정한 요청에만)
* 2. JWT 가 유효하면 정상적으로 요청을 수행하도록 한다.
* 3. JWT 가 유효하지 않으면 Http 403을 반환한다.
* 3. JWT 가 유효하지 않으면 Http 401을 반환한다.
*
*/

private val faker = Faker.instance()

@Inject
private lateinit var objectMapper: ObjectMapper

@Inject
private lateinit var loginUserService: LoginUserService

@MockBean
private lateinit var userAuthentication: UserAuthentication

Expand Down Expand Up @@ -169,19 +175,50 @@ class LoginWithSpringSecurityAndJwtTest {
}
}

@Disabled

@Nested
inner class `인증이 필요한 API 는` {

//테스트할 타겟 api uri
//private val targetApiUri = springApi.getRandomAuthenticatedApiPattern()
private val targetApiUri = "/test"

@Nested
inner class `매 요청마다 토큰을 검증해서` {
inner class `매 요청마다 헤더의 토큰을 확인해서` {

private lateinit var email: String
private lateinit var password: String

@BeforeEach
fun setup() {
email = faker.internet().emailAddress()
password = faker.internet().password()
}

@Nested
inner class 유효하면 {

@BeforeEach
fun setup() {
`when`(userAuthentication.authenticateUser(email, password))
.thenReturn(Users.randomUser(emailAddress = email))
}

@Test
fun `API 를 정상수행한다`() {
//정상적으로 로그인한 정보
val loginResponse = loginUserService.login(LoginRequest(email, password))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

통합 테스트에선 가급적이면 이런식으로 whitebox 에 의존한 구현보단 createUserAndLogin 같은 test helper function 을 만들고 그놈을 호출하는 식으로 구현하시는게 좀더 테스트 코드를 읽기 좋게 만들 수 있을 것 같습니다. (내부 구현도 LoginUserService 를 직접 호출하기보단 그냥 http call 하듯 구현한다면 좀더 blackbox testing 에 가깝게 구현할 수 있겠죠?)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안그래도 rest assured에서 http call을 한 후, 결과값을 가져오는 방법에 대해서 찾다가 잘 안찾아져서 일단 service로 바로 진행했었는데요.
말씀하신대로 hepler function을 이용해서 다시 한번 진행해보겠습니다.


Given {
spec(requestSpecification)
//정상적으로 로그인한 정보에서 추출한 token을 헤더에 담아서 요청한다.
header("Authorization", "Bearer ${loginResponse?.token}")
} When {
get(targetApiUri)
} Then {
statusCode(200)
log().all()
}
}

}
Expand All @@ -190,8 +227,15 @@ class LoginWithSpringSecurityAndJwtTest {
inner class `유효하지 않으면` {

@Test
fun `403을 반환한다`() {

fun `401을 반환한다`() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오류가 발생한다 (HTTP 403) 이 좀더 나을 것 같습니다.

Given {
spec(requestSpecification)
} When {
get(targetApiUri)
} Then {
statusCode(401)
log().all()
}
}

}
Expand Down
38 changes: 38 additions & 0 deletions app-main/src/test/kotlin/kr/flab/wiki/app/testlib/SpringApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package kr.flab.wiki.app.testlib

import org.springframework.context.ApplicationContext
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.mvc.method.RequestMappingInfo
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping
import java.util.*
import javax.inject.Inject


@Component
class SpringApi {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

클래스 이름이... SpringApi 가 무슨 의미인지 잘 모르겠어요.
그리고 이놈 Test context 에서 로드하게 하려면 어떻게 구현해야 할까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1.실험적인 코드를 작성한 터라 네이밍에 신경쓰지 못했습니다.

  1. @Inject, @Autowired 어노테이션으로 DI하면 될 것 같습니다.
    테스트 환경에서만 사용할 유틸성 클래스이기 때문에, 빈으로 생성 안하고 직접 생성하는 것도 방법이 될 수 있습니다.
    근데, 이 클래스는 인증이 필요한 api 리스트를 동적으로 생성해주고, 테스트 할 때 사용하려고 했는데, uri를 가져오는 건 별로 어렵지 않았으나, api마다 파라미터 정보를 가져오고, 데이터를 만들어주고 하는 것이 배보다 배꼽이 큰 느낌이라,
  • 해당 클래스는 실제로 사용하지 않았습니다. 앞으로의 수정에서 삭제될 예정입니다.


@Inject
private lateinit var context: ApplicationContext

private val random: Random = Random()

fun getAuthenticatedApiPatternList(): List<String> {
val requestMappingHandlerMapping = context
.getBean("requestMappingHandlerMapping", RequestMappingHandlerMapping::class.java)
val map = requestMappingHandlerMapping
.handlerMethods

return map.filter { (key: RequestMappingInfo?, value: HandlerMethod?) ->
!key.patternValues.contains("/login") && !key.patternValues.contains("/error")
}.map { (key: RequestMappingInfo?, value: HandlerMethod?) ->
key.patternValues.toString()
}
}

fun getRandomAuthenticatedApiPattern(): String {
val patternList = getAuthenticatedApiPatternList()
return patternList[random.nextInt(patternList.size)]
}

}