-
프로젝트에 JWT 인증/인가 적용하기Project 2024. 8. 30. 15:42
팀 프로젝트에서 JWT 인증/인가를 적용한 것을 팀원들과 공유하기 위해 작성한다. Spring Security와 JWT에 대해 조금이라도 알고 있어야 이해하는 데 편할 수 있다.
구현 목표 (백엔드)
- JWT 이중 토큰 방식(Access / Refresh)을 사용하여 인증/인가를 구현한다.
- Access 토큰은 응답 헤더에, Refresh 토큰은 쿠키에 담아서 클라이언트에게 보낸다.
- Redis DB에 Refresh 토큰을 관리한다.
- Refresh 토큰 rotate 전략을 사용한다.
- 로그아웃 기능을 통해 JWT 탈취를 최대한 방어한다.
1. 환경설정
build.gradle - 관련 의존성 추가
// Spring Security implementation 'org.springframework.boot:spring-boot-starter-security' // JWT implementation 'io.jsonwebtoken:jjwt-api:0.12.3' implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
JwtConstants
- 코드 변경 시 변경 지점을 최소화시키고 오타 실수를 방지하기 위해 JWT 상수를 추가했다.
public abstract class JwtConstants { public static final String ACCESS_TOKEN_HEADER_NAME = "access"; public static final long ACCESS_TOKEN_EXPIRATION = 600000L; // 10분 public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh"; public static final long REFRESH_TOKEN_EXPIRATION = 86400000L; // 24시간 }
2. 로그인 필터 개발
CustomLoginFilter
- 로그인 성공 시 JWT를 발급해야 하므로 로그인 필터를 개발해야 한다.
- UsernamePasswordAuthenticationFilter는 username과 password가 맞는지 확인하는 필터이다. 즉, 이 필터를 커스텀해야 한다.
@Slf4j @RequiredArgsConstructor public class CustomLoginFilter extends UsernamePasswordAuthenticationFilter { private final JwtService jwtService; private final CookieService cookieService; private final RedisService redisService; private final AuthenticationManager authenticationManager; @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String username = obtainUsername(request); String password = obtainPassword(request); log.info("다음 사용자가 로그인 시도 : {}", username); UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null); return authenticationManager.authenticate(authToken); } // 로그인 성공 시 @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) { String username = authentication.getName(); String role = authentication.getAuthorities().iterator().next().getAuthority(); // 토큰 생성 String access = jwtService.createJwt(ACCESS_TOKEN_HEADER_NAME, username, Role.valueOf(role), ACCESS_TOKEN_EXPIRATION); String refresh = jwtService.createJwt(REFRESH_TOKEN_COOKIE_NAME, username, Role.valueOf(role), REFRESH_TOKEN_EXPIRATION); // Radis에 refresh 토큰 저장 redisService.saveRefreshToken(username, refresh, Duration.ofMillis(REFRESH_TOKEN_EXPIRATION)); /** * 응답 설정 * - Access Token -> Header * - Refresh Token -> Cookie * - Role -> Header */ response.setHeader(ACCESS_TOKEN_HEADER_NAME, "Bearer " + access); response.addCookie(cookieService.createRefreshCookie(REFRESH_TOKEN_COOKIE_NAME, refresh)); response.setHeader("role", role); response.setStatus(HttpStatus.OK.value()); log.info("다음 사용자가 로그인 성공 : {}", username); } @Override protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) { log.warn("인증 실패 = {}", failed.getMessage()); response.setStatus(HttpStatus.UNAUTHORIZED.value()); } }
JwtService
- JWT를 생성하고 JWT에 담긴 정보를 얻을 수 있는 클래스
- 참고로 isExpired() 메서드의 반환타입이 void인 이유는 토큰이 만료되었을 경우 예외가 발생되었는지 확인함으로써 만료 여부를 확인할 수 있기 때문이다.
@Slf4j @Service public class JwtService { private final SecretKey secretKey; public JwtService(@Value("${spring.jwt.secret}") String secretKey) { this.secretKey = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); } public String createJwt(String category, String username, Role role, Long expiredMs) { return Jwts.builder() .claim("category", category) .claim("username", username) .claim("role", role.toString()) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + expiredMs)) .signWith(secretKey) .compact(); } private Claims getClaims(String token) { try { return Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(token) .getPayload(); } catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) { log.info("Error getting claims : ", e); throw e; } } public String getUsername(String token) { return getClaims(token).get("username", String.class); } public String getRole(String token) { return getClaims(token).get("role", String.class); } public void isExpired(String token) { getClaims(token).getExpiration(); } public String getCategory(String token) { return getClaims(token).get("category", String.class); } }
CookieService
- Refresh 토큰을 쿠키에 담기 위한 메서드
- 쿠키를 초키화하는 메서드
- Request에서 Refresh 토큰을 찾는 메서드
@Service public class CookieService { public Cookie createRefreshCookie(String key, String value) { Cookie cookie = new Cookie(key, value); cookie.setMaxAge((int) Duration.ofDays(1).getSeconds()); cookie.setPath("/"); cookie.setHttpOnly(true); cookie.setAttribute("SameSite", "None"); // cookie.setSecure(true); // HTTPS 환경에서 사용 가능 return cookie; } public void clearCookie(String key, HttpServletResponse response) { Cookie cookie = new Cookie(key, null); cookie.setMaxAge(0); cookie.setPath("/"); // cookie.setSecure(true); response.addCookie(cookie); } public String getRefreshToken(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if (cookie.getName().equals(REFRESH_TOKEN_COOKIE_NAME)) { return cookie.getValue(); } } } return null; } }
RedisService
- Refresh 토큰을 Redis에 저장하고 찾거나 삭제하는 기능을 제공
@Service public class RedisService { private final RedisTemplate<String, String> redisTemplate; private final ValueOperations<String, String> valueOperations; private final JwtService jwtService; @Autowired public RedisService(RedisTemplate<String, String> redisTemplate, JwtService jwtService) { this.redisTemplate = redisTemplate; this.valueOperations = redisTemplate.opsForValue(); this.jwtService = jwtService; } public void saveRefreshToken(String username, String refreshToken, Duration duration) { valueOperations.set(username, refreshToken, duration); } public String getRefreshToken(String username) { return valueOperations.get(username); } public void deleteRefreshToken(String username) { redisTemplate.delete(username); } public void deleteByRefreshToken(String refreshToken) { String username = jwtService.getUsername(refreshToken); deleteRefreshToken(username); } }
여기까지 사용자가 로그인에 성공한 경우 JWT를 발급하고 Refresh 토큰을 DB에 관리하는 기능이다. 이제는 사용자의 요청이 들어오면 JWT가 존재하는지 확인하고 Access 토큰을 검증하는 필터를 개발해야 한다.
2. JWT 검증 필터 개발
JwtFilter
- 각 요청당 한 번만 필터를 거치면 되므로 OncePerRequestFilter를 커스텀한다.
- 로그인 요청이나 Access 토큰이 없는 경우(미인증 사용자)는 검증할 필요가 없기 때문에 다음 필터로 넘긴다.
- Access 토큰이 있는 경우 검증 과정을 거친다. 검증에 실패하면 다음 필터로 넘기지 않고 401 에러와 함께 return 한다.
- 검증에 성공하면 임시 사용자 정보를 생성하여 저장한다. 세션 무상태로 관리할 것이기 때문에 요청이 끝나면 해당 정보는 사라진다. 임시 정보를 저장해 놓아야 컨트롤러에서 인증된 사용자 정보를 쉽게 얻을 수 있다.
@Slf4j @RequiredArgsConstructor public class JwtFilter extends OncePerRequestFilter { private final JwtService jwtService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { log.info("JWT 필터 호출"); // 로그인 요청 -> 다음 필터로 if ("/login".equals(request.getServletPath()) && "POST".equalsIgnoreCase(request.getMethod())) { filterChain.doFilter(request, response); return; } // 엑세스 토큰이 없는 경우 (미인증 사용자) -> 다음 필터로 String accessToken = request.getHeader(ACCESS_TOKEN_HEADER_NAME); if (accessToken == null) { filterChain.doFilter(request, response); return; } accessToken = accessToken.split(" ")[1]; // 토큰 만료 여부 확인, 만료 시 다음 필터로 넘기지 않음 try { jwtService.isExpired(accessToken); } catch (ExpiredJwtException e) { String msg = "Access token is expired"; writeError(response, msg); return; } // 토큰이 Access 토큰인지 확인, 아니면 다음 필터로 넘기지 않음 String category = jwtService.getCategory(accessToken); if (!category.equals(ACCESS_TOKEN_HEADER_NAME)) { String msg = "Access token is invalid"; writeError(response, msg); return; } // username, role 값을 획득 User user = null; Role role = Role.valueOf(jwtService.getRole(accessToken)); if (role.equals(ROLE_STUDENT)) { user = new Student(); user.setUsername(jwtService.getUsername(accessToken)); user.setRole(ROLE_STUDENT); } if (role.equals(ROLE_MANAGER)) { user = new Manager(); user.setUsername(jwtService.getUsername(accessToken)); user.setRole(ROLE_MANAGER); } if (role.equals(ROLE_TEACHER)) { user = new Manager(); user.setUsername(jwtService.getUsername(accessToken)); user.setRole(ROLE_TEACHER); } // 임시 사용자 정보 저장 CustomUserDetails customUserDetails = new CustomUserDetails(user); Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authToken); log.info("JWT 검증 완료, SecurityContext에 인증 정보 저장"); filterChain.doFilter(request, response); } private void writeError(HttpServletResponse response, String msg) throws IOException { PrintWriter writer = response.getWriter(); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); writer.print(msg); writer.flush(); writer.close(); } }
이제 사용자 요청이 들어올 때마다 JWT 필터가 작동하여 Access 토큰을 검증하게 된다. 이제는 프론트엔드측에서 Access 토큰이 만료되어 토큰을 재발급하기 위해 요청이 들어올 경우 재발급을 처리해주는 기능을 개발해야 한다.
3. 토큰 재발급 컨트롤러 개발
ReissueController
- 요청을 서비스단을 거쳐 검증하고 결과에 따라 응답한다. 검증에 통과하면 토큰을 재발급한다.
@RestController @RequiredArgsConstructor public class ReissueController { private final ReissueService reissueService; // 토큰 재발급 @PostMapping("/reissue") public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) { String result = reissueService.checkRefreshToken(request); if (result == null) { return new ResponseEntity<>("Refresh token is null", HttpStatus.BAD_REQUEST); // 400 } if (result.equals("expired")) { return new ResponseEntity<>("Refresh token is expired", HttpStatus.BAD_REQUEST); } if (result.equals("invalid")) { return new ResponseEntity<>("Refresh token is invalid", HttpStatus.BAD_REQUEST); } String refreshToken = result.split(" ")[1]; reissueService.reissueRefreshToken(refreshToken, response); return new ResponseEntity<>(HttpStatus.OK); } }
ReissueService
- 요청을 검증하고 새로운 토큰을 발급해주는 서비스 클래스
- 새로운 토큰을 발급할 때 기존 Refresh 토큰을 삭제하고 새로운 Refresh 토큰을 저장한다. (rotate)
@Slf4j @Service @RequiredArgsConstructor public class ReissueService { private final JwtService jwtService; private final CookieService cookieService; private final RedisService redisService; public String checkRefreshToken(HttpServletRequest request) { String refreshToken = cookieService.getRefreshToken(request); if (refreshToken == null) { log.info("Refresh token is null"); return null; } try { jwtService.isExpired(refreshToken); } catch (ExpiredJwtException e) { log.info("Refresh token is expired"); return "expired"; } String category = jwtService.getCategory(refreshToken); if (!category.equals("refresh")) { log.info("Refresh token is invalid"); return "invalid"; } String username = jwtService.getUsername(refreshToken); String storedToken = redisService.getRefreshToken(username); if (!refreshToken.equals(storedToken)) { log.info("Refresh token is invalid"); return "invalid"; } return "ok " + refreshToken; } public void reissueRefreshToken(String refreshToken, HttpServletResponse response) { String username = jwtService.getUsername(refreshToken); String role = jwtService.getRole(refreshToken); // 새로운 토큰 발급 (rotate) String newAccess = jwtService.createJwt(ACCESS_TOKEN_HEADER_NAME, username, Role.valueOf(role), ACCESS_TOKEN_EXPIRATION); String newRefresh = jwtService.createJwt(REFRESH_TOKEN_COOKIE_NAME, username, Role.valueOf(role), REFRESH_TOKEN_EXPIRATION); // Redis에 기존 Refresh 토큰을 삭제하고 새로운 Refresh 토큰 저장 redisService.deleteByRefreshToken(refreshToken); redisService.saveRefreshToken(username, newRefresh, Duration.ofMillis(REFRESH_TOKEN_EXPIRATION)); response.setHeader(ACCESS_TOKEN_HEADER_NAME, "Bearer " + newAccess); response.addCookie(cookieService.createRefreshCookie(REFRESH_TOKEN_COOKIE_NAME, newRefresh)); } }
이제 사용자는 Access 토큰이 만료되어도 Refresh 토큰이 만료되지 않았다면 다시 로그인할 필요 없이 서비스를 계속 이용할 수 있다. 다음으로는 사용자가 로그아웃할 경우 적절히 처리해주어야 한다.
3. 로그아웃 필터 개발
CustomLogoutFilter
- 로그아웃 요청일 때만 적절하게 로그아웃 기능을 수행하는 필터
- Redis에 저장된 Refresh 토큰을 삭제하며 쿠키를 초기화한다. 물론 프론트엔드 측에서도 로컬 스토리지에 있는 Access 토큰을 삭제해주어야 한다.
@Slf4j @RequiredArgsConstructor public class CustomLogoutFilter extends GenericFilterBean { private final JwtService jwtService; private final RedisService redisService; private final CookieService cookieService; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; // 로그아웃 경로와 HTTP 메서드 검증 if (isLogoutRequest(httpRequest)) { handleLogout(httpRequest, httpResponse); } else { filterChain.doFilter(request, response); } } private boolean isLogoutRequest(HttpServletRequest request) { return request.getRequestURI().matches("^\\/logout$") && request.getMethod().equals("POST"); } private void handleLogout(HttpServletRequest request, HttpServletResponse response) { String refresh = cookieService.getRefreshToken(request); if (refresh == null || isTokenInvalid(refresh)) { log.info("Refresh token is invalid or expired"); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); return; } String username = jwtService.getUsername(refresh); if (isStoredTokenInvalid(username, refresh)) { log.info("Stored refresh token is invalid"); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); return; } // 로그아웃 진행 redisService.deleteRefreshToken(username); cookieService.clearCookie(REFRESH_TOKEN_COOKIE_NAME, response); response.setStatus(HttpServletResponse.SC_OK); log.info("다음 사용자가 로그아웃 성공 : {}", username); } private boolean isTokenInvalid(String refresh) { try { jwtService.isExpired(refresh); } catch (ExpiredJwtException e) { return true; } return !jwtService.getCategory(refresh).equals(REFRESH_TOKEN_COOKIE_NAME); } private boolean isStoredTokenInvalid(String username, String refresh) { String storedToken = redisService.getRefreshToken(username); return !refresh.equals(storedToken); } }
사용자가 로그아웃 시 프론트엔드와 백엔드측에서 적절히 처리해줌으로써 토큰 탈취 위협에 대해 최대한 방어할 수 있게 되었다. 이제 마지막으로 Spring Security 환경설정을 진행해주면 된다.
4. Security 환경설정
SecurityConfig
- 구현한 필터를 적절하게 배치한다.
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtService jwtService; private final CookieService cookieService; private final RedisService redisService; private final AuthenticationConfiguration authenticationConfiguration; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.formLogin(auth -> auth.disable()); http.httpBasic(auth -> auth.disable()); http.authorizeHttpRequests(auth -> auth .requestMatchers("/", "/login", "/register/**", "/reissue").permitAll() .requestMatchers("/manager/**").hasRole("MANAGER") .requestMatchers("/teacher/**").hasRole("TEACHER") .requestMatchers("/student/**").hasRole("STUDENT") .anyRequest().authenticated() ); // JWT 사용 위해 세션을 무상태로 설정 http.sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // 커스텀 필터 등록 (JwtFilter -> CustomLoginFilter -> CustomLogoutFilter -> LogoutFilter) http.addFilterBefore(new JwtFilter(jwtService), CustomLoginFilter.class); http.addFilterAt(new CustomLoginFilter(jwtService, cookieService, redisService, authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class); http.addFilterBefore(new CustomLogoutFilter(jwtService, redisService, cookieService), LogoutFilter.class); return http.build(); } @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } }
추가적으로 Access 토큰 블랙 리스트를 만들어 보안을 강화하는 방법이 있다. 사용자가 로그아웃하면 해당 Access 토큰을 블랙 리스트에 저장하여 관리하는 것이다. 하지만 이렇게 하면 사용자의 모든 요청에서 Access 토큰이 블랙 리스트에 포함되어 있는지 확인해야 한다. 전통적인 세션 방식과 비슷하게 되어 JWT만의 Stateless한 장점을 잃어 버리게 된다. 그래서 이번에 JWT를 구현할 때 블랙 리스트 방식은 사용하지 않았다.
Access 토큰의 경우 유효기간이 상당히 짧기 때문에 탈취당하더라도 상대적으로 큰 위협이 되지는 않지만 유효기간이 매우 긴 Refresh 토큰의 경우는 이야기가 달라진다. 따라서 Refresh 토큰을 보호하기 위해 더 많은 시간을 투자하는 것이 바람직하다. HttpOnly, Secure, Samesite 등을 통해 보호할 수 있다.
'Project' 카테고리의 다른 글
Netty TCP 서버 구축 실습 (1) 2024.10.09 [팀 프로젝트] LMS 웹 사이트 만들기 (Learn Hub) (1) 2024.08.28 구글 reCAPTCHA v2, v3 사용해보기 🤖 (0) 2024.08.15 온라인 자바 컴파일러 만들기 (Judge0 API) (0) 2024.07.19 [팀 프로젝트] 애니메이션 커뮤니티 웹 사이트 만들기 (Who's Ducking) (0) 2024.07.07