Spring Security를 이용한 JWT 로그인 과정 가보자잇

Kevin·2023년 5월 27일
5

Spring

목록 보기
10/11
post-thumbnail

스프링 시큐리티에 대해서 공부를 하면서, Authentication Provider, Manager?? UserDetail? Filter Chain등 여러가지 생소한 개념들을 처음 듣게 되면서 스프링 시큐리티에 잔뜩 쫄아있었다. 사실 아직도 좀 쫄아있긴 하다.

그래서 나는 정면돌파 해보기로 하였다. 유저가 로그인을 하였을 때 어떤 Flow로 Spring Security가 동작하는지를 직접 하나하나 코드들을 뜯어보면서, 공부를 해보았고, 이를 통해서 나름대로의 공포를 극복한 것 같아서 나 처럼 스프링 시큐리티에 막연하게 겁을 먹은 사람들을 위해 이렇게 글로 남기고자 한다.

세상 모든 겁쟁이들 파이팅!!


로그인을 할 때의 Process

1. 유저는 로그인 url로 로그인 Request를 보낸다.


2. Spring Security는 LoginFilter인 UsernamePasswordAuthenticationFilter로 가던 Request를 JwtLoginFilter로 보낸다.

  • JWTLoginFilter로 Request를 보내기 위해서, ScurityConfig에서 기존 UsernamePasswordAuthenticationFilter와 BasicAuthenticationFilter를 대신해서 JWTLoginFilterJWTCheckFilter를 사용하겠다고 코드를 작성해준다.
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class AdbancedSecurityConfig extends WebSecurityConfigurerAdapter  {

    @Autowired
    private SpUserService userService;

    @Bean
    PasswordEncoder passwordEncoder(){
        return NoOpPasswordEncoder.getInstance();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 로그인을 처리해주는 jwtLoginFilter와 로그인 된 토큰을 매번 request마다 검증해줄 jwtCheckFilter
        // jwtLoginFilter은 authenticationManager에게 유저 검증을 위임하지만, jwtCheckFilter는 사용자를 직접 가져와야 할 상황이 생기기에 SpUserService가 필요함
        JWTLoginFilter jwtLoginFilter = new JWTLoginFilter(authenticationManager(), userService);
        JWTCheckFilter jwtCheckFilter = new JWTCheckFilter(authenticationManager(), userService);

        http
                .csrf().disable()
                // Token을 사용하기 때문에 세션을 사용안한다.
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                // 기존 UsernamePasswordAuthenticationFilter의 역할을 jwtLoginFilter가 맡게 되었음.
                .addFilterAt(jwtLoginFilter, UsernamePasswordAuthenticationFilter.class)
                .addFilterAt(jwtCheckFilter, BasicAuthenticationFilter.class);
    }
}

이 때 Request는 **UserLoginForm**이라는 객체에 담겨서 전달된다. → Dto라고 생각을 하면 된다.
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserLoginForm {

    private String username;
    private String password;
    private String refreshToken;
}

3. JwtLoginFilter에서는 Ruquest를 UserLoginForm(ex) DTO)으로 받아오고, 이를 기반으로 아직은 권한이 null인 Authenticatrion을 생성해준다. 그 후 AuthenticationManager에게 해당 Authentication의 유효성 검증을 부탁한다.

