앞서 구현한
JWT
관련 기능과 공부한스프링 시큐리티
개념을 가지고 프로젝트에 구현한 스프링 시큐리티 필터를 커스텀을 한 내용을 정리해보겠다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'mysql:mysql-connector-java'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
...
//jwt 사용
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5', 'io.jsonwebtoken:jjwt-jackson:0.11.5'
//Redis cache
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
@Data
public class PrincipalDetails implements UserDetails {
private User user;
public PrincipalDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
List<String> list = new ArrayList<>();
list.add(user.getRole().name());
list.stream().forEach( r -> {
authorities.add(() -> r);
});
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
회원 데이터를 조회하고 해당 정보와 권한을 저장하는 UserDetails
인터페이스를 implements 하여 직접 구현하였다.
뒤에서 JWT 토큰을 통한 인증/인가가 완료되었을때 SecurityContext
에 Authentication
객체가 들어가게 되는데
Authentication
객체의 principal로 UserDetails
의 구현체가 들어가게 설계하였다.
이렇게 한 이유는, 인증이 필요한 리소스에 사용자가 접근할때 인증/인가가 정상적으로 이루어졌을때 Controller
단에서 인증처리된 사용자의 정보를 사용하기 위해서이다.
@Service
@RequiredArgsConstructor
public class PrincipalDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return userRepository.findByEmail(email)
.map(m -> new PrincipalDetails(m))
.orElseThrow( () -> new UsernameNotFoundException("존재하지 않은 사용자 입니다."));
}
}
인증 과정 중 실제 사용자 Repository에서 회원을 조회하는 서비스를 커스텀하여 구현하였다.
만약 DB에서 사용자를 찾지 못한다면 UsernameNotFountException
에러를 터트리면 아래 에러 처리 핸들러가 실행될 수 있다. 그 이유는 아래에서 자세히 살펴보겠다.
//로그인 인증 처리 커스텀 필터
@RequiredArgsConstructor
public class UsernamePasswordAuthenticationCustomFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final ObjectMapper objectMapper;
private final UserLoginSuccessCustomHandler successHandler;
private final UserLoginFailureCustomHandler failureHandler;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//1. body 에서 로그인 정보 받아오기
UserLoginRequestDto loginDto = null;
try {
loginDto = objectMapper.readValue(request.getInputStream(), UserLoginRequestDto.class);
} catch (IOException e) {
throw new RuntimeException("Internal server error");
}
//2. Login ID, Pass 를 기반으로 AuthenticationToken 생성
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getEmail() , loginDto.getPassword());
//3. User Password 인증이 이루어지는 부분
//"authenticate" 가 실행될때 "PrincipalDetailService.loadUserByUsername" 실행
Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
return authenticate;
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
this.failureHandler.onAuthenticationFailure(request,response, failed);
}
}
사용자가 로그인 인증을 요청했을 때, 실행되는 필터이다.
사용자가 요청한 ID, Password를 가지고 UserPasswordAuthenticationToken
을 발급한 후에 AuthenticationManager
에게 전달되고 처리할 수 있는 AuthenticationProvider
가 인증 메서드(loadUserByUsername) 호출하여 로그인 인증이 진행된다.
//"UsernamePasswordCustomFilter" 가 정상적으로 성공할 경우 호출되는 커스텀 Handler => 여기서 JWT 토큰을 반환해준다.
@Slf4j
@Component
@RequiredArgsConstructor
public class UserLoginSuccessCustomHandler implements AuthenticationSuccessHandler {
private final JwtService jwtService;
private final ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("로그인 성공");
//1. 로그인 인증을 마친 사용자 가져오기
User loginUser = ((PrincipalDetails) authentication.getPrincipal()).getUser();
//2. 토큰 생성
JwtToken jwtToken = jwtService.login(loginUser.getUser_id(), loginUser.getNickname(), loginUser.getRole().name());
//3. 반환 Dto 생성
UserLoginDto userLoginDto = new UserLoginDto(loginUser.getUser_id(), loginUser.getNickname(), jwtToken);
DataResponse dataResponse = new DataResponse(String.valueOf(HttpStatus.OK.value()),
"로그인을 성공하였습니다. 토큰이 발급되었습니다.", userLoginDto);
//4. response
String res = objectMapper.writeValueAsString(dataResponse);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpStatus.OK.value());
response.getWriter().write(res);
}
}
정상적으로 사용자 로그인 인증이 완료되었으면 실행되는 핸들러이다.
실제 토큰 발급이 이루어지는 부분이다.
//"UsernamePasswordCustomFilter" 에서 로그인 실패시 실행되는 커스텀 Handler
@Slf4j
@Component
@RequiredArgsConstructor
public class UserLoginFailureCustomHandler implements AuthenticationFailureHandler {
private final ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
log.error("로그인 실패");
//response
BaseErrorResult errorResult = new BaseErrorResult("아이디 또는 비밀번호를 다시 확인해주시기 바랍니다.",
HttpStatus.UNAUTHORIZED.getReasonPhrase(),
String.valueOf(HttpStatus.UNAUTHORIZED.value()));
String res = objectMapper.writeValueAsString(errorResult);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(res);
}
}
로그인 인증이 실패할 경우 호출되는 핸들러이다. (패스워드가 틀렸을때, DB에 사용자가 없을때)
💡로그인 인증이 어떻게 이루어지는것일까?(패스워드 비교 및 사용자가 DB에 없을때)
UsernamePasswordAuthenticationCustomFilter
에서 위의 과정에서authenticate
메서드가 호출된다.
AuthenticationManager
의 구현체인ProviderManager
내부를 살펴보겠다.내부 코드를 살펴보게되면
AuthenticationProvider
의 인터페이스를 호출하게 되는데 이 인터페이스는AbstractUserDetailsAuthenticationProvider
의 상위 인터페이스이다.
그럼AbstractUserDetailsAuthenticationProvider
의authenticate
메서드를 살펴보겠다.내부 로직을 보게 되면
retrieveUser
메서드와additionalAuthenticationChecks
메서드를 각각 호출하는 것을 볼수 있다. 메서드를 확인해보면AbstractUserDetailsAuthenticationProvider
는 추상 클래스이고,
DaoAuthenticationProvider
가 이를 오버라이드하여 구현하였다.
그럼 두 함수를 오버라이드한DaoAuthenticationProvider
내부를 살펴보겠다.
DaoAuthenticationProvider
의retrieveUser
에서UserDetailsService
의loadUserByUsername
를 호출하는 것을 볼 수 있다.
이 과정에서 사용자를 DB에서 찾게 되고 찾지 못할시 UsernameNotFoundExcepion 예외를 발생시키면 catch로 예외를 처리하는 것을 볼수 있다.사용자를 찾은 후에는
additionalAuthenticationChecks
를 호출하여 패스워드 검증이 이루어진다. 또한 DB에 비밀번호가 암호화 되어있더라도 내부적으로passwordEncoder
을 통해 알아서 비교해주는것을 알 수 있다.즉 정리하자면 아래와 같은 그림으로 흘러가는거 같다.
//jwt 인증 처리 커스텀 필터
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
private final JwtProvider jwtProvider;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtProvider jwtProvider) {
super(authenticationManager);
this.jwtProvider = jwtProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
//1. Request Header 토큰 추출
String accessToken = getToken(request);
//2. 토큰 유효성 검사(헤더에 토큰이 있는지, 로그아웃된 토큰인지, 유효성 및 유효기간 검사)
if(StringUtils.hasText(accessToken) && jwtProvider.validBlackToken(accessToken) && jwtProvider.validationToken(accessToken)){
//3. 토큰으로 인증 정보 추출
Authentication authentication = jwtProvider.getAuthentication(accessToken);
if(authentication!=null) {
//4. SecurityContext 에 저장 (인가검증에 사용)
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
//인증 실패시 SecurityContext 에 Authentication 객체가 없어 다음 필터에서 인증 실패 처리
chain.doFilter(request,response);
}
//Request Header 에서 토큰 추출
private String getToken(HttpServletRequest request) {
String token = request.getHeader(jwtProvider.ACCESS_HEADER_STRING);
if(StringUtils.hasText(token) && token.startsWith(jwtProvider.ACCESS_PREFIX_STRING))
return token.substring(7);
return null;
}
}
Header를 통해 JWT 토큰의 인증 요청일 왔을때 처리하는 필터이다.
만약 정상적으로 처리되게 되면 SecurityContext
에 Authentication
객체가 들어가게 되고 다음 인가 검증 필터가 진행된다.
//Spring Security 에서 인증되지 않은 사용자의 리소스에 대한 접근 처리는 AuthenticationEntryPoint 가 담당
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.error("JWT 토큰 인증 실패");
//response
BaseErrorResult errorResult = new BaseErrorResult("인증에 실패하였습니다.",
HttpStatus.UNAUTHORIZED.getReasonPhrase(),
String.valueOf(HttpStatus.UNAUTHORIZED.value()));
String res = objectMapper.writeValueAsString(errorResult);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(res);
}
}
JWT 토큰 인증이 실패하였을때 호출 되는 핸들러이다.
@Slf4j
@Component
@RequiredArgsConstructor
//Spring Security 에서 인증이 되었지만 권한이 없는 사용자의 리소스에 대한 접근 처리는 AccessDeniedHandler 가 담당
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.error("권한이 없는 사용자 접근");
//response
BaseErrorResult errorResult = new BaseErrorResult("권한이 없습니다.",
HttpStatus.FORBIDDEN.getReasonPhrase(),
String.valueOf(HttpStatus.FORBIDDEN.value()));
String res = objectMapper.writeValueAsString(errorResult);
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(res);
}
}
JWT토큰의 인가 실패시 호출되는 핸들러이다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsConfig config;
private final ObjectMapper objectMapper;
private final JwtProvider jwtProvider;
private final UserLoginSuccessCustomHandler successHandler;
private final UserLoginFailureCustomHandler failureHandler;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring()
//유저관련(소셜로그인)
.antMatchers("/codebox/login/token/kakao", "/codebox/login/token/google")
//유저관련(회원가입)
.antMatchers("/codebox/join*", "/codebox/join/mailConfirm", "/codebox/join/validNickName")
//리프레쉬 토큰 관련
.antMatchers("/codebox/refreshToken")
//게시물 관련(정규식 표현)
.antMatchers(HttpMethod.POST,"/codebox/","/codebox/{nickname:^((?!setting|logout|write).)*$}")
.antMatchers(HttpMethod.GET,"/codebox/*/{*[0-9]*$+}" )
//swagger
.antMatchers("/swagger-ui.html/**", "/swagger/**", "/v2/api-docs", "/swagger-resources/**", "/webjars/**")
.antMatchers("/v3/api-docs/**", "/swagger-ui/**")
//test
.antMatchers( "/test", "/login/oauth2/code/kakao", "/login/oauth2/code/google", "/tokenParsingTest");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf().disable()
// 시큐리티는 기본적으로 세션을 사용
// 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 시큐리티가 제공해주는 폼 로그인 UI 사용안함
// 헤더에 토큰으로 "basic "으로 된 토큰을 사용하는 경우 -> httpBasic() / 사용하지 않으면 "BasicAu~"가 작동안하는데 우리는 JWT 토큰을 사용하니 커스텀해서 등록해주기
.and()
.formLogin().disable()
.httpBasic().disable()
// exception handling 할 때 우리가 만든 클래스를 추가
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
// 커스텀 필터 등록
.apply(new MyCustomDsl())
.and()
//인증, 권한 api 설정
.authorizeRequests()
//유저 관련
.antMatchers("/codebox/setting").hasAuthority("USER") //get, put
.antMatchers(HttpMethod.GET, "/codebox/logout").hasAuthority("USER")
//게시물 관련
.antMatchers(HttpMethod.POST, "/codebox/write").hasAuthority("USER")
.antMatchers("/codebox/*/*/edit").hasAuthority("USER") //get, post
.antMatchers(HttpMethod.DELETE, "/codebox/*/*").hasAuthority("USER")
//댓글 관련
.antMatchers(HttpMethod.POST, "/codebox/*/*/reply/add").hasAuthority("USER")
.antMatchers("/codebox/*/*/reply/*").hasAuthority("USER") //get,put,delete
//좋아요 관련
.antMatchers( "/codebox/*/*/like").hasAuthority("USER") //post,get
//팔로우 관련
.antMatchers("/codebox/follow/*").hasAuthority("USER")
.and()
.build();
}
//jwt 커스텀 필터 등록
public class MyCustomDsl extends AbstractHttpConfigurer<MyCustomDsl, HttpSecurity> {
@Override
public void configure(HttpSecurity http) {
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
http.addFilter(config.corsFilter()); //스프링 시큐리티 필터내에 cors 관련 필터가 있음!! 그래서 제공해주는 필터 객체를 생성후 HttpSecurity에 등록!
http.addFilter(new UsernamePasswordAuthenticationCustomFilter(authenticationManager, objectMapper , successHandler, failureHandler));
http.addFilter(new JwtAuthenticationFilter(authenticationManager, jwtProvider));
}
}
}
참고로 .formLogin().disable()
로 설정하게 되면 시큐리티가 제공해주는 login 폼을 사용하지 않기 때문에 UsernamePasswordAuthenticationFilter
필터도 동작하지 않게 된다.
그래서 UsernamePasswordAuthenticationFilter
을 커스텀 하여 직접 등록했던 것이다.
세션 정책을 위 코드와 같이 한다는 의미는 인증 처리 관점에서 스프링 시큐리티가 세션을 검증하는 필터를 사용하지 않겠다는 의미이다.
자세히 말하자면 스프링 시큐리티가 제공하는 로그인 uri인/login
통해서 (post)form,json 방식으로 로그인을 시도하면UsernamePasswordAuthenticationFilter
가 인증처리를 하고 결과로 Authentication 객체를 생성하게 된다.
그 후 SpringContext(스프링 시큐리티 전용 세션영역)에 Authentication 객체를 저장하기 위해서SecurityContextPersistenceFilter
가 그역할을 하게된다.
하지만 JWT 토큰방식을 사용하게 되면 세션영역을 사용할 필요가 없기 때문에 위와같은 설정을 하게 되었다.
/**
* JWT 를 사용할때 반드시 해주기 "CORS 정책"
*/
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true); //내서버가 응답을 할때 json을 자바스크립트에서 처리할 수 있게 할지
config.addAllowedOriginPattern("*"); //모든 아이피를 응답허용
config.addAllowedHeader("*"); //모든 header 응답허용
config.addAllowedMethod("*"); //모든 post,get,put 허용
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
💡SOP란?
: SOP란 같은 Origin에만 요청을 주고받을수 있게 제한하는 정책이다. 즉 같은 호스트, 같은 포트, 같은 프로토콜을 사용해야지 접근이 가능하다. 스프링 부트는 기본설정이 SOP정책을 사용한다.
@Slf4j
@NoArgsConstructor
public class SecurityUtil {
//Security Context 에 저장되어있는 인증 객체(유저 객체) 가져오기
public static User getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();
User user = principal.getUser();
return user;
}
}
JWT 토큰 인증,인가가 완료되었으면 위에서 말한대로 SecurityContext
에 Authentication
객체가 들어가게 된다.
위의 메서드는 인증이 완료된 사용자 정보를 SecurityContext
에서 꺼내 유저 정보를 사용하기 위한 용도로 사용된다.
로그인 URL 변경하기💡
SecurityConfig
에서.formLogin().disable()
와 같이 설정하게 되면 시큐리티가 제공해주는 로그인 폼을 사용하지 않기 때문에/login url POST
요청으로 오는 로그인 인증을 위한UsernamePasswordAuthenticationFilter
필터가 동작하지 않는다!!기존에는
UsernamePasswordAuthenticationFilter
을 오버라이드 한 구현체를 등록해주는 방식으로 사용하였는데, 로그인을 위한 URL을 변경하지 못하는 문제가 있었다.살펴보니
UsernamePasswordAuthenticationFilter
는AbstractAuthenticationProcessingFilter
추상 클래스를 오버라이드한 구현체 였다.
UsernamePasswordAuthenticationFilter
구현 클래스에서 상위 클래스인AbstractAuthenticationProcessingFilter
생성자로 로그인 URL을 넣어주는것을 알 수 있었다.그래서
UsernamePasswordAuthenticationFilter
을 상속하여 구현하는 것이 아니라,
AbstractAuthenticationProcessingFilter
을 상속하도록 코드를 바꾸어 로그인 URL을 변경할 수 있었다.
아래 코드와 같다.//로그인 인증 처리 커스텀 필터 //@RequiredArgsConstructor public class UsernamePasswordAuthenticationCustomFilter extends AbstractAuthenticationProcessingFilter { private final ObjectMapper objectMapper; private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/codebox/login", "POST"); public UsernamePasswordAuthenticationCustomFilter(AuthenticationManager authenticationManager, ObjectMapper objectMapper, UserLoginSuccessCustomHandler userLoginSuccessCustomHandler, UserLoginFailureCustomHandler userLoginFailureCustomHandler) { super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager); this.objectMapper = objectMapper; setAuthenticationSuccessHandler(userLoginSuccessCustomHandler); setAuthenticationFailureHandler(userLoginFailureCustomHandler); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { //1. body 에서 로그인 정보 받아오기 UserLoginRequestDto loginDto = null; try { loginDto = objectMapper.readValue(request.getInputStream(), UserLoginRequestDto.class); } catch (IOException e) { throw new RuntimeException("Internal server error"); } //2. Login ID, Pass 를 기반으로 AuthenticationToken 생성 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getEmail() , loginDto.getPassword()); //3. User Password 인증이 이루어지는 부분 //"authenticate" 가 실행될때 "PrincipalDetailService.loadUserByUsername" 실행 Authentication authenticate = this.getAuthenticationManager().authenticate(usernamePasswordAuthenticationToken); // authenticationManager.authenticate(usernamePasswordAuthenticationToken); return authenticate; }
기존과 내부 코드는 동일하지만
AbstractAuthenticationProcessingFilter
을 오버라이드하여 구현하였다.