지금까지 만든 토큰을 보면,
1. 로그인 성공 -> 서버측에서 jwt token 발급 -> 클라이언트에게 jwt token header에 담아 발급
2. 권한이 필요한 모든 요청 : 클라이언트 header에 jwt토큰 담아서 서버에 전송을 한다
- Access 토큰 : 권한이 필요한 모든 요청 헤더에 사용될 JWT로 탈취 위험을 낮추기 위해 약 10분 정도의 짧은 생명주기를 가진다.
- Refresh 토큰 : Access 토큰이 만료되었을 때 재발급 받기 위한 용도로만 사용되며 약 24시간 이상의 긴 생명주기를 가진다.
Acess token이 만료되었다는 요청을 클라이언트가 받았을 때, 프론트에서는 Refresh token을 가지고
✅서버의 Refresh 토큰을 받는 특정 경로로 요청을 보내 Acess token을 재발급 받는다
- ✅
로컬 스토리지:XSS공격에 취약함 : Access 토큰 저장- ✅
httpOnly 쿠키:CSRF공격에 취약함 : Refresh 토큰 저장
Access 토큰
- Access 토큰은 주로 로컬 스토리지에 저장됩니다.
- 짧은 생명 주기로 탈취에서 사용까지 기간이 매우 짧고, 에디터 및 업로더에서 XSS를 방어하는 로직을 작성하여 최대한 보호 할 수 있지만 CSRF 공격의 경우 클릭 한 번으로 단시간에 요청이 진행되기 때문입니다.
- 권한이 필요한 모든 경로에 사용되기 때문에 CSRF 공격의 위험보다는 XSS 공격을 받는 게 더 나은 선택일 수 있습니다.
- -> 그렇기에 Acess 토큰은, 로컬 스토리지에 저장
Refresh 토큰
- Refresh 토큰은 주로 쿠키에 저장됩니다.
- 쿠키는 XSS 공격을 받을 수 있지만 httpOnly를 설정하면 완벽히 방어할 수 있습니다.
- 그럼 가장 중요한 CSRF 공격에 대해 위험하지 않을까라는 의구심이 생깁니다.
- 하지만 Refresh 토큰의 사용처는 단 하나인 토큰 재발급 경로입니다.
- CSRF는 Access 토큰이 접근하는 회원 정보 수정, 게시글 CRUD에 취약하지만 토큰 재발급 경로에서는 크게 피해를 입힐 만한 로직이 없기 때문입니다.
- -> 그렇기에 Refresh 토큰은, httpOnly Cookie에 저장
acess, refresh 토큰을 생성해주면 된다!Authorization이름으로 전달하고createJwt를 만들었었고Authentication에서 가져온다!@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
// 유저 정보
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority authority = iterator.next();
String role = authority.getAuthority();
// jwt 생성
String access = jwtUtil.createJwt("access", username, role, 60 * 10 * 1000L);
String refresh = jwtUtil.createJwt("refresh", username, role, 60 * 60 * 24 * 1000L);
//Refresh 토큰 mysql에 저장
addRefreshEntity(username, refresh, 86400000L);
response.addHeader("Authorization","Bearer " + access);
response.addCookie(createCookie("refresh", refresh));
response.setStatus(HttpStatus.OK.value());
}
private Cookie createCookie(String key, String value) {
Cookie cookie = new Cookie(key, value);
cookie.setMaxAge(24*60*60);
//cookie.setSecure(true);
//cookie.setPath("/");
cookie.setHttpOnly(true); // 자바스크립트로 쿠키 접근 할 수 없도록 하기
return cookie;
}
public String createJwt(String category, String username, String role, Long expiredMs) {
return Jwts.builder()
.claim("category", category)
.claim("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
public String getCategory(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
}
claim에 정보를 저장해주고


이제 클라이언트가 발급받은 Acess token을 가지고, 서버로 요청을 받는다
서버는 요청받은 Acess token에 대해서 유효한지 검사를 하고, 권한을 SecurityContextHolder의 Context에 저장한다
우리는 Jwt를 검증하는 로직을 JwtFilter에서 작성하였다
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final static String HEADER_AUTHORIZATION = "Authorization";
private final static String TOKEN_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// request에서 Authorization 헤더를 찾기
String authorization = request.getHeader(HEADER_AUTHORIZATION);
// Authorization 헤더 검증 -> jwt token인지
if (authorization == null || !authorization.startsWith("Bearer ")){
log.info("token null");
filterChain.doFilter(request,response);
// 다음 조건이 해당하면 -> 메서드 종료
return;
}
// 가져온 값에서 접두사 제거 -> 토큰 꺼내 오기
String token = getAccessToken(authorization);
if (jwtUtil.validToken(token)){
// 토큰이 access인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(token);
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 email = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
// userEntity를 생성해서 해당 값을 넣어준다
UserEntity userEntity = UserEntity.builder()
.email(email)
.role(role)
.password("temppassword")
.build();
//UserDetails에 회원 정보 객체 담기
CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);
//스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
//세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
}
filterChain.doFilter(request, response);
}
private String getAccessToken(String authorization){
if (authorization != null && authorization.startsWith(TOKEN_PREFIX)){
return authorization.substring(TOKEN_PREFIX.length());
}
return null;
}
}
JwtUtil에 validToken으로 구현을 해두었다getCategory 메서드를 이용해, acess가 아니면 401 에러를 내버린다UsernamePasswordAuthenticationToken을 생성하고doFilter을 통해 다음 filter로 보낸다@RestController
@RequiredArgsConstructor
public class ReissueController {
private final JwtUtil jwtUtil;
@PostMapping("/reissue")
public Api<?> reissue(HttpServletRequest request, HttpServletResponse response){
String refresh = refreshTokenService.findRefreshCookie(request);
if (refresh == null) {
return Api.Error(TokenErrorCode.NULL_REFRESH_TOKEN);
}
//expired check
try {
jwtUtil.isExpired(refresh);
} catch (TokenException e) {
//response status code
return Api.Error(TokenErrorCode.EXPIRED_REFRESH_TOKEN);
}
// 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
return Api.Error(TokenErrorCode.REFRESH_TOKEN_EXCEPTION);
}
String username = jwtUtil.getUsername(refresh);
String role = jwtUtil.getRole(refresh);
//make new JWT
String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
//response
response.addHeader("Authorization","Bearer " + newAccess);
return Api.OK(null);
}
}
.requestMatchers("/reissue").permitAll()
저번에 발급했던 🤔refresh token은 삭제되는게 아님!!!
- 전에 발급된 refresh token에 대한 blacklist 로직을 추가해야 한다
@RestController
@RequiredArgsConstructor
public class ReissueController {
private final JwtUtil jwtUtil;
@PostMapping("/reissue")
public Api<?> reissue(HttpServletRequest request, HttpServletResponse response){
String refresh = refreshTokenService.findRefreshCookie(request);
if (refresh == null) {
return Api.Error(TokenErrorCode.NULL_REFRESH_TOKEN);
}
//expired check
try {
jwtUtil.isExpired(refresh);
} catch (TokenException e) {
//response status code
return Api.Error(TokenErrorCode.EXPIRED_REFRESH_TOKEN);
}
// 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
return Api.Error(TokenErrorCode.REFRESH_TOKEN_EXCEPTION);
}
String username = jwtUtil.getUsername(refresh);
String role = jwtUtil.getRole(refresh);
//make new JWT
String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
//response
response.addHeader("Authorization","Bearer " + newAccess);
response.addCookie(createCookie("refresh", newRefresh));
return Api.OK(null);
}
private Cookie createCookie(String key, String value) {
Cookie cookie = new Cookie(key, value);
cookie.setMaxAge(24*60*60);
//cookie.setSecure(true);
//cookie.setPath("/");
cookie.setHttpOnly(true);
return cookie;
}
ReissueController에, refresh token을 이용해서 acess token을 만들 때, refresh token도 새로 만들어준다그렇기 때문에!!! ✅서버측에서 token에 대한 주도권을 가져야 한다
일반적으로 서버측에 주도권을 잡는 방법에는
1.redis를 이용해 캐시에 저장
2.rdb에 refresh token을 저장
이 있다
✅구현 방법
- 발급시
- Refresh 토큰을 서버측 저장소에 저장
- 갱신시 (Refresh Rotate)
- 기존 Refresh 토큰을 삭제하고 새로 발급한 Refresh 토큰을 저장
@Entity
@Getter
@Setter
public class RefreshEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String refresh;
private String expiration;
}
public interface RefreshRepository extends JpaRepository<RefreshEntity, Long> {
Boolean existsByRefresh(String refresh);
@Transactional
void deleteByRefresh(String refresh);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
// 유저 정보
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority authority = iterator.next();
String role = authority.getAuthority();
// jwt 생성
String access = jwtUtil.createJwt("access", username, role, 60 * 10 * 1000L);
String refresh = jwtUtil.createJwt("refresh", username, role, 60 * 60 * 24 * 1000L);
//Refresh 토큰 mysql에 저장
addRefreshEntity(username, refresh, 86400000L);
response.addHeader("Authorization","Bearer " + access);
response.addCookie(createCookie("refresh", refresh));
response.setStatus(HttpStatus.OK.value());
}
private void addRefreshEntity(String email, String refresh, Long expiredMs) {
Date date = new Date(System.currentTimeMillis() + expiredMs);
RefreshEntity refreshEntity = RefreshEntity.builder()
.email(email)
.refresh(refresh)
.expiration(date.toString())
.build();
refreshRepository.save(refreshEntity);
}
@RestController
@RequiredArgsConstructor
public class ReissueController {
private final JwtUtil jwtUtil;
private final RefreshTokenService refreshTokenService;
private final RefreshRepository refreshRepository;
@PostMapping("/reissue")
public Api<?> reissue(HttpServletRequest request, HttpServletResponse response){
String refresh = refreshTokenService.findRefreshCookie(request);
if (refresh == null) {
return Api.Error(TokenErrorCode.NULL_REFRESH_TOKEN);
}
//expired check
try {
jwtUtil.isExpired(refresh);
} catch (TokenException e) {
//response status code
return Api.Error(TokenErrorCode.EXPIRED_REFRESH_TOKEN);
}
// 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
return Api.Error(TokenErrorCode.REFRESH_TOKEN_EXCEPTION);
}
// DB에 저장되어 있는지 확인
Boolean isExist = refreshRepository.existsByRefresh(refresh);
if (!isExist) {
//response body
return Api.Error(TokenErrorCode.INVALID_REFRESH_TOKEN);
}
String username = jwtUtil.getUsername(refresh);
String role = jwtUtil.getRole(refresh);
//make new JWT
String newAccess = jwtUtil.createJwt("access", username, role, 600000L);
String newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L);
//Refresh 토큰 저장 DB에 기존의 Refresh 토큰 삭제 후 새 Refresh 토큰 저장
refreshRepository.deleteByRefresh(refresh);
addRefreshEntity(username, newRefresh, 86400000L);
//response
response.addHeader("Authorization","Bearer " + newAccess);
response.addCookie(createCookie("refresh", newRefresh));
return Api.OK(null);
}
private void addRefreshEntity(String email, String refresh, Long expiredMs) {
Date date = new Date(System.currentTimeMillis() + expiredMs);
RefreshEntity refreshEntity = RefreshEntity.builder()
.email(email)
.refresh(refresh)
.expiration(date.toString())
.build();
refreshRepository.save(refreshEntity);
}
private Cookie createCookie(String key, String value) {
Cookie cookie = new Cookie(key, value);
cookie.setMaxAge(24*60*60);
//cookie.setSecure(true);
//cookie.setPath("/");
cookie.setHttpOnly(true);
return cookie;
}
}
이번 프로젝트에서는 간단한 경우이기 때문에 repository를 의존성을 주입받았다
프로젝트가 복잡해지고, 요구 사항이 많아지면 -> 🤔 Service계층을 구현해서@Transactional을 통해원자성을 보장해주자!
- 로그아웃 버튼 클릭시
프론트엔드측: 로컬 스토리지에 존재하는 Access 토큰 삭제 및 서버측 로그아웃 경로로 Refresh 토큰 전송백엔드측: 로그아웃 로직을 추가하여 Refresh 토큰을 받아 쿠키 초기화 후 Refresh DB에서 해당 Refresh 토큰 삭제 (모든 계정에서 로그아웃 구현시 username 기반으로 모든 Refresh 토큰 삭제)
✅백엔드에서 로그아웃 수행 작업
1. DB에 저장하고 있는 Refresh 토큰 삭제
2. Refresh 토큰 쿠키 null로 변경
@RequiredArgsConstructor
public class CustomLogoutFilter extends GenericFilterBean {
private final JwtUtil jwtUtil;
private final RefreshRepository refreshRepository;
@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
String requestUri = request.getRequestURI();
if (!requestUri.matches("^\\/logout$")) {
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 = refreshRepository.existsByRefresh(refresh);
if (!isExist) {
//response status code
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
//로그아웃 진행
//Refresh 토큰 DB에서 제거
refreshRepository.deleteByRefresh(refresh);
//Refresh 토큰 Cookie 값 0
Cookie cookie = new Cookie("refresh", null);
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
response.setStatus(HttpServletResponse.SC_OK);
}
deleteByRefresh 메서드를 이용해 refresh token db에서 삭제 http
.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter.class);
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
private final JwtUtil jwtUtil;
private final RefreshRepository refreshRepository;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
return http
// csrf disable
.csrf((auth) -> auth.disable())
// Form 로그인 방식 disable -> jwt 인증 방식을 사용할 것이기 때문에
.formLogin((auth) -> auth.disable())
// http basic 인증 방식 disable
.httpBasic((auth) -> auth.disable())
// 경로별 인가 작업
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login", "/", "/join").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.requestMatchers("/reissue").permitAll()
.anyRequest().authenticated())
.addFilterBefore(new JwtFilter(jwtUtil), LoginFilter.class)
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil,refreshRepository), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new JwtExceptionHandler(), JwtFilter.class)
.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter.class)
// jwt는 세션을 stateless하게 관리한다
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
}
// authenticationManager을 Bean으로 등록!
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception{
return configuration.getAuthenticationManager();
}
}






물론 이번 구현에서는... refresh token이 하루가 지나고 삭제되는 스케줄러 구현은 하지 못하였다
- db에 refresh token이 쌓이는 문제가 생긴다...
- 다음에는 refresh token이 만료되면 -> 스케쥴러를 통해서 db에서 삭제하는 로직을 구현해 보겠다
- redis를 이용하면 TTL설정을 통해, 자동으로 토큰을 삭제할 수 있다고 한다
- 다음 프로젝트에서는 많은 사람들이 사용하는 redis를 이용하는 refresh token을 구현해 보겠다