이전 글
- Spring Session을 이용한 로그인 https://devhtak.github.io/spring/2021/02/23/JWT_SpringSession.html
- JWT와 Spring boot를 이용한 로그인 https://devhtak.github.io/spring/2021/02/28/JWT_SpringBoot.html
JWT와 Spring Security
- JWT와 Spring Security를 연동하여 로그인 구현을 진행하려고 한다.
-
이전 글에서는 하드코딩된 User와 Coffee를 조회하였는 데, JPA를 활용하여 DB 연동까지 진행하려고 한다.
- 회원 엔티티
- Member.java
@Getter @Entity public class Member { @Id @GeneratedValue @JsonIgnore private Long id; @NotNull @Size(min = 4, max = 50) private String username; @NotNull @Size(min = 4, max = 100) private String password; @NotNull @Size(min = 4, max = 50) private String email; @NotNull @Size(min = 4, max = 50) private String role; }
- //TODO password에 경우 encoding된 것을 저장해야 하기 떄문에 범위가 달라질 수 있다.
- //TODO role과 enum 타입의 Role의 차이가 발생했다. @Enumerated(EnumType.STRING)로 타입이 저장될 수 있도록 수정이 필요하다.
- EnumType.ORDINAL과 EnumType.STRING
- Anti pattern: ordinal에 경우 숫자가 저장되어 type safety에 위배된다. 만약 enum타입 중간에 추가되거나 순서가 변경되는 경우, 저장된 데이터가 완전 꼬이게 된다.
- Member.java
- 인증 & 인가 구현
- 기존 Http Session을 활용한 예제와 JWT + Spring Boot를 활용한 예제에서는 Interceptor를 구현했지만 여기에서는 Filter를 사용하여 Security Config에 설정한다.
- Interceptor: prevHandle, postHandle, afterCompletion으로 핸들러 앞에서 요청에 대한 처리를 해줄 수 있다.
- Filter: DispatcherServlet 앞단에서 동작하여 요청에 대한 처리를 해줄 수 있다.
- 둘의 가장 큰 차이는 Context(실행 영역)에 있다. Filter에 경우 Web Application 내에서 동작하기 때문에 Spring Context에 접근하기 어렴지만, Interceptor에 경우 Spring 영역 내에 있기 때문에 Spring Context에 접근하기 용이하다.
- JwtFilter.java
public class JWTFilter extends GenericFilterBean { private static final String AUTHORIZATION_HEADER = "x-auth-token"; private JwtAuthTokenProvider jwtAuthTokenProvider; JWTFilter(JwtAuthTokenProvider jwtAuthTokenProvider) { this.jwtAuthTokenProvider = jwtAuthTokenProvider; } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest; Optional<String> token = resolveToken(httpServletRequest); if (token.isPresent()) { JwtAuthToken jwtAuthToken = jwtAuthTokenProvider.convertAuthToken(token.get()); if(jwtAuthToken.validate()) { Authentication authentication = jwtAuthTokenProvider.getAuthentication(jwtAuthToken); SecurityContextHolder.getContext().setAuthentication(authentication); } } filterChain.doFilter(servletRequest, servletResponse); } private Optional<String> resolveToken(HttpServletRequest request) { String authToken = request.getHeader(AUTHORIZATION_HEADER); if (StringUtils.hasText(authToken)) { return Optional.of(authToken); } else { return Optional.empty(); } } }
- GenericFilterBean을 활용하여 Filter 생성
- Spring은 spring config 설정 정보를 쉽게 처리하기 위해 GenericFilterBean을 제공한다.
- Filter 구현과 동일하며 getFilterConfig()나 getEnvironment()를 제공한다.
- 인증 과정은 동일하다.
- Spring Security에 경우 SecurityContext에 인증 객체를 설정해야 한다!!
SecurityContextHolder.getContext().setAuthentication(authentication);
- 인증 객체는 Authentication 의 구현체만 가능하며, UsernamePasswordAuthenticationToken을 활용하였다.
- GenericFilterBean을 활용하여 Filter 생성
- JwtFilter를 등록한 후 WebSecurityConfigurerAdapter에 적용해준다.
- SecurityConfigureAdapter를 상속하는 JWTConfigurer 클래스에 필터를 설정한 후
-
WebSecurityConfig 설정에 필터를 최종 설정하였다.
- JwtConfigure.java
public class JWTConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { private JwtAuthTokenProvider jwtAuthTokenProvider; public JWTConfigurer(JwtAuthTokenProvider jwtAuthTokenProvider) { this.jwtAuthTokenProvider = jwtAuthTokenProvider; } @Override public void configure(HttpSecurity http) { JWTFilter customFilter = new JWTFilter(jwtAuthTokenProvider); http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class); } }
- SecurityConfig.java
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig extends WebSecurityConfigurerAdapter{ private final JwtAuthTokenProvider jwtAuthTokenProvider; private final JwtAuthenticationEntryPoint authenticationErrorHandler; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity httpSecurity) throws Exception { httpSecurity .csrf().disable() .exceptionHandling() .authenticationEntryPoint(authenticationErrorHandler) .accessDeniedHandler(jwtAccessDeniedHandler) .and() .headers() .frameOptions() .sameOrigin() .and() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/v1/login/**").permitAll() .antMatchers("/api/v1/coffees/**").hasAnyAuthority(Role.USER.getCode()) .anyRequest().authenticated() .and() .apply(securityConfigurerAdapter()); } @Override public void configure(WebSecurity web) { web.ignoring() .antMatchers(HttpMethod.OPTIONS, "/**") // allow anonymous resource requests .antMatchers( "/", "/h2-console/**" ); } private JWTConfigurer securityConfigurerAdapter() { return new JWTConfigurer(jwtAuthTokenProvider); } }
- exceptionHandling()
- 인증 또는 인가에 실패한 경우 exception 처리
- sessionManagement()
- 세션 기능을 사용하지 않는다.
- antMatchers(“/api/v1/login”).permitAll()
- 인증 없이 /api/v1/login으로 접근 가능하다.
- antMatchers(“/api/v1/coffess”).hasAnyAuthority(Role.USER.getCode())
- Role.USER 권한이 있는 경우 api/v1/coffess 접근이 가능하다.
- headers().frameOptions().sameOrigin()
- HTTP 응답 헤더 중 X-Frame-Options가 있다. frame 또는 iframe 태그로 접근할 수 있는 설정
- deny: 어떤 사이트에서도 frame 상에서 보여질 수 없다.
- sameOrigin: 동일한 사이트의 frame만 보여진다.
- allow-from uri: 지정된 특정 URI의 frame만 보여진다.
- exceptionHandling()
- 기존 Http Session을 활용한 예제와 JWT + Spring Boot를 활용한 예제에서는 Interceptor를 구현했지만 여기에서는 Filter를 사용하여 Security Config에 설정한다.
-
로그인 구현 및 인증 통과
- LoginController.java
@RestController @RequiredArgsConstructor public class LoginController { private final LoginService loginService; @PostMapping("/api/v1/login") public CommonResponse login(@RequestBody LoginRequestDTO loginRequestDTO) { MemberDTO optionalMemberDTO = loginService.login(loginRequestDTO.getEmail(), loginRequestDTO.getPassword()) .orElseThrow(LoginFailedException::new); JwtAuthToken jwtAuthToken = (JwtAuthToken) loginService.createAuthToken(optionalMemberDTO); return CommonResponse.builder() .code("LOGIN_SUCCESS") .status(200) .message(jwtAuthToken.getToken()) .build(); } }
- login한 객체를 가져와, token을 생성하고, response message에 넣어 보내준다.
- LoginService.java
@Service @RequiredArgsConstructor public class LoginService implements LoginUseCase { private final AuthenticationManagerBuilder authenticationManagerBuilder; private final JwtAuthTokenProvider jwtAuthTokenProvider; private final static long LOGIN_RETENTION_MINUTES = 30; @Override public Optional<MemberDTO> login(String email, String password) { // TODO Auto-generated method stub UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, password); Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); SecurityContextHolder.getContext().setAuthentication(authentication); Role role = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .findFirst() .map(Role::of) .orElse(Role.UNKNOWN); MemberDTO memberDTO = MemberDTO.builder() .username("eddy") .email(email) .role(role) .build(); return Optional.ofNullable(memberDTO); } @Override public AuthToken createAuthToken(MemberDTO memberDTO) { // TODO Auto-generated method stub Date expiredDate = Date.from(LocalDateTime.now().plusMinutes(LOGIN_RETENTION_MINUTES).atZone(ZoneId.systemDefault()).toInstant()); return jwtAuthTokenProvider.createAuthToken(memberDTO.getEmail(), memberDTO.getRole().getCode(), expiredDate); } }
- login 메서드를 확인해보면 UsernamePasswordAuthenticationToken 인증 객체를 만든다.
- 패스워드 비교 등에 로직은 구현하지 않았다.
- UserDetailsService에서 구현한 loadUserByUsername 메서드에서 실행한다.
- CustomUserDetailsService.java
@Service @RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService{ private final MemberRepository memberRepository; @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { // TODO Auto-generated method stub return memberRepository.findByEmail(email) .map(this::createSpringSecurityUser) .orElseThrow(RuntimeException::new); } private User createSpringSecurityUser(Member member) { List<GrantedAuthority> authority = Collections.singletonList(new SimpleGrantedAuthority(member.getRole())); return new User(member.getEmail(), member.getPassword(), authority); } }
- loadByUsername에서 회원 객체를 가져와야 한다.
- UserDetails 인터페이스를 리턴해야 하는데, 여기에서는 Spring에서 제공하는 User를 리턴했다.
- User 객체 생성할 때에는 username, password, Collection<? extends GrantedAuthority> authorities가 파라미터로 입력되는데, authorities는 ROLE을 정의하는 리스트로 만들어준다.
- LoginController.java
- Spring Security ExceptionHandler
- Login 실패 시 BadCredentialsException 예외가 발생한다.
@ExceptionHandler(BadCredentialsException.class) protected ResponseEntity<CommonResponse> handleBadCredentialsException(BadCredentialsException e) { log.info("handleBadCredentialsException", e); CommonResponse response = CommonResponse.builder() .code(ErrorCode.Login_FAILED.getCode()) .message(e.getMessage()) .status(ErrorCode.Login_FAILED.getStatus()) .build(); return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); }
- 인증 실패
private final JwtAuthenticationEntryPoint authenticationErrorHandler; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; // ... .exceptionHandling() .authenticationEntryPoint(authenticationErrorHandler) .accessDeniedHandler(jwtAccessDeniedHandler) // ...
- 인증이 되지 않았을 경우 AuthenticationEntryPoint 부분에서 AuthenticationException이 발생한다.
- 인증은 되었으나 해당 요청에 대한 권한이 없는 경우 AccessDeniedHandler 부분에서 AccessDeniedException이 발생한다.
- 그렇기 떄문에 인증 실패에 대한 재정의는 AuthenticationEntryPoint, AccessDeniedHandler를 재정의 해서 사용한다.
- JwtAccessDeniedHandler
@Component @RequiredArgsConstructor public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { private final HandlerExceptionResolver handlerExceptionResolver; @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { //response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); handlerExceptionResolver.resolveException(request, response, null, authException); } }
- AuthenticationExceptionHandler
@ExceptionHandler(InsufficientAuthenticationException.class) protected ResponseEntity<CommonResponse> handleInsufficientAuthenticationException(InsufficientAuthenticationException e) { log.info("handleInsufficientAuthenticationException", e); CommonResponse response = CommonResponse.builder() .code(ErrorCode.AUTHENTICATION_FAILED.getCode()) .message(ErrorCode.AUTHENTICATION_FAILED.getMessage()) .status(ErrorCode.AUTHENTICATION_FAILED.getStatus()) .build(); return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); }
- JwtAuthenticationEntryPoint
@Component @RequiredArgsConstructor public class JwtAccessDeniedHandler implements AccessDeniedHandler { private final HandlerExceptionResolver handlerExceptionResolver; @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { handlerExceptionResolver.resolveException(request, response, null, accessDeniedException); } }
- AccessDeniedExceptionHandler
@ExceptionHandler(AccessDeniedException.class) protected ResponseEntity<CommonResponse> handleAccessDeniedException(AccessDeniedException e) { log.info("handleAccessDeniedException", e); CommonResponse response = CommonResponse.builder() .code(ErrorCode.ACCESS_DENIED.getCode()) .message(ErrorCode.ACCESS_DENIED.getMessage()) .status(ErrorCode.ACCESS_DENIED.getStatus()) .build(); return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); }
- Login 실패 시 BadCredentialsException 예외가 발생한다.
- 출처: https://brunch.co.kr/@springboot/491