ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring] Spring Security JWT 구현 (2)
    Code 2024. 12. 12. 00:39

    ↓ 이 글에서 이어집니다.

     

    [Spring] Spring Security JWT 구현 (1)

    프로젝트 진행 중 필요했던 로그인 기능 구현을 위해 학습한 내용, 구현한 내용을 정리한 글입니다. 🥹 프로젝트 진행에 필요한 정도만 구현하였기 때문에 OAuth 소셜 로그인이나 요런 녀석들은

    henhen.tistory.com

        이전 글에서 구현 방식 개요와 토큰 저장소 관련 세팅, 토큰 관련 서비스를 생성했다. 이어서 회원 서비스에서 편리하게 사용하기 위하여 UserDetails, UserDetailsService를 구현하고, 세팅한 토큰과 서비스에 대해 커스텀 필터를 구현해 준 다음 SecurityConfig를 구성해보려고 한다. 분량이 된다면 회원 기능에서 인증과 연관이 있을 로직 처리까지 작성해 보겠습니다...

    구현

    5. UserDetails

    @RequiredArgsConstructor
    public class SecurityUserDetails implements UserDetails {
        private final AdminEntity admin;
    
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return Collections.singleton(new SimpleGrantedAuthority("ROLE_" + admin.getRole().toString()));
        }
    
        @Override
        public String getPassword() {
            return admin.getPassword();
        }
    
        @Override
        public String getUsername() {
            return admin.getUsername();
        }
    
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        @Override
        public boolean isAccountNonLocked() {
            return admin.getStatus();
        }
    
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        @Override
        public boolean isEnabled() {
            return admin.getStatus();
        }
    
        public AdminEntity getAdmin() {
            return admin;
        }
    }
    • UserDetails를 사용할 때, 서비스에서 로그인 한 유저에 대해 접근하려고 하는 경우 기본으로 존재하는 메서드 뿐만 아니라 엔티티의 다른 필드에도 접근할 일이 종종 있어서, 엔티티 자체를 반환하는 메서드를 추가했다.
    • getAuthorities()와 isEnabled()에서 현재 엔티티에 맞게 넣어줬다.

    UserDetailsService

    @Service
    @RequiredArgsConstructor
    public class SecurityUserDetailsService implements UserDetailsService {
        private final AdminJpaRepository adminJpaRepository;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            AdminEntity admin = adminJpaRepository.findByUsername(username)
                    .orElseThrow(() -> new CustomException(JwtMessageType.USER_NOT_FOUND));
    
            return new SecurityUserDetails(admin);
        }
    }
    • JwtAuthenticationFilter에서 UserDetails를 세팅해주기 위해 구현했다. 변수로 받은 아이디에 해당하는 유저가 존재하지 않는 경우, 커스텀 예외 처리를 해주었다.

    6. Filter

    JwtAuthenticationFilter

    @Component
    @RequiredArgsConstructor
    public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
        private final JwtUtil jwtUtil;
        private final SecurityUserDetailsService userDetailsService;
    
        @Value("${jwt.access-token-expiration}")
        private long accessTokenExpiration;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            String accessToken = getToken(request, "accessToken");
            String username = null;
    
            if (accessToken == null) {
                String refreshToken = getToken(request, "refreshToken");
                if (refreshToken == null) {
    
                    //swagger, auth 예외처리
                    String path = request.getRequestURI();
                    if (path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs") || path.equals("/api/v1/admins/auth")) {
                        filterChain.doFilter(request, response);
                        return;
                    }
    
                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    response.getWriter().write("리프레시 토큰이 존재하지 않습니다.");
                    return;
                }
                if (!jwtUtil.validateRefreshToken(refreshToken)) {
                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    response.getWriter().write("리프레시 토큰이 유효하지 않습니다.");
                }
                // 액세스 토큰이 만료되어 재발급 받는 상태는 로그인 된 상태
                username = jwtUtil.getUsername(refreshToken);
                String userId = jwtUtil.getUserId(refreshToken);
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
    
                accessToken = jwtUtil.createAccessToken(username, userId, userDetails.getAuthorities().toString());
                ResponseCookie newAccessTokenCookie = ResponseCookie.from("accessToken", accessToken)
                        .httpOnly(true)
                        .secure(true)
                        .path("/")
                        .maxAge(accessTokenExpiration)
                        .build();
                response.addHeader(HttpHeaders.SET_COOKIE, newAccessTokenCookie.toString());
            } else {
                if (!jwtUtil.validateAccessToken(accessToken)) {
                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    response.getWriter().write("액세스 토큰이 유효하지 않습니다.");
                    return;
                }
                username = jwtUtil.getUsername(accessToken);
            }
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            if (!userDetails.isEnabled()) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            }
    
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    
            SecurityContextHolder.getContext().setAuthentication(authentication);
    
            filterChain.doFilter(request, response);
    
        }
    
        @Override
        protected boolean shouldNotFilter(HttpServletRequest request) {
            String path = request.getRequestURI();
            return path.equals("/api/v1/admins/login");
        }
    
        private String getToken(HttpServletRequest request, String token) {
            if (request.getCookies() != null) {
                for (Cookie cookie : request.getCookies()) {
                    if (token.equals(cookie.getName())) {
                        return cookie.getValue();
                    }
                }
            }
            return null;
        }
    }
    • jwt 인증에 성공하면 SecurityContextHolder에 인증된 Authentication 객체를 set 해주고 필터를 넘기는 역할.
    • 나의 경우에는 토큰을 클라이언트의 쿠키에 저장하기 때문에 따로 Authorization 헤더에 Bearer 방식으로 토큰을 보낸다거나 하지는 않는 대신, XSS 공격에 대한 방어를 위해 HTTP-Only 및 Secure 속성을 설정했다. 
    • 첫 로그인 시에는 토큰이 존재하지 않으므로 해당 로그인 api를 제출할 때에는 해당 필터를 실행하지 않았다.
    • 브라우저 새로고침 시 로그인 유저 정보를 vuex에 업데이트 해주기 위해 인증을 확인하는 api를 app.vue 파일에 추가했는데, 해당 api의 경우 access token과 refresh token 둘 다 없는 경우 비로그인 상태(초기 화면)으로 판단하여 해당 필터를 넘겨주었다.
      * 테스트를 위해 같은 경우에서 swagger uri도 넘겨줬다..
    • refresh token을 확인하여 access token을 재발급 받는 로직도 이 필터에서 수행하고, 쿠키에 새 jwt를 세팅한다.
    • 마지막으로 jwt 인증한 사용자가 유효한 사용자인지 검증한 후, authentication 객체를 set 한다.

    CsrfTokenFilter

    @Component
    @RequiredArgsConstructor
    public class CsrfTokenFilter extends OncePerRequestFilter {
        private final CsrfTokenUtil csrfTokenUtil;
        private final RedisTokenService redisTokenService;
    
        @Value("${server.servlet.session.timeout}")
        private long sessionTTL;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {
    
            if (request.getRequestURI().equals("/api/v1/admins/login")) {
                filterChain.doFilter(request, response);
                return;
            }
    
            if (request.getMethod().equalsIgnoreCase("POST") ||
                    request.getMethod().equalsIgnoreCase("PUT") ||
                    request.getMethod().equalsIgnoreCase("DELETE")) {
    
                String csrfToken = request.getHeader("X-CSRF-Token");
    
                Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
                String username = null;
    
                if (authentication != null && authentication.isAuthenticated() && !(authentication instanceof AnonymousAuthenticationToken)) {
                    username = authentication.getName();
                }
                
                try {
                    if (!csrfTokenUtil.validateToken(csrfToken, username, redisTokenService)) {
                        String newCsrfToken = csrfTokenUtil.generateToken();
                        response.setHeader("X-CSRF-Token", newCsrfToken);
                        log.info("CSRF 토큰이 갱신되었습니다: {}", newCsrfToken);
                        redisTokenService.saveCsrfToken(username, newCsrfToken, sessionTTL);
                    }
                } catch (Exception e) {
                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    return;
                }
            }
    
            filterChain.doFilter(request, response);
        }
    }

     

    • HTTP-Only 옵션으로 XSS 공격을 방어하고, CSRF 공격 방지를 위해 CSRF 토큰을 도입했다. redis에서 토큰을 관리할 예정이라 기본 시큐리티 설정을 사용하지 않고 util과 필터를 따로 구현해줬다.
    • 클라이언트 측 코드 간편화 등 편의성을 위해 토큰을 쿠키에서 관리하는데, 매번 csrf 토큰을 redis에 접근해서 읽어오면 뭔가 좀 아깝다고 느껴져서(?) POST, PUT, DELETE api에서만 토큰을 읽도록 했다. 마찬가지로 로그인 api인 경우 필터를 넘겨줬다(왜 이건 ShouldNotFilter 메서드로 처리 안했지?)
    • 여차저차 구현하고 나서 문제가 발견됐는데, 재발급이 정말 신경써야할 게 많았던 것 같다. 사용성때문에 재발급되는 경우에는 추가 검증을 거치지 않고 필터를 통과하도록 했는데, 재발급하는 조건을 redis나 헤더에 하나라도 없으면 발급하게 구현해서 csrf 토큰이 헤더에 존재하지 않는 경우에 따로 인증 절차도 없고, security 오류를 반환하지 않고 갱신 후 필터를 통과시키는 거였다. 사실상 동작을 안했다........
    • 결과적으로는 csrf 토큰을 없앴다. 보안을 위한 보안이라는 생각이 계속해서 들었고, rest api로 구현한다는 점, secure 설정을 한다는 점 등을 이유로...

    7. SecurityConfig.java

    여기부터는 차차 구현중이다... 아직 개발을 덜 해서 CORS 설정이나 Origins 설정을 덜 했다 ^_^..

    우선 필터 순서만 맞춰준 정도..ㅎㅎ

                    .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                    .addFilterAfter(csrfTokenFilter, JwtAuthenticationFilter.class);