diff --git a/app-main/src/main/kotlin/kr/flab/wiki/app/api/user/loginUser/LoginUserApi.kt b/app-main/src/main/kotlin/kr/flab/wiki/app/api/user/loginUser/LoginUserApi.kt index 058e0a4..eae3612 100644 --- a/app-main/src/main/kotlin/kr/flab/wiki/app/api/user/loginUser/LoginUserApi.kt +++ b/app-main/src/main/kotlin/kr/flab/wiki/app/api/user/loginUser/LoginUserApi.kt @@ -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 @@ -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 { + return ResponseEntity.ok().body("test") + } } diff --git a/app-main/src/main/kotlin/kr/flab/wiki/app/appconfig/security/SecurityBeansDefinition.kt b/app-main/src/main/kotlin/kr/flab/wiki/app/appconfig/security/SecurityBeansDefinition.kt index d1855c2..ba51bbb 100644 --- a/app-main/src/main/kotlin/kr/flab/wiki/app/appconfig/security/SecurityBeansDefinition.kt +++ b/app-main/src/main/kotlin/kr/flab/wiki/app/appconfig/security/SecurityBeansDefinition.kt @@ -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 @@ -19,7 +20,7 @@ import org.springframework.security.crypto.password.PasswordEncoder class SecurityBeansDefinition { @Bean - fun objectMapper() : ObjectMapper { + fun objectMapper(): ObjectMapper { return jacksonObjectMapper() } @@ -39,4 +40,9 @@ class SecurityBeansDefinition { fun authenticationManager(webSecurityConfig: WebSecurityConfig): AuthenticationManager { return webSecurityConfig.authenticationManagerBean() } + + @Bean + fun jwsAuthenticationFilter(): JwsAuthenticationFilter { + return JwsAuthenticationFilter() + } } diff --git a/app-main/src/main/kotlin/kr/flab/wiki/app/appconfig/security/WebSecurityConfig.kt b/app-main/src/main/kotlin/kr/flab/wiki/app/appconfig/security/WebSecurityConfig.kt index e9792f5..e3c2cab 100644 --- a/app-main/src/main/kotlin/kr/flab/wiki/app/appconfig/security/WebSecurityConfig.kt +++ b/app-main/src/main/kotlin/kr/flab/wiki/app/appconfig/security/WebSecurityConfig.kt @@ -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 /** * 스프링 시큐리티의 설정을 담은 클래스입니다. @@ -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 를 사용하지 않았습니다. @@ -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 + ) } } diff --git a/app-main/src/main/kotlin/kr/flab/wiki/app/components/authentication/JwsAuthenticationFilter.kt b/app-main/src/main/kotlin/kr/flab/wiki/app/components/authentication/JwsAuthenticationFilter.kt new file mode 100644 index 0000000..551b924 --- /dev/null +++ b/app-main/src/main/kotlin/kr/flab/wiki/app/components/authentication/JwsAuthenticationFilter.kt @@ -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? + 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) + } +} diff --git a/app-main/src/main/kotlin/kr/flab/wiki/app/components/authentication/UnauthorizedEntryPoint.kt b/app-main/src/main/kotlin/kr/flab/wiki/app/components/authentication/UnauthorizedEntryPoint.kt new file mode 100644 index 0000000..9004435 --- /dev/null +++ b/app-main/src/main/kotlin/kr/flab/wiki/app/components/authentication/UnauthorizedEntryPoint.kt @@ -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 { + override fun commence( + request: HttpServletRequest?, + response: HttpServletResponse?, + authException: AuthenticationException? + ) { + response?.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Access Denied") + } +} diff --git a/app-main/src/main/kotlin/kr/flab/wiki/app/utils/JwtUtils.kt b/app-main/src/main/kotlin/kr/flab/wiki/app/utils/JwtUtils.kt index 01010e8..fe87962 100644 --- a/app-main/src/main/kotlin/kr/flab/wiki/app/utils/JwtUtils.kt +++ b/app-main/src/main/kotlin/kr/flab/wiki/app/utils/JwtUtils.kt @@ -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)에서 읽어들일 예정입니다. @@ -30,17 +34,47 @@ class JwtUtils private constructor() { .signWith(KEY_PAIR.private) .compact() } -// fun validateJws(jwsString: String): Jws? { + + fun getEmailFromJws(jwsString: String): String { + return Jwts.parserBuilder() + .setSigningKey(KEY_PAIR.public) + .build() + .parseClaimsJws(jwsString) + .body["email"] + .toString() + } + + fun validateJws(jwsString: String): Jws? { // val jws: Jws // 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 + } } } diff --git a/app-main/src/test/kotlin/kr/flab/wiki/app/testcase/login/LoginWithSpringSecurityAndJwtTest.kt b/app-main/src/test/kotlin/kr/flab/wiki/app/testcase/login/LoginWithSpringSecurityAndJwtTest.kt index 23c8774..69c2059 100644 --- a/app-main/src/test/kotlin/kr/flab/wiki/app/testcase/login/LoginWithSpringSecurityAndJwtTest.kt +++ b/app-main/src/test/kotlin/kr/flab/wiki/app/testcase/login/LoginWithSpringSecurityAndJwtTest.kt @@ -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 @@ -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 @@ -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 @@ -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 @@ -150,7 +149,7 @@ class LoginWithSpringSecurityAndJwtTest { } @Test - fun `400 을 반환한다`() { + fun `오류가 발생한다 (HTTP 400)`() { Given { spec(requestSpecification) @@ -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() + } } } @@ -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() + } } } diff --git a/app-main/src/test/kotlin/kr/flab/wiki/app/testlib/LoginTestHelper.kt b/app-main/src/test/kotlin/kr/flab/wiki/app/testlib/LoginTestHelper.kt new file mode 100644 index 0000000..e6db802 --- /dev/null +++ b/app-main/src/test/kotlin/kr/flab/wiki/app/testlib/LoginTestHelper.kt @@ -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) + } + +}