[스프링부트] Spring security, JWT token 적용

YTT.erica·2024년 6월 19일

현재 진행중인 커뮤니티 백엔드 프로젝트에 Spring security와 JWT token을 통해 인증 인가를 구현해보려고 한다.

JWT 토큰 로직

1. 사용자 로그인

로그인 요청 수신

사용자가 이메일과 비밀번호를 입력하여 로그인 요청 -> LogInFilter에서 처리하도록

인증 처리

LogInFilter는 입력된 이메일과 비밀번호를 UsernamePasswordAuthenticationToken 객체로 생성하고, AuthenticationManager를 사용하여 인증시도

인증 성공

인증 성공 시 successfulAuthentication 메서드 호출 -> JWTUtil을 사용하여 두 가지 토큰을 생성 (액세스 토큰, 리프레시 토큰)

2. API 요청

요청 수신

사용자가 API 요청을 보낼 때, JWTFilter가 요청을 가로챔
요청 헤더에서 액세스 토큰을 추출합니다.

토큰 검증

JWTUtil을 사용하여 액세스 토큰의 유효성 검증 -> 만료된 경우에는 SC_UNAUTHORIZED 상태 코드와 함께 "access token expired" 메시지 반환
토큰의 카테고리가 "access"인지 확인 -> 맞지 않으면 "invalid access token" 메시지 반환

사용자 인증 처리

유효한 액세스 토큰 -> 토큰에서 사용자 정보를 추출
추출한 정보로 스프링 시큐리티의 인증 객체 생성 -> 세션 등록 -> 다음 필터로 전달
모든 검증이 완료되면 요청을 다음 필터로 전달하여 실제 요청 처리

3. 토큰 재발급 (리프레시)

리프레시 요청 수신

사용자가 액세스 토큰 만료로 새로운 토큰 요청 -> 이때 쿠키에 저장된 리프레시 토큰 검증 -> 유효성 검증
유효성 검사 통과 시 -> 새로운 액세스 토큰, 리프레시 토큰 생성 -> 데이터베이스에 리프레시 토큰 저장

4. 로그아웃

- 로그아웃 요청 수신

사용자가 로그아웃 요청 -> 리프레시 토큰 검증 및 삭제


프로젝트 적용

SecurityConfig

Spring Security를 사용하여 웹 에플리케이션 보안 설정

  • authenticationManager(AuthenticationConfiguration configuration) AuthenticationConfiguration에서 AuthenticationManager 가져오기
  • securityFilterChain(HttpSecurity http, AuthenticationConfiguration authenticationConfiguration) HttpSecurity 객체를 통해 보안 설정을 정의
  • 코드
    @Configuration
    @Log4j2
    @EnableWebSecurity
    @RequiredArgsConstructor
    public class SecurityConfig {
    
      private final RefreshTokenRepository refreshTokenRepository;
    
      private final AuthenticationConfiguration authenticationConfiguration;
    
      private final JWTUtil jwtUtil;
    
      private final EmailService emailService;
    
      @Bean
      public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
      }
    
      @Bean
      public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return http
            .httpBasic(AbstractHttpConfigurer::disable)
            .csrf(AbstractHttpConfigurer::disable)
            .cors(withDefaults())
            .authorizeHttpRequests(authorize -> authorize
                //주소 바꾸기
                .requestMatchers("/api/users/logIn").permitAll()
                .requestMatchers("/api/users/logOut").permitAll()
                .requestMatchers("/api/reissue").permitAll()
                .requestMatchers("/api/posts/info").permitAll()
                .requestMatchers("/api/users/signup").permitAll()
                .requestMatchers("/error").permitAll()
                .requestMatchers("/api/users/email/**").permitAll()
                .requestMatchers("/api/users/signup/nickname/**").permitAll()
                .requestMatchers("/images/profile/**").permitAll()
                .requestMatchers("/images/post/**").permitAll()
                .requestMatchers("/api/posts/**").hasRole("USER")
                .requestMatchers("/api/users/user/**").hasRole("USER")
                .anyRequest().authenticated()
            )
            .addFilterBefore(new JWTFilter(jwtUtil, refreshTokenRepository),  LogInFilter.class)
            .addFilterAt(new LogInFilter("/api/users/logIn", authenticationManager(authenticationConfiguration), jwtUtil, refreshTokenRepository, emailService), UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshTokenRepository, "/api/users/logOut"), LogoutFilter.class)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .build();
      }
    
      @Bean
      public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
      }
    }
    