// id, pwd를 받아 유효한 유저인지를 검증 후 유효한 사용자라면 인증 filter를 내려주는 책임
public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter{

    private SpUserService spUserService;
    private ObjectMapper objectMapper = new ObjectMapper();

    public JWTLoginFilter(AuthenticationManager authenticationManager, SpUserService spUserService) {
        super(authenticationManager);
        this.spUserService = spUserService;
        setFilterProcessesUrl("/login");
    }

    // 사용자 인증을 처리한다.
    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, TokenExpiredException {
        // request로부터 UserLoginForm을 읽어온다.
        UserLoginForm userLogin = objectMapper.readValue(request.getInputStream(), UserLoginForm.class);

        // refreshToken이 없다면, username, password를 통해서 인증 객체를 생성
        if(userLogin.getRefreshToken() == null) {
            // 아직은 권한이 null인 Authentication을 생성해준다.
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
                    userLogin.getUsername(), userLogin.getPassword(), null
            );
            // AuthenticationManager에게 token의 유효성 검증을 부탁
            return getAuthenticationManager().authenticate(token);
        }
        
        // refreshToken이 존재한다면
        VerifyResult verify = JWTUtil.verify(userLogin.getRefreshToken());
        // AuthToken과 RefreshToken을 다시 생성해준다. -> successfulAuthentication()이 자동 호출되므로
        if(verify.isSuccess()){
            SpUser user = (SpUser) spUserService.loadUserByUsername(verify.getUsername());
            return new UsernamePasswordAuthenticationToken(
                    user, user.getAuthorities());
        } else{
            throws new TokenExpiredException("refresh token expired");
        }
    }

    // 인증이 성공되었을 때 호출되는 메서드
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // Principal에 들어가있는 User를 불러온다.
        SpUser spUser = (SpUser) authResult.getPrincipal();
        response.setHeader("auth_token", JWTUtil.makeAuthToken(spUser));
        response.setHeader("refresh_token", JWTUtil.makeRefreshToken(spUser));
        response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
        // Login 성공시 유저에게 Response로 로그인 된 유저의 객체를 넘겨준다.
        response.getOutputStream().write(objectMapper.writeValueAsBytes(spUser));
    }
}
  • attemptAuthentication 메서드에서 refreshToken의 유/무 여부에 따라서 분기처리 된다.
    • RefreshToken이 Request에 있다면, 유효한 Token인지를 검증해주고, VerifyResult라는 Dto 객체에 담아준다.
    • 검증값이 True이면, 바로 유효한 Authentication 객체를 Return 해주고, False이면 Token 만료 예외를 리턴해준다.
    • RefreshToken이 Request에 없다면, Authentication 객체를 생성해서 AuthenticationManager에게 유효성 검증을 요청한다.
      • AuthenticationManager는 AuthenticationProvider들 중에서 검증할 수 있는 AuthenticationProvider에게 인증을 위임한다.

      • 더 자세한 것은 아래의 글을 참고 바란다.

        스프링 시큐리티 정면돌파하기


  • VerifyResult의 코드이다.
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    public class VerifyResult {
        private boolean success;
        private String username;
    }

4. 만약 AuthenticationManager가 유효성 검증을 True로 반환하면 인증 성공 이벤트를 발생시키면서, 이벤트를 처리하는 핸들러(=AuthenticationSuccessHandler)에 의해 JWTLoginFilter의 successfulAuthentication 메서드가 호출이된다.

  • 이 때 AuthenticationSuccessHandler에서는 로그인시 리다이렉트 할 주소나 세션 관리, 로그 기록, 사용자 정보 업데이트 등의 로직을 수행할 수 있다.
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // 인증 성공 후 처리할 작업을 구현합니다.
        // 예를 들어, 세션 관리, 로그 기록, 사용자 정보 업데이트 등을 수행할 수 있습니다.

        // 인증 성공 후 리다이렉트할 URL을 설정합니다.
        response.sendRedirect("/home");
    }

