🌐 Swagger 로그인에 대한 필요성 & 요구사항
Briefing App이 2023년도 12월 30일 기준 2.0.0 버전 앱 심사가 마무리가 되어 배포가 된 상황입니다.
2.0.0 업데이트와 동시에 서비스에 대한 홍보를 본격적으로 진행하고 이에 따라 사용자가 늘어나게 되었습니다.
기존에는 Swagger의 API 명세 페이지에 누구든지 접근이 가능했고, 실제 브리핑 정보를 Spring Boot 서버에 Post하는 API에 대한 보안 처리가 없었기 때문에 우선적으로 Swagger 페이지에 보안 처리를 해두기로 했습니다.
추후 브리핑 정보를 Post 하는 API에 대해서도 보안 처리를 하기로 했습니다.
Swagger에 대한 보안 처리를 하기 위한 요구사항은 아래와 같았습니다.
📰 Swagger 보안 처리 요구사항
- swagger-ui/index.html로 접근 시 Form Login 가 적용이 되어야 한다.
- 나머지 API에 대해서는 기본적으로 JWT 인증을 적용한다.
- swagger 접속 시 정해진 아이디와 비밀번호로 로그인을 수행한다.
⁉️ 수 많은 시행착오
Briefing에 기존에 적용이 되어있던 인증 방식은 JWT 인증 하나였습니다. 그러나 Spring Boot 3를 처음으로 적용해본 프로젝트이며 Spring Security 또한 버전이 달라지면서 많은 부분이 바뀌게 되었습니다.
이런 상황에서 Spring Security에 대한 이해가 부족했기에 많은 시행착오를 경험했고 Spring Security Docs를 읽으면서 Spring Security에 대한 학습이 필요함을 느꼈습니다.
따라서 제가 겪은 시행착오, 학습을 하면서 새롭게 알게 된 개념들이 어떤 것이었고 무엇이 문제였는지를 중점적으로 다룰 예정입니다.
😇 잘못된 접근
Spring Security를 사용해 JWT 인증을 적용을 하긴 했지만 다른 블로그의 포스팅을 참고하여 그 동작 원리를 파악하지 못한 상태로 적용을 했기 때문에 새로운 요구 사항에 대한 적절한 대응이 어려웠습니다.
우선 초기의 Briefing의 Security 설정은 아래와 같습니다.
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) ->
web.ignoring()
.requestMatchers(
"",
"/",
"/schedule",
"/swagger-ui.html",
"/v3/api-docs",
"/v3/api-docs/**",
"/swagger-ui/index.html",
"/swagger-ui/**",
"/docs/**",
"/briefings/temp");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfiguration()))
.httpBasic(withDefaults())
.csrf(AbstractHttpConfigurer::disable) // 비활성화
.sessionManagement(
manage ->
manage.sessionCreationPolicy(
SessionCreationPolicy.STATELESS)) // Session 사용 안함
.formLogin(AbstractHttpConfigurer::disable) // form login 사용 안함
.authorizeHttpRequests(
authorize -> {
authorize
.requestMatchers("/v2/briefings/**")
.permitAll(); // 모두 접근 가능합니다.
authorize.requestMatchers("/briefings/**").permitAll(); // 모두 접근 가능합니다.
authorize.requestMatchers("/v2/members/auth/**").permitAll();
authorize.requestMatchers("/members/auth/**").permitAll();
authorize.requestMatchers("/chattings/**").permitAll();
authorize
.requestMatchers(HttpMethod.DELETE, "/v2/members/{memberId}")
.authenticated();
authorize
.requestMatchers(HttpMethod.DELETE, "/members/{memberId}")
.authenticated();
authorize.requestMatchers("/v2/scraps/**").authenticated();
authorize.requestMatchers("/scraps/**").authenticated();
authorize.anyRequest().authenticated();
})
.exceptionHandling(
exceptionHandling ->
exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler))
.addFilterBefore(
new JwtRequestFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationExceptionHandler, JwtRequestFilter.class)
.build();
}
설정을 살펴보면, 세션을 사용하지 않고, formLogin도 사용을 하지 않고 있습니다. 그리고 exceptionHandling에 대해 json 응답을 내려주기 위한 핸들러 2개가 등록이 된 모습입니다.
그리고 JwtRequestFilter, 즉 JWT 인증에 대한 필터를 추가하는 것을 확인 할 수 있습니다.
실제 트러블 슈팅 과정에서 JwtRequestFilter와 핸들러의 코드를 확인하는 과정을 거치게 됩니다.
첫 번째 시도
우선 formLogin을 사용하는 것으로 수정을 했을 경우 어떻게 변하게 되는지 확인을 해보고자
formLogin 설정을 아래와 같이 수정을 해봤습니다.
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) ->
web.ignoring()
.requestMatchers(
"",
"/",
"/schedule",
// "/swagger-ui.html",
// "/v3/api-docs",
// "/v3/api-docs/**",
// "/swagger-ui/index.html",
// "/swagger-ui/**",
// "/docs/**",
"/briefings/temp");
}
우선 swagger uri에 대해 인증 과정을 거치도록 수정을 했습니다.
(위의 ignoring 방식도 다음 포스팅 때 개선 하는 과정을 담을 예정입니다!)
.sessionManagement(
manage ->
manage.sessionCreationPolicy(
SessionCreationPolicy.STATELESS)) // Session 사용 안함
.formLogin(Customizer.withDefaults())
다음으로 formLogin을 사용하되 기본적인 설정을 사용 하도록 설정을 해 보았습니다.
그러나....
예상
- http://localhost:8080/swagger-ui/index.html로 접근 시 form Login이 보여짐
실제
- json 에러 응답이 옴
제가 실제로 원하는 모습은 아래와 같습니다. (아래의 화면은 최종적인 해결 이후 캡쳐 한 모습입니다.)
그러나 당시에는 왜 json 에러 응답이 온 것인지 감조차 잡지 못 했습니다.
따라서 블로그를 참고해서 확실한 이해 없이 시큐리티를 적용하는 것의 한계를 느끼고 공식 문서를 통해 학습을 했습니다.
당연히 해당 에러 응답은 제가 등록 한 에러 핸들러에서 생성한 응답인 것은 확인 했으나 어떤 흐름으로 응답이 온 것인지
확실하게 파악하기를 원했습니다.
이번 포스팅에서는 최종적으로 제가 선택한 방법에 대해서만 서술할 것이고
해당 방법을 선택하기 까지의 제 고민은 아래 포스팅에서 확인 가능합니다.
[Spring Security] Filter Chain & 예외 처리 흐름 파고들기
[Spring Security] Filter 흐름, 예외 처리 흐름 파고들기
해당 포스팅은 Briefing Service에서 요구 사항에 대한 문제 해결 과정에서 학습한 내용에 대한 내용입니다! 🌐 Spring Security 에 대한 학습의 계기 지금까지 Spring Security를 도입한 프로젝트가 다수 있
ddol-dev-blog.tistory.com
🎉 해결 방안 - Security FilterChain의 분리
Filter Chain의 흐름과 그 예외 처리에 대해 학습을 하고 다시한번 문제 상황을 어떻게 해결 할 지 고민을 했습니다.
근본적으로 현재의 JWT 인증을 사용하는 Filter Chain에 form Login을 같이 적용하기에는 아래의 문제점이 있었습니다.
- form Login을 적용하기 위해서는 세션이 필요하다.
- 커스텀 한 Access Deny Handler 등 직접 등록 한 에러 핸들러는 json 응답을 주지만 form Login을 위해서는 처음 온 사용자 (인증이 거부 된)에 대해서는 json 응답이 아니라 로그인을 위한 form 화면이 필요하다.
위와 같은 문제를 해결하기 위해 Filter Chain 자체에 대한 분리가 가능한 것을 활용해서 아래와 같은 결론을 내렸습니다.
Swagger 접속 경로에 대해서는 formLogin 필터 체인을, 나머지 API에 대해서는 JWT 인증 필터체인을 쓰자
따라서 저는 아래와 같은 설정을 추가했습니다.
@Bean
@Order(1)
public SecurityFilterChain formLoginFilterChain(HttpSecurity http) throws Exception {
return http.securityMatcher("/swagger-ui/**", "/login")
.cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfiguration()))
.httpBasic(withDefaults())
.csrf(AbstractHttpConfigurer::disable) // 비활성화
.sessionManagement(
manage ->
manage.sessionCreationPolicy(
SessionCreationPolicy.IF_REQUIRED))
.formLogin(authorize->authorize.defaultSuccessUrl("/swagger-ui/index.html").permitAll())
.authorizeHttpRequests(authorize-> authorize.requestMatchers("/swagger-ui/index.html").authenticated()
.anyRequest().permitAll())
.build();
}
새로운 필터체인을 만들었고, /swagger-ui/** 에 대한 uri에 대해서는 해당 필터체인을 사용하도록 설정했습니다.
.securityMatcher()를 사용하면 원하는 uri에 대해서 해당 필터체인을 사용하도록 설정이 가능합니다.
아래는 Security Filter에 대한 수정된 설정 입니다.
@Bean
@Order(1)
public SecurityFilterChain formLoginFilterChain(HttpSecurity http) throws Exception {
return http.securityMatcher("/swagger-ui/**", "/login")
.cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfiguration()))
.httpBasic(withDefaults())
.csrf(AbstractHttpConfigurer::disable) // 비활성화
.sessionManagement(
manage -> manage.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.formLogin(
authorize ->
authorize
.successHandler(swaggerLoginSuccessHandler)
.defaultSuccessUrl("/swagger-ui/index.html")
.permitAll())
.authorizeHttpRequests(
authorize ->
authorize
.requestMatchers("/swagger-ui/index.html")
.authenticated()
.anyRequest()
.permitAll())
.build();
}
@Bean
public SecurityFilterChain JwtFilterChain(HttpSecurity http) throws Exception {
return http.cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfiguration()))
.httpBasic(withDefaults())
.csrf(AbstractHttpConfigurer::disable) // 비활성화
.sessionManagement(
manage ->
manage.sessionCreationPolicy(
SessionCreationPolicy.STATELESS)) // Session 사용 안함
.formLogin(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(
authorize -> {
authorize
.requestMatchers("/v2/briefings/**")
.permitAll(); // 모두 접근 가능합니다.
authorize.requestMatchers("/briefings/**").permitAll(); // 모두 접근 가능합니다.
authorize.requestMatchers("/v2/members/auth/**").permitAll();
authorize.requestMatchers("/members/auth/**").permitAll();
authorize.requestMatchers("/chattings/**").permitAll();
authorize
.requestMatchers(HttpMethod.DELETE, "/v2/members/{memberId}")
.authenticated();
authorize
.requestMatchers(HttpMethod.DELETE, "/members/{memberId}")
.authenticated();
authorize.requestMatchers("/v2/scraps/**").authenticated();
authorize.requestMatchers("/scraps/**").authenticated();
authorize.anyRequest().authenticated();
})
.exceptionHandling(
exceptionHandling ->
exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler))
.addFilterBefore(
new JwtRequestFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationExceptionHandler, JwtRequestFilter.class)
.build();
}
🛬 In Memory User를 통한 인증
인증 Filter에 대해서는 새로운 Chain을 만들기로 했고, 세부적인 인증 과정에 대해서는 논의 결과
In Memory User를 만들어서 정해진 하나의 아이디, 패스워드를 통해 인증이 되도록 했습니다.
Filter 내부 세부적인 인증 과정에 대해서는 아래의 포스팅을 참고해주세요!
[Spring Security] Filter Chain & 예외 처리 흐름 파고들기
[Spring Security] 인증 흐름 알아보기
Briefing 프로젝트를 진행하면서 스프링 시큐리티와 관련된 요구사항들이 있었습니다. 그러나 Spring Security에 대한 이해도가 낮았기 때문에 필터 체인의 흐름, 내부적인 인증 과정에 대한 이해를
ddol-dev-blog.tistory.com
form Login시에는 UserDetails의 구현체인 User가 사용이 됩니다.
그리고 UserDetails를 UserDetailsService에서 가져와서 인증을 수행합니다.
UserDetailsService를 제가 구현해서 빈으로 등록 해두면 Spring Security 인증 과정에서 우선적으로 제가 직접 등록한 DetailsService를 사용하게 됩니다. 따라서 아래와 같이 UserDetailsService를 빈으로 등록했습니다.
@Bean
public UserDetailsService userDetailsService() {
UserDetails userDetails =
User.builder()
.username(swaggerId)
.password(passwordEncoder().encode(swaggerPass))
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(userDetails);
}
참고로 swaggerId와 swaggerPass는 환경변수로 분리했습니다.
저희는 하나의 정해진 아이디와 패스워드만 필요하기 때문에 UserDetailsService에서 데이터베이스와 통신을 할 필요가 없기 때문에 메모리에 유저의 정보를 정해두고 데이터베이스까지 가지 않게 했습니다!
이제 Swagger에 접속을 해보면....
잘 동작하는 것을 확인 가능합니다!
저희의 전체 코드는 아래 링크에서 확인 가능합니다!