JWTFilter

요청이 들어올 때마다 호출, JWT를 검증하고 사용자 인증을 처리

  • 토큰 추출
  • 토큰 만료 체크
  • 토큰 유형 체크
  • 사용자 정보 추출
  • 사용자 인증 처리
  • 다음 필터로 전달
  • 코드
    @Log4j2
    public class JWTFilter extends OncePerRequestFilter {
      private final JWTUtil jwtUtil;
    
      public JWTFilter(JWTUtil jwtUtil, RefreshTokenRepository refreshTokenRepository) {
        this.jwtUtil = jwtUtil;
      }
    
      @Override
      protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //request에서 Authorization 헤더를 찾음
        // 헤더에서 access키에 담긴 토큰을 꺼냄
        String accessToken = request.getHeader("access");
    
        // 토큰이 없다면 다음 필터로 넘김
        if (accessToken == null) {
          filterChain.doFilter(request, response);
          return;
        }
    
        try {
          jwtUtil.isExpired(accessToken);
        } catch (ExpiredJwtException e) {
          //response body
          PrintWriter writer = response.getWriter();
          writer.print("access token expired");
    
          //response status code
          response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
          return;
        }
    
        // 토큰이 access인지 확인 (발급시 페이로드에 명시)
        String category = jwtUtil.getCategory(accessToken);
        if (!category.equals("access")) {
          //response body
          PrintWriter writer = response.getWriter();
          writer.print("invalid access token");
    
          //response status code
          response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
          return;
        }
    
        //토큰에서 username과 role 획득
        String username = jwtUtil.getEmail(accessToken);
        String role = jwtUtil.getRole(accessToken);
    
        //userEntity를 생성하여 값 set
        User user = User.builder()
            .email(username)
            .role(role)
            .build();
    
        //UserDetails에 회원 정보 객체 담기
        CustomUserDto customUserDetails = new CustomUserDto(user);
        //스프링 시큐리티 인증 토큰 생성
        Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
        //세션에 사용자 등록
        SecurityContextHolder.getContext().setAuthentication(authToken);
    
        filterChain.doFilter(request, response);
      }
    }

JWTUtil

JWT(JSON Web Token)를 생성 및 검증

  • 코드
    @Component
    public class JWTUtil {
      private SecretKey secretKey;
    
      public JWTUtil(@Value("${spring.jwt.secret}") String secret) {
        this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
      }
    
      public String getEmail(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("email", String.class);
      }
    
      public String getRole(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
      }
    
      public Boolean isExpired(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
      }
    
      public String getCategory(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
      }
    
      public Boolean validateToken(String token) {
        try {
          Jwts.parser().verifyWith(secretKey).build().parse(token);
          return true;
        } catch (ExpiredJwtException | SignatureException e) {
          return false;
        }
      }
    
      //토큰 생성
      public String createJwt(String category,String email, String role, Long expiredMs) {
        return Jwts.builder()
            .claim("category", category)
            .claim("email", email)
            .claim("role", role)
            .issuedAt(new Date(System.currentTimeMillis()))
            .expiration(new Date(System.currentTimeMillis() + expiredMs))
            .signWith(secretKey)
            .compact();
      }
    }

LogInFilter

Spring Security의 UsernamePasswordAuthenticationFilter를 확장하여 사용자 로그인 요청 처리

