Skip to content
This repository was archived by the owner on Aug 13, 2022. It is now read-only.

스프링 시큐리티를 이용한 api 사용 인증 #68

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ 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.security.access.prepost.PreAuthorize
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 +23,10 @@ class LoginUserApi(
?: return ResponseEntity.status(HttpStatus.BAD_REQUEST).build()
return ResponseEntity.ok().body(loginResponse)
}

@PreAuthorize("isAuthenticated()")
@GetMapping("/test")
fun test(): 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,17 @@
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.method.configuration.EnableGlobalMethodSecurity
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 @@ -18,8 +20,11 @@ import org.springframework.security.config.http.SessionCreationPolicy
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
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 +53,17 @@ 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")
}
}
44 changes: 39 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,15 +1,19 @@
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)에서 읽어들일 예정입니다.
Expand All @@ -30,17 +34,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.split(' ')[1]
}
return null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.github.javafaker.Faker
import org.hamcrest.Matchers.*
import org.junit.jupiter.api.*
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito.*
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit.jupiter.SpringExtension
import io.restassured.builder.RequestSpecBuilder
import io.restassured.config.LogConfig
import io.restassured.config.RestAssuredConfig
Expand All @@ -19,6 +17,7 @@ import io.restassured.module.kotlin.extensions.When
import io.restassured.specification.RequestSpecification
import kr.flab.wiki.TAG_TEST_E2E
import kr.flab.wiki.app.components.authentication.UserAuthentication
import kr.flab.wiki.app.testlib.LoginTestHelper
import kr.flab.wiki.core.testlib.user.Users
import org.mockito.MockitoAnnotations
import org.springframework.beans.factory.annotation.Value
Expand All @@ -28,7 +27,6 @@ import javax.inject.Inject
@Tag(TAG_TEST_E2E)
@DisplayName("스프링 시큐리티와 JWT를 사용한 로그인 시나리오를 확인한다.")
@Suppress("ClassName", "NonAsciiCharacters") // 테스트 표현을 위한 한글 사용
@ExtendWith(SpringExtension::class)
@SpringBootTest(
properties = ["baseUri=http://localhost", "port=8080"],
webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT
Expand All @@ -48,11 +46,12 @@ 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

Expand Down Expand Up @@ -150,7 +149,7 @@ class LoginWithSpringSecurityAndJwtTest {
}

@Test
fun `400 을 반환한다`() {
fun `오류가 발생한다 (HTTP 400)`() {

Given {
spec(requestSpecification)
Expand All @@ -169,19 +168,49 @@ class LoginWithSpringSecurityAndJwtTest {
}
}

@Disabled

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

//테스트할 타겟 api uri
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 = LoginTestHelper.makeLoginResponse(requestSpecification, email, password)

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

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

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

fun `오류가 발생한다 (HTTP 403)`() {
Given {
spec(requestSpecification)
} When {
get(targetApiUri)
} Then {
statusCode(401)
log().all()
}
}

}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.flab.wiki.app.testlib

import io.restassured.RestAssured
import io.restassured.specification.RequestSpecification
import kr.flab.wiki.app.api.user.request.LoginRequest
import kr.flab.wiki.app.api.user.response.LoginResponse

object LoginTestHelper {

fun makeLoginResponse(requestSpecification: RequestSpecification, email: String, password: String): LoginResponse {
return RestAssured
.given()
.spec(requestSpecification)
.body(LoginRequest(email, password))
.`when`()
.post("/login")
.then()
.extract()
.`as`(LoginResponse::class.java)
}

}