5. successfulAuthentication에서는 헤더에 인증 토큰을 만들어주고, body에 유저에게 인증이 성공한 유저 객체를 반환해준다.

    // 인증이 성공되었을 때 호출되는 메서드
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // Principal에 들어가있는 User를 불러온다.
        SpUser spUser = (SpUser) authResult.getPrincipal();
        response.setHeader("auth_token", JWTUtil.makeAuthToken(spUser));
        response.setHeader("refresh_token", JWTUtil.makeRefreshToken(spUser));
        response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
        // Login 성공시 유저에게 Response로 로그인 된 유저의 객체를 넘겨준다.
        response.getOutputStream().write(objectMapper.writeValueAsBytes(spUser));
    }
  • JWTUtil은 JWT 생성, 검증, 정의를 하는 객체이다.
    public class JWTUtil {
    
        private static final Algorithm algorithm = Algorithm.HMAC256("secret-key");
        private static final long AUTH_TIME = 20*60; // Auth가 유지되는 시간 = 20분
        private static final long REFRESH_TIME = 60*60+24*7; // REFRESH가 유지되는 시간 = 일주일
    
        // user 객체를 기반으로 AuthToken을 생성해주는 메서드
        public static String makeAuthToken(SpUser user){
            return JWT.create()
                    .withSubject(user.getUsername())
                    .withClaim("exp", Instant.now().getEpochSecond()+AUTH_TIME) // withExpireAt()은 Date 객체를 써야함으로 claim에 exp로 직접적어줌
                    .sign(algorithm);
        }
    
        // user 객체를 기반으로 RefreshToken을 생성해주는 메서드
        public static String makeRefreshToken(SpUser user){
            return JWT.create()
                    .withSubject(user.getUsername())
                    .withClaim("exp", Instant.now().getEpochSecond()+REFRESH_TIME) 
                    .sign(algorithm);
        }
        
        // Token 유효한지를 검증하는 메서드
        public static VerifyResult verify(String token){
            try {
                // Token의 유효성을 verify(token)를 통해서 검증, 유효하다면 VerifyResult 객체에 성공값과 유효한 토큰의 값을 반환한다.
                DecodedJWT verify = JWT.require(algorithm).build().verify(token);
                return VerifyResult.builder().success(true)
                        .username(verify.getSubject())
                        .build();
    
            }catch (Exception ex){
                // Token이 유효하지 않았다면 VerifyResult 객체에 실패값과 해독한 토큰의 값을 반환한다.
                DecodedJWT decode = JWT.decode(token);
                return VerifyResult.builder().success(false)
                        .username(decode.getSubject())
                        .build();
            }
        }
    }

인증이 필요한 일반 Request를 할 때의 Process

1. Client가 인증이 필요한 url에 Request를 보낸다.

  • 단 Client는 반드시 Header에 로그인시 받았던 AuthToken을 전달해줘야 한다.

2. LoginCheckFilter에서 해당 Request를 받고, 넘겨받은 bear Token(AuthToken)이 유효한지 검증한다.

// 토큰의 유효성 검증
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        // HTTP Hear에 AUTHORIZATION 헤더에 있는 bearer 토큰을 가져옴
        String bearer = request.getHeader(HttpHeaders.AUTHORIZATION);

        // bearer 토큰이 잘못된 경우 그 다음 filter로 그냥 흘려 보내야한다. -> 추후에 예외 처리나 리다이렉트 처리
        if(bearer == null || !bearer.startsWith("Bearer ")){
            chain.doFilter(request, response);
            return;
        }

        // bearer 토큰이면 이를 검증해본다.
        String token = bearer.substring("Bearer ".length());
        VerifyResult result = JWTUtil.verify(token);

        // 만약 인증이 된 사용자라면 request token에 있던 username으로부터 User 객체 생성
        if(result.isSuccess()){
            SpUser user = (SpUser) userService.loadUserByUsername(result.getUsername());
            UsernamePasswordAuthenticationToken userToken = new UsernamePasswordAuthenticationToken(
                    user.getUsername(), null, user.getAuthorities()
            );
            // 그리고 SecurityContextHolder에 Authentication을 넣어준다.
            SecurityContextHolder.getContext().setAuthentication(userToken);
        }else {
            throw new AuthenticationException("Token is not valid");
        }
        chain.doFilter(request, response);
    }
}
  • 만약 AuthToken이 유효하다면 Token에 들어있던 username, password 등으로 Authentication 객체를 생성하고, 이를 SecurityContextHolder 에 넣어준다.
  • AuthToken이 유효하지 않다면 그 다음 filter로 흘려 보내거나 예외 처리를 해주어야 한다.
profile
Hello, World! \n

0개의 댓글