사용자가 로그인을 시도하면 이 필터가 실행 → 사용자 인증 처리하고 JWT를 생성 → 응답 헤더.

  • 코드
    @Log4j2
    public class LogInFilter extends UsernamePasswordAuthenticationFilter {
      private final AuthenticationManager authenticationManager;
    
      private final JWTUtil jwtUtil;
    
      private final ObjectMapper objectMapper = new ObjectMapper();
    
      private final RefreshTokenRepository refreshTokenRepository;
    
      private final EmailService emailService;
    
      public LogInFilter(String defaultFilterUrl, AuthenticationManager authenticationManager, JWTUtil jwtUtil, RefreshTokenRepository refreshTokenRepository, EmailService emailService) {
    
        setFilterProcessesUrl(defaultFilterUrl);
        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
        this.refreshTokenRepository = refreshTokenRepository;
        this.emailService = emailService;
      }
    
      @Override
      public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) throws AuthenticationException {
        log.info("hi");
        String email = null;
        String password = null;
    
        try (BufferedReader reader = req.getReader()) {
          Map<String, String> requestBody = objectMapper.readValue(reader, new TypeReference<Map<String, String>>() {});
          email = requestBody.get("email");
          password = requestBody.get("password");
        } catch (IOException e) {
          throw new RuntimeException(e);
        }
    
        log.info("email: " + email);
    
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password, null);
        return authenticationManager.authenticate(authToken);
      }
    
      @Override
      protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, FilterChain chain, Authentication authentication) {
        //유저 정보
        String email = authentication.getName();
    //    emailService.sendEmailNotice(email);
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        String role = auth.getAuthority();
    
        //토큰 생성
        String access = jwtUtil.createJwt("access", email, role, 6000L);
        String refresh = jwtUtil.createJwt("refresh", email, role, 86400000L);
    
        //Refresh 토큰 저장
        addRefreshEntity(refresh, 86400000L);
    
        res.setHeader("access", access);
        res.setHeader("Set-Cookie","refresh=" + refresh +";Path=/; Domain=localhost; HttpOnly; Max-Age=604800; SameSite=None; Secure;");
    
        res.setStatus(HttpStatus.OK.value());
      }
    
      @Override
      protected void unsuccessfulAuthentication(HttpServletRequest req, HttpServletResponse res, AuthenticationException failed) {
        log.info("Authentication failed");
        res.setStatus(401);
      }
    
      private void addRefreshEntity(String refresh, Long expiredMs) {
        Date date = new Date(System.currentTimeMillis() + expiredMs);
        RefreshToken refreshToken = RefreshToken.builder()
            .token(refresh)
            .expirationDate(date.toString())
            .build();
    
        refreshTokenRepository.save(refreshToken);
      }
    }

CustomLogoutFilter

사용자 로그아웃을 처리하는 커스텀 필터

JWT 기반 인증 시스템에서 사용자의 Refresh 토큰을 검증하고 제거 → 로그아웃 수행

  • 코드
    public class CustomLogoutFilter extends GenericFilterBean {
    
      private final JWTUtil jwtUtil;
    
      private final RefreshTokenRepository refreshTokenRepository;
    
      private final String defaultFilterUrl;
    
      public CustomLogoutFilter(JWTUtil jwtUtil, RefreshTokenRepository refreshTokenRepository, String defaultFilterUrl) {
        this.jwtUtil = jwtUtil;
        this.refreshTokenRepository = refreshTokenRepository;
        this.defaultFilterUrl = defaultFilterUrl;
      }
    
      @Override
      public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
      }
    
      private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        //path and method verify
        if (!defaultFilterUrl.equals(request.getRequestURI())) {
          filterChain.doFilter(request, response);
          return;
        }
    
        String requestMethod = request.getMethod();
        if (!requestMethod.equals("POST")) {
          filterChain.doFilter(request, response);
          return;
        }
    
        //get refresh token
        String refresh = null;
    
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
          if (cookie.getName().equals("refresh")) {
            refresh = cookie.getValue();
          }
        }
    
        //refresh null check
        if (refresh == null) {
          response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
          return;
        }
    
        //expired check
        try {
          jwtUtil.isExpired(refresh);
        } catch (ExpiredJwtException e) {
          //response status code
          response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
          return;
        }
    
        // 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
        String category = jwtUtil.getCategory(refresh);
        if (!category.equals("refresh")) {
          //response status code
          response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
          return;
        }
    
        //DB에 저장되어 있는지 확인
        Boolean isExist = refreshTokenRepository.existsByToken(refresh);
        if (!isExist) {
          //response status code
          response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
          return;
        }
    
        //로그아웃 진행
        //Refresh 토큰 DB에서 제거
        refreshTokenRepository.deleteByToken(refresh);
    
        Cookie cookie = new Cookie("refresh", null);
        cookie.setMaxAge(0);
        cookie.setPath("/");
    
        response.addCookie(cookie);
        response.setStatus(HttpServletResponse.SC_OK);
      }
    }

