[SpringBoot] 스프링 시큐리티 Form Login 방식에서 Jwt 인증 방식으로 변경

    JWT 인증 방식 적용

    1. SecurityConfiguration 수정

    시큐리티 Form Login 방식에서 JWT 인증방식으로 변경하려면 

    SecurityConfig에서 FormLogin방식과 HttpBasic 인증방식을 disable 해야 한다.

    그리고 JWT를 사용하는 이유 중 하나인 session 유지를 비활성화한다.

     

    [ httpBasic().disable 이유 ]

    HttpBasic 인증 방식은 request 헤더에 Authorization 키 값에 Basic 뒤에 사용자 아이디, 패스워드를 base64로 인코딩해서 전송하여 인증하는 방식이다. base64 방식은 누구나 쉽게 복호화할 수 있어서 HttpBasic 인증 방식을 사용하지 않는다. 사용하더라도 아이디 패스워드를 암호화해서 전송되도록 HTTPS를 써야 한다.

     

    위 HttpBasic 방식을 안 쓰고 JWT 인증 방식을 사용하면 Authorization 키 값에 Basic 대신에 Bearer로 변경되고 그 뒤에 인코딩 된 JWT 토큰이 있다. 

     

    [ formLogin().disable 이유 ]

    Form Login 방식을 사용하지 않는다는것은 스프링 시큐리티에서 기본적으로 제공하는 login 구현 방식을 사용하지 않는 다는 것이다. 

    JWT 토큰을 이용한 로그인 절차로 커스터마이징 할 것이기 때문에 비활성화하고 로그인 절차를 구현해야 한다.

     

    [ session 유지 비활성화와 JWT 사용 이유 ]

    JWT 토큰을 요청 때마다 전송하여 인증할 것 이기 때문에 session 유지를 활성화 비활성화 한다.

    session 유지를 비활성화한다는 것은 요청 때마다 인증 정보를 요청한다는 것이다.

    만약 세션 유지를 비활성화하고 http basic방식을 사용한다 하면 Authorization 키 값에 아이디, 비밀번호를 계속 전송해야 하는데 이러면 보안이 훨씬 더 취약해진다.

     

    세션 유지를 활성화해서 사용한다는 것은 요청이 올 때마다 서버에서 클라이언트에서 받은 쿠키의 세션 ID와 서버의 세션 ID를 비교해서 인증해서 인증을 한다는 뜻인데 사용자별로 생기는 세션은 사용자가 많아질수록 세션 인증 방식 때문에 서버에 많은 과부하가 생긴다. 

    JWT 토큰을 사용하여 사용자의 아이디와 비밀번호를 그대로 서버에 전송하지 않는 보안적 이점과 세션을 세션 유지를 비활성화하여 서버에 부담감을 줄이게 한다. 이것이 JWT 토큰을 사용하는 기본적인 이유다.

     

    2. build.gradle 추가 및 JwtProperties 생성

    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'

     

    application.yml 파일에 jwt secret_key, accessToken 만료시간 3분, refresh 만료시간 30분을 정의하고 JwtProperties 클래스를 만들어 값을 매핑시켰다. secret_key는 아무 문자열을 base64로 인코딩 한 값이다.

     

    참고 : access 토큰과 refresh 토큰을 따로 만드는 이유는 Jwt 토큰을 사용함으로써 서버의 부하나 보안적으로 이점은 있지만 access 토큰 자체가 탈취되었을 때는 탈취자는 인증을 할 수 있게 된다. 이 단점을 보안하기 위해서 access 토큰의 시간을 짧게 3분으로 짧게 유지시키고 refresh 토큰을 30분으로 길게 늘여서 access 토큰이 탈취당하더라도 탈취자가 최대한 적은 시간 토큰을 사용할 수 있게 하고 refres 토큰으로 access 토큰을 재발급하여 사용자가 다시 로그인할 필요 없이 서비스를 사용할 수 있도록 한다.

    3. build.gradle 추가 및 JwtProperties 생성

    @Slf4j
    @Component
    public class JwtUtil {
        private final String AUTHORIZATION_HEADER = "Authorization";
        private final String TOKEN_PREFIX = "Bearer ";
        private final String ACCESS_TOKEN = "ACCESS_TOKEN";
        private final String REFRESH_TOKEN = "REFRESH_TOKEN";
        private final JwtProperties jwtProperties;
        private final SecretKey secretKey;
    
        public JwtUtil(JwtProperties jwtProperties) {
            this.jwtProperties = jwtProperties;
            secretKey = new SecretKeySpec(jwtProperties.getSecretKey().getBytes(),
                    Jwts.SIG.HS256.key().build().getAlgorithm());
        }
    
        //http 헤더 Authorization의 값이 jwt token인지 확인하고 token값을 넘기는 메서드
        public String resolveToken(HttpServletRequest request) {
            String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
    
            if (bearerToken != null && bearerToken.startsWith(TOKEN_PREFIX)) {
                return bearerToken.substring(TOKEN_PREFIX.length());
            }
    
            return null;
        }
    
        public String createAccessToken(Member member) {
            return createToken(member, jwtProperties.getAccessTokenValidTimeMillis(), ACCESS_TOKEN);
        }
    
        public String createRefreshToken(Member member) {
            return createToken(member, jwtProperties.getRefreshTokenValidTimeMillis(), REFRESH_TOKEN);
        }
    
        private String createToken(Member member, Long tokenValidTimeMillis, String tokenType) {
            Long memberId = member.getMemberId();
            String memberEmail = member.getEmail();
            Date currentDate = new Date();
            Date expireDate = new Date(currentDate.getTime() + tokenValidTimeMillis);
    
            return Jwts.builder()
                    .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                    .setIssuedAt(currentDate)
                    .setExpiration(expireDate)
                    .claim("tokenType", tokenType)
                    .claim("email", memberEmail)
                    .claim("id", memberId)
                    .claim("role", member.getRole())
                    .signWith(SignatureAlgorithm.HS256, secretKey)
                    .compact();
        }
    
        public Claims getClaims(String token) {
            return Jwts
                    .parser()
                    .setSigningKey(secretKey)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        }
    
        public Long getMemberId(String token) {
            Claims claims = getClaims(token);
            return claims.get("id", Long.class);
        }
    
        public String getTokenType(String token) {
            Claims claims = getClaims(token);
            return claims.get("tokenType", String.class);
        }
    
        public boolean validateToken(String token) {
            try {
                Jwts.parser()
                        .verifyWith(secretKey)
                        .build()
                        .parseClaimsJws(token);
                return true;
            } catch (MalformedJwtException ex) {
                log.error("Invalid JWT token");
            } catch (ExpiredJwtException ex) {
                log.error("Expired JWT token");
            } catch (UnsupportedJwtException ex) {
                log.error("Unsupported JWT token");
            } catch (IllegalArgumentException ex) {
                log.error("JWT claims string is empty.");
            }
            return false;
        }
    }

    JwtUtil 클래스는 Jwt토큰을 생성하고 토큰 검증을 하기 위한 여러 가지 메서드들을 정의한 클래스이다.

    createToken() : 메서드로 토큰을 생성

    resolveToken() : 메서드로 Authorization 키값에서 Bearer를 빼고 뒤에 붙은 Jwt 토큰만을 가져온다.

    validateToken() : Jwt 토큰이 만료시간이 지났는지 등 토큰 유효성 검증한다.

    getClaims() : Jwt 토큰을 파싱 하여 토큰 바디에 있는 claim map을 가져오고 getMemberId(), getTokenType() 으로 특정 claim 키의 값을 가져온다.

     

    4. LoginFilter 정의

    로그인 요청이 왔을 때 실행되는 메서드들을 정의 한다. setFilterProcessesUrl("/v1/login") 로 로그인 URL을 변경할 수 있다.

    @Slf4j
    public class LoginFilter extends UsernamePasswordAuthenticationFilter {
        private final AuthenticationManager authenticationManager;
        private final JwtUtil jwtUtil;
    
        public LoginFilter(AuthenticationManager authenticationManager, JwtUtil jwtUtil) {
           this.authenticationManager = authenticationManager;
           this.jwtUtil = jwtUtil;
           setFilterProcessesUrl("/v1/login");
        }
    
        @Override
        public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
           throws AuthenticationException {
    
           log.info("attemptAuthentication() 실행");
           ObjectMapper om = new ObjectMapper();
           MemberLoginRequest memberLoginRequest;
           String email;
           String password;
    
           try {
              memberLoginRequest = om.readValue(request.getInputStream(), MemberLoginRequest.class);
              log.info("login parameter memberLoginRequest: {}", memberLoginRequest.toString());
    
              email = memberLoginRequest.getEmail();
              password = memberLoginRequest.getPassword();
           } catch (IOException e) {
              throw new RuntimeException(e);
           }
    
           UsernamePasswordAuthenticationToken authTokenDTO
              = new UsernamePasswordAuthenticationToken(email, password, null);
    
           return authenticationManager.authenticate(authTokenDTO);
        }
    
        @Override
        protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
           Authentication authentication) throws IOException, ServletException {
           log.info("로그인 성공");
           CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
           Member member = customUserDetails.getMember();
           log.info("member: {}", member.toString());
    
           String accessToken = jwtUtil.createAccessToken(member);
           log.info("생성 accessToken: Bearer {}", accessToken);
           String refreshToken = jwtUtil.createRefreshToken(member);
           log.info("생성 refreshToken: Bearer {}", refreshToken);
    
           response.addHeader("Authorization", "Bearer " + accessToken);
           response.addCookie(createCookie("refreshToken", refreshToken));
           response.setStatus(HttpStatus.OK.value());
        }
    
        @Override
        protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
           AuthenticationException failed) throws IOException, ServletException {
    
           log.info("로그인 실패");
           response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        }
    
        public Cookie createCookie(String key, String value) {
           Cookie cookie = new Cookie(key, value);
           cookie.setMaxAge(60 * 30); //refresh token하고 같은 생명주기 30분으로 세팅
           cookie.setHttpOnly(true); //자바스크립트로 쿠키 접근 못하게 막음
    
           return cookie;
        }
    }

     

    LoginFilter 클래스는 UsernamePasswordAuthenticaionFilter 클래스를 상속받는다. UsernamePasswordAuthenticaionFilter는 아래 그림처럼 스프링 시큐리티 인증 절차에서 가장 처음으로 실행되는 클래스이다. 

    [ attemptAuthentication() ]

    이 UsernamePasswordAuthenticaionFilter는 AuthenticationManager에 Authentication 객체를 전달해줘야 한다. 그 역할을 하는 메서드가 attemptAuthentication() 이다.  이 메서드에서 로그인할 때 받은 사용자 ID와 암호를 json형식으로 받은 데이터를 MemberLoginRequest 객체(단순히 아이디 패스워드가 있는 클래스)로 변환하고 이 객체의 데이터로 UsernamePasswordAuthenticationToken 객체를 생성 후 AuthenticationManager에 전달한다. UsernamePasswordAuthenticationToken은 단순히 AuthenticationManager에 전달하기 위한 DTO 역할을 한다.  attemptAuthentication()가 실행되고 나서 UserDetailsService 클래스의 loadUserByUsername() 함수가 실행된다.

     

    [ successfulAuthentication() ]

    successfulAuthentication() 함수는 로그인이 성공했을 때 실행되는 함수이다. access 토큰은 response 헤더에 담고 refresh 토큰은 쿠키에 담는다. 

    access 토큰은 localStorage에 담을 거 기 때문에 XSS 취약하고, refresh 토큰은 쿠키에 담아서 CSRF에 취약하다. 하지만 refresh 토큰으로 할 수 있는 기능은 access 토큰을 갱신하는 것이기 때문에 refresh 인증으로 할 수 있는 건 없다. 그래서 쿠키에 담았다.

    참고 : setHttpOnly(true)로 쿠키 데이터가 XSS 공격으로 탈취당하는 것을 막기 때문에 쿠키는 XSS에는 공격에 취약하지 않다.

     

    [ unsuccessfulAuthentication() ]

    unsuccessfulAuthentication() 함수는 로그인이 실패했을 때 실행되는 함수이다. 간단하게 403 상태 코드만 던진다.

     

     

    5. LoginFilter 정의

    JwtAuthenticationFilter 클래스는 OncePerRequestFilter 클래스를 상속받아 요청 시 한 번만 실행되게 할 메서드를 정의한다.

    @Slf4j
    @RequiredArgsConstructor
    @Component
    public class JwtAuthenticationFilter extends OncePerRequestFilter {
        private final JwtUtil jwtUtil;
        private final MemberRepository memberRepository;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {
            String token = jwtUtil.resolveToken((HttpServletRequest) request);
            log.info("doFilterInternal() 실행, token={}", token);
    
            if (token == null) {
                log.info("JWT 토큰정보가 없습니다.");
            } else if (!jwtUtil.validateToken(token)) {
                log.info("JWT 토큰이 만료되거나 잘못되었습니다.");
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            } else {
                authenticateUserWithToken(token);
            }
    
            filterChain.doFilter(request, response);
        }
    
        private void authenticateUserWithToken(String token) {
            Long memberId = jwtUtil.getMemberId(token);
            log.info("refresh 토큰 memberId: {}", memberId);
    
            Member member = memberRepository.findById(memberId)
                    .orElseThrow(() -> new IllegalArgumentException("not found by memberId: " + memberId));
    
            CustomUserDetails customUserDetails = new CustomUserDetails(member);
    
            UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                    customUserDetails, null, customUserDetails.getAuthorities());
    
            SecurityContextHolder.getContext().setAuthentication(authToken);
        }
    }

     

    [ doFilterInternal() ]

    OncePerRequestFilter 상속받아서 구현해야 하는 메서드이며 요청 시 한 번만 실행되는 함수이다. 

    jwtUtil.resolveToken()로 요청받을 때 request 헤더에 Jwt 토큰이 있는지 확인하고 가져온다.

    jwtUtil.validateToken()로 Jwt이 만료되었는지 등 유효성 검증을 한다.

    authenticateUserWithToken(token) 으로 SecurityContextHolder에 인증된 사용자를 담아서  filterChain.doFilter(request, response) 로 다음 필터로 전달한다.

     

    6. SecurityFilterChain 수정

    //UsernamePasswordAuthenticationFilter 대신에 LoginFilter가 실행된다.
    //LoginFilter 이전에 jwtAhthenticationFilter가 실행된다.
    http.addFilterBefore(jwtAuthenticationFilter, LoginFilter.class);
    http.addFilterAt(new LoginFilter(authenticationManager(), jwtUtil), UsernamePasswordAuthenticationFilter.class);
    

     

    addFilterBefore()는 특정 클래스 전에 실행되도록 하는 함수이다. 위 코드에서는 LoginFilter 클래스 이전에 jwtAuthenticationFilter 클래스가 실행되도록 하는 것이다.

    addFilterAt()는 특정 클래스 대신에 실행되도록 하는 함수이다. UsernamePasswordAuthenticationFilter 클래스 대신에 LoginFilter 클래스가 실행되도록 한다.

     

    여기까지 완료 하면 로그인시 access 토큰과 refresh 토큰을 받았고 access 토큰은 response 헤더에서 access 토큰을 추출해서 요청때마다 request 헤더에 담아서 요청해 인증해야 한다. 그리고 access 토큰이 만료되면 refresh 토큰으로 access 토큰을 갱신해야 한다. 갱신은 위 소스에 없고 다음 포스팅에서 진행할 것이다.

     

     

     

     

    참고 

    - https://www.youtube.com/watch?v=NPRh2v7PTZg&list=PLJkjrxxiBSFCcOjy0AAVGNtIa08VLk1EJ

    - https://velog.io/@dailylifecoding/spring-security-authentication-process-flow

    - https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication

    - https://jwt.io/introduction

    - https://docs.tosspayments.com/resources/glossary/basic-auth

    댓글

    Designed by JB FACTORY