이것이 제일 큰 문제였던가..?
Kotlin SpringBoot로 많은 서비스를 포함한 LuckyFind라는 프로젝트를 진행하고있습니다.
뭐 이것저것 추가하면서 공부하고있었는데..
여기서 Spring Security 라이브러리를 추가하고 FormLogin 방식으로 진행하고있었는데 문득 JWT가 눈에 띄는겁니다? 그래서 어? 나도 한번 써봐야겠다!! 해서 시작을했는데!!
약 8시간동안 헤매기만하고 내가 뭘한건지 싶네요..
결론적으로는 해결아닌 해결을 했지만, 대부분 그렇듯 코드 몇줄로 해결될 일을 질질 끌었다는 것이죠??
덕분에 SpringSecurity관련 공부를 더 깊게 해야겠다는 생각이 강하게 들었습니다 ㅎㅎ
일단 소스로 제가 어떤것 때문에 헤맸는지 알려드릴게요
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val authenticationConfiguration: AuthenticationConfiguration,
private val jwtUtils: JWTUtils,
private val userRepository: UserRepository,
) {
// Cors Filter Custom
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val config = CorsConfiguration()
config.allowCredentials = true
config.allowedOrigins = listOf("*")
config.allowedMethods = listOf("*")
config.allowedHeaders = listOf("*")
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", config)
return source
}
// Filter Chain
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
// http.cors {
// corsConfigurationSource()
// }
http.invoke {
csrf {
disable()
}
headers {
frameOptions {
sameOrigin = true
}
}
formLogin{
disable()
}
authorizeHttpRequests {
authorize("/swagger-ui/**", permitAll)
authorize(PathRequest.toH2Console(), permitAll)
authorize("/h2-console/**", permitAll)
authorize("/assets/**", permitAll)
authorize("/login", permitAll)
authorize("/api/v1/**", permitAll)
authorize("/api/v1/user/signUp", permitAll)
authorize("/register", permitAll)
authorize(anyRequest, authenticated)
}
sessionManagement {
// 세션을 사용하지 않고 JWT를 사용할 예정
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}
}
http.addFilterAt(
JwtAuthenticationFilter(authenticationManager(), jwtUtils), << 이부분..
UsernamePasswordAuthenticationFilter::class.java
)
http.addFilterAt(
JwtAuthorizationFilter(userRepository, jwtUtils), << 이부분..
BasicAuthenticationFilter::class.java
)
// JWT token
return http.build()!!
}
@Bean
fun authenticationManager(): AuthenticationManager =
authenticationConfiguration.authenticationManager
// Password Encoder
@Bean
fun passwordEncoder() = BCryptPasswordEncoder()
}
먼저 부연설명을 하자면!
JWT 토큰을 발행하기 위해서 통행증개념의 Authentication 및 Authorization이 필요했습니다.
그래서 UsernamePasswordAuthenticationToken
을 대신할 JwtAuthenticationFilter
와,
이 사용자가 유효한 토큰을 지니고 있는지 확인하는 JwtAuthorizationFilter
를 생성했습니다.
class JwtAuthenticationFilter(
private val authenticationManager: AuthenticationManager,
private val jwtUtils: JWTUtils,
) : UsernamePasswordAuthenticationFilter() {
// UsernamePasswordAuthenticationFilter getAuthenticationManager is null problem solve.
override fun getAuthenticationManager(): AuthenticationManager =
authenticationManager
override fun attemptAuthentication(request: HttpServletRequest?, response: HttpServletResponse?): Authentication {
// username && password parameter
println(request?.getParameter(this.usernameParameter))
println(request?.getParameter(this.passwordParameter))
val authenticationToken =
UsernamePasswordAuthenticationToken(
request?.getParameter(this.usernameParameter),
request?.getParameter(this.passwordParameter)
)
println(authenticationToken) // authentication Token
println(authenticationToken.principal) // username
println(authenticationToken.credentials) // password
println("로그인 시도 ==========================")
val authentication: Authentication = authenticationManager.authenticate(authenticationToken) // 로그인 시도
println(authentication.isAuthenticated)
println("로그인 시도 종료 ==========================")
// Authentication return
return authentication
}
// 인증성공
override fun successfulAuthentication(
request: HttpServletRequest?,
response: HttpServletResponse?,
chain: FilterChain?,
authResult: Authentication?
) {
println("인증 완료 ==============")
val userResponse: UserResponse = with((authResult?.principal as User)) {
UserResponse(
username = this.username,
password = this.password,
authorities = this.authorities!!,
userId = this.userId!!,
enabled = this.enabled
)
}
println(userResponse) // 인증정보
val token = jwtUtils.createToken(userResponse)
println(token)
SecurityContextHolder.getContext().authentication = authResult
// 토큰 헤더에 추가
response?.addHeader("Authorization", "Bearer $token")
// response?.sendRedirect("/notice")
super.successfulAuthentication(request, response, chain, authResult)
}
override fun unsuccessfulAuthentication(
request: HttpServletRequest?,
response: HttpServletResponse?,
failed: AuthenticationException?
) {
println("실패??")
super.unsuccessfulAuthentication(request, response, failed)
}
}
이곳의 코드에서도 많이 헤매었지만, 별거 아니었습니다.
- 단순하게, 사용자가 로그인을 시도하면, 해당 아이디와 패스워드가 동일한지 체크하고, 그것들을 기반으로 로그인 처리와 토큰을 발급해서 건네주는 방식이었습니다.
- 세션방식은 사용하지 않고, 헤더에 토큰을 넣어 건네주었습니다.
- print를 찍어보면서 제대로 아이디와 패스워드가 넘어가고, 토큰까지 발행되는것을 확인했습니다.
그런데..
로그인을 통해서 토큰이 발급되었는데 왠지 모르게 권한이없다면서 밀어내는것입니다..
도저히 생각이 안떠올라서 몇시간동안 코드 수정해가면서, Filter가 문제인가, 아니면 AuthenticationManager 주입을 잘못했나 싶기도하고 머리를 좀 쥐어짰습니다.. 😂😂😂
무한히 검색하면서 찾아본 결과로는 적절한 방법인지는 아직 모르겠으나
BasisAuthenticationFilter
처리를 하는것이였습니다.
제 견해로는.. 모든 요청에 BasicAuthenticationFilter를 거치게 되는데,
기존에는 Session 방식으로 로그인을 하여서 세션에 사용자의 정보와 권한이 담겨있었지만,
JWT를 도입하면서 Session을 버리고 단순히 헤더에 넣어서 유저의 정보와 권한을 건네주기때문에 이사람이 적절한 권한을 가지고있는지, 또 올바르게 접근을 했는지 판단을 하지 못한것 같습니다.
그래서 BasisAuthenticationFilter를 Custom하게 생성하여 SecurityConfig에 주입해주었습니다.
class JwtAuthorizationFilter(
private val userRepository: UserRepository,
private val jwtUtils: JWTUtils,
) : OncePerRequestFilter() {
// 요청 제외 url
private val excludeUrls =
listOf(
"/assets",
"/login",
"/templates",
"/scripts",
"/swagger-ui/",
// "/v3/api-docs"
)
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
println(request.servletPath) // 요청 path
// val header = request.getHeader("Authorization")
//
// if (header == null || !header.startsWith("Bearer ")) {
// filterChain.doFilter(request, response)
// return
// }
val user = userRepository.findByUsername("admin")
val authentication: Authentication = UsernamePasswordAuthenticationToken(
user, user!!.password, user.authorities
)
SecurityContextHolder.getContext().authentication = authentication
filterChain.doFilter(request, response);
}
override fun shouldNotFilter(request: HttpServletRequest): Boolean {
return excludeUrls.stream().anyMatch {
request.servletPath.contains(it)
}
}
}
아니 BasicAuthenticationFilter라면서 왜 OncePerRequestFilter 에요??
이게 BasicAuthenticationFilter의 경우에는 모든 요청에 대해서 필터를 거치지만,
OncePerRequestFilter
의 경우에는 필터내부 로직을 처리할지 말지를 결정할 shouldNotFilter
를 가지고있기때문에 보다 부하를 줄일 수 있다는 생각이 들었습니다.
그래서 해당 필터로 선택하였고, 아직 내부 로직은 개선 중입니다 😊😊
덕분에 수많은 블로그와 문서를 읽으면서 필터별 역할도 아주 달달달 기억이 날정도인 것 같아요 ㅎㅎ
이번 글은 제가 오늘 하루종일 삽질하면서 느끼고 뭔가 훈장처럼 남기고 싶어서 적는 글이고
프로젝트가 마무리될쯤에 제가 알고있는내용들을 다 정리하면서 다시 포스팅할 예정입니다.
혹시 제 프로젝트 진행상황이 궁금하신분들을 위해 깃헙소스는 아래 링크에 추가하겠습니다.
https://github.com/FrenchRuin/LuckyFind
아 그리고, 소스를 혹시 보신다면 개선해야될점들을 찾아주신다면 매우 감사드리겠습니다 👍👍👍👍
감사합니다