ReissueController

리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급 기능

  • 코드
    @RestController
    @RequiredArgsConstructor
    public class ReissueController {
    
      private final ReissueService reissueService;
    
      @PostMapping("/api/reissue")
      public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
        return reissueService.reissue(request, response);
      }
    }

ReissueService

리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급

  • 코드
    @Service
    @RequiredArgsConstructor
    public class ReissueService {
    
      private final JWTUtil jwtUtil;
      private final RefreshTokenRepository refreshTokenRepository;
    
      public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
        // get refresh token
        String refresh = getRefreshTokenFromCookies(request);
    
        if (refresh == null) {
          return new ResponseEntity<>("refresh token null", HttpStatus.BAD_REQUEST);
        }
    
        // expired check
        try {
          jwtUtil.isExpired(refresh);
        } catch (ExpiredJwtException e) {
          return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
        }
    
        // 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
        String category = jwtUtil.getCategory(refresh);
        if (!category.equals("refresh")) {
          return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
        }
    
        // DB에 저장되어 있는지 확인
        Boolean isExist = refreshTokenRepository.existsByToken(refresh);
        if (!isExist) {
          return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
        }
    
        String email = jwtUtil.getEmail(refresh);
        String role = jwtUtil.getRole(refresh);
    
        // make new JWT
        String newAccess = jwtUtil.createJwt("access", email, role, 600000L);
        String newRefresh = jwtUtil.createJwt("refresh", email, role, 86400000L);
    
        // Refresh 토큰 저장 DB에 기존의 Refresh 토큰 삭제 후 새 Refresh 토큰 저장
        refreshTokenRepository.deleteByToken(refresh);
        addRefreshEntity(newRefresh, 86400000L);
    
        // response
        response.setHeader("access", newAccess);
        response.setHeader("Set-Cookie", "refresh=" + newRefresh + ";Path=/; Domain=localhost; HttpOnly; Max-Age=604800; SameSite=None; Secure;");
    
        return new ResponseEntity<>(HttpStatus.OK);
      }
    
      private void addRefreshEntity(String refresh, Long expiredMs) {
        Date date = new Date(System.currentTimeMillis() + expiredMs);
        RefreshToken refreshToken = RefreshToken.builder()
            .token(refresh)
            .expirationDate(date.toString())
            .build();
    
        refreshTokenRepository.save(refreshToken);
      }
    
      private String getRefreshTokenFromCookies(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
          for (Cookie cookie : cookies) {
            if (cookie.getName().equals("refresh")) {
              return cookie.getValue();
            }
          }
        }
        return null;
      }
    }

RefreshTokenRepository

RefreshToken 엔티티와 관련된 데이터베이스 작업을 수행하는 메서드를 제공

  • 코드
    @Repository
    public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
      // token을 사용하여 RefreshToken을 찾는 메서드
      @Lock(LockModeType.PESSIMISTIC_WRITE)
      Optional<RefreshToken> findByToken(String token);
    
      Boolean existsByToken(String token);
    
      @Transactional
      void deleteByToken(String token);
    }
    
profile
'◡'✿ 꿈을 찾아가보자고~ '◡'✿

0개의 댓글