임도현의 성장
[Spring-Boot] JWT Access Token Refresh Token 본문
JWT (Json Web Token) 란?
JWT는 일반적으로 클라이언트와 서버 사이에서 통신할 때 권한을 위해 사용하느 토큰이다. 웹 상에서 정보를 Json형태로 주고 받기 위해 표준규약에 따라 생성한 암호화된 토큰으로 복잡하고 읽을 수 없는 String 형태로 저장되어있다.
😎 JWT 구성요소
헤더(Header), 페이로드(Payload), 서명(Signature)세 파트로 나눠져 있다.
- 헤더(Header)
- 어떠한 알고리즘으로 암호화 할 것인지 어떠한 토큰을 사용할 것 인지에 대한 정보가 들어있다.
- 페이로드(Payload)
- 전달하려는 정보(사용자의 ID나 데이터들, 이것들을 클레임이라고 부름)가 들어있다. Payload에 있는 내용은 수정이 가능하며 더 많은 정보를 추가할 수 있다. 하지만 노출과 수정이 가능하기에 인증이 필요한 최소한의 정보만들 담아야한다.
- 서명(Signature)
- 가장 중요한 부분으로 헤더와 정보를 합친 후 발급해준 서버가 지정한 Seret Key로 암호화 시켜 토큰을 변조하기 어렵게 만들어준다. 토큰이 발급된 후 누군가가 Payload의 정보를 수정하면 누군가의 조작된 정보가 들어가 있지만 Signature에는 수정되기 전의 Payload 내용 기반으로 이미 암호화 되어있기 때문에 조작된 토큰인지 아닌지 알 수 있다.
👩💻 Acess Token
- 정의 : Access Token은 사용자가 인증된 후 서버가 클라이언트에게 발급하는 짧은 기간 동안 유효한 토큰입니다. 이 토큰은 API 요청을 인증하는 데 사용합니다.
- 유효 기간 : 짧게 (15분, 30분)
- 용도 : Access Token을 사용하여 사용자가 API를 요청할 때 마다 서버는 이 토큰을 확인하여 해당 사용자가 인증된 사용자임을 확인합니다.
- 단점 : 짧은 유효 기간 때문에 사용자가 계속해서 서비스에 접근하려면 Access Token을 갱신할 수 있어야 한다.
👻 Refresh Token
- 정의 : Refresh Token은 Access Token의 유효 기간이 만료된 후 새로운 Access Token을 발급받기 위해 사용된는 길게 유효한 토큰입니다. 보통 서버에만 저장되며, 클라이언트는 주로 로컬 저장소나 쿠키에 저장합니다.
- 유효 기간 : 매우 길다 (몇 주나 몇달간 유효)
- 용도 : 클라이언트 Access Token을 갱신할 때 사용됩니다. 예를 들어 Access Token이 만료되면 클라이언트는 Refresh Token을 서버에 보내 새로운 Access Token을 받급받을 수 있습니다.
🥶 서버-클라이언트 통신 방법
- 사용자가 처음 로그인할 때 서버는 사용자 정보를 확인하고 Access Token과 Refresh Token을 발급해줍니다. 이때 Refresh Token은 DB에 저장하고 쿠키에 담아서 보내줍니다.
- 이제 사용자가 클라이언트에 요청할때 마다 HTTP 헤더에 Access Token을 포함시켜 보냅니다.
- 헤더에서 Access Token을 꺼내 검증한다.
- A ccess Token이 만료되었으면 Access Invalid Token이라는 메세지와 205상태값을 보낸다. 이전에 보냈던 요청을 새 토큰을 받고 다시 요청하기 위해 205상태값을 보낸다.
- Access Token이 없으면 Access Token is missing이라는 메세지와 401상태값을 보낸다.
- 만약 Access Token이 만료되었으면 사용자는 RefreshToken 쿠키를 CookieValue로 받아서 Refresh Token을 확인해 유효기간이 남아있고 DB에도 똑같은 Token이 있으면 응답쿼리에 새로운 Access Token을 넣어 응답한다.
- Refresh Token도 만료 되었으면 401상태값을 보낸다.
- DB에 저장된 Refresh Token이 서로 다르면 Danger Security Token!! 이라는 메세지와 401상태값을 보낸다. 이 경우 조작된 토큰일 가능성이 높다.
🎃 토큰 생성 코드
아까 설명했던 JWT에서 Signature를 담당하는 부분이 SECRET_KEY입니다.
그리고 Payload로드에 정보를 추가 할 수 있는 방법이다. .claim("username", user.getUserName())이렇게 하면 Payload에 유저 이름도 포함 될 수가 있다. 위에서 설명 했듯이 Access Token은 짧은 유효 시간 을 가지고 Refresh Token은 긴 유효 시간을 가진다.
public static final String SECRET_KEY = Base64.getEncoder().encodeToString(Keys.secretKeyFor(SignatureAlgorithm.HS256).getEncoded());
public static final String REFRESH_TOKEN_SECRET_KEY = Base64.getEncoder().encodeToString(Keys.secretKeyFor(SignatureAlgorithm.HS512).getEncoded());
// accessToken 생성
public static String createAccessToken(String userId) {
return Jwts.builder()
.setSubject(userId) // 유저 번호를 subject로 설정 // #G909
// .claim("username", user.getUserName()) // 추가 정보 저장
.setIssuedAt(new Date()) // 발급 시간 (현재 시간으로 자동 설정)
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 30)) // 30분 후 만료
.signWith(SignatureAlgorithm.HS256, SECRET_KEY) // 서명
.compact();
}
// Access Token 만료 시간 반환 메서드
public static Date getExpirationFromToken(String accessToken) {
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY) // 서명 검증을 위한 키
.parseClaimsJws(accessToken) // 토큰 파싱
.getBody(); // Payload 추출
return claims.getExpiration(); // 만료 시간 반환
}
// refresh Token 생성
public static String createRefreshToken(String userId) {
return Jwts.builder()
.setSubject(userId)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 7)) // 7일 후 만료
.signWith(SignatureAlgorithm.HS512, REFRESH_TOKEN_SECRET_KEY)
.compact();
}
// refresh Token 만료 시간 반환 메서드
public static Date getExpirationFromRefreshToken(String accessToken) {
Claims claims = Jwts.parser()
.setSigningKey(REFRESH_TOKEN_SECRET_KEY)
.parseClaimsJws(accessToken)
.getBody();
return claims.getExpiration();
}
🍪 Refresh Token 쿠키에 담기
Token을 in-memory에 저장하고 새로고침을 하면 Token들이 사라진다. 이때 토큰들이 사라지면 다시 로그인을 해야하는 불편함이 발생한다. 이때 Refresh Token을 쿠키에 담으면 새로고침을 해도 지워지지 않는다. 하지만 쿠키에 담으면 보안이 취약하다는 단점이 생긴다. 나는 쿠키에 옵션을 줘서 보안을 강화하였다. 탈취되는 경우도 고려하여 쿠키에있는 Refresh Token을 꺼내서 DB에있는 Refresh Token하고 동일한지 검증하는 로직도 만들었다.
@ResponseBody
@GetMapping(value="/{userId}/tokenInfo", produces="application/json; charset=UTF-8")
public ResponseEntity<AccessVO> tokenInfo(@PathVariable String userId, HttpServletRequest request, HttpServletResponse response){
TokenVO token = userService.makeToken(userId);
if (token == null || token.getRefreshToken() == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null);
}
String refreshToken = token.getRefreshToken();
Cookie refreshTokenCookie = new Cookie("RefreshToken", refreshToken);
refreshTokenCookie.setHttpOnly(true); // 자바스크립트 접근 불가
refreshTokenCookie.setSecure(true); // HTTPS에서만 전송
refreshTokenCookie.setPath("/"); // 애플리케이션 전역에서 사용 가능
refreshTokenCookie.setMaxAge(-1); // 세션 동안 유효, 브라우저 종료 시 쿠키 삭제됨
refreshTokenCookie.setDomain("api.mildo.xyz"); // 도메인 설정
refreshTokenCookie.setAttribute("SameSite", "None"); // SameSite 속성 설정
response.addCookie(refreshTokenCookie);
AccessVO result = userService.findAccessToken(userId);
return ResponseEntity.ok(result);
}
🔒 토큰 생성 결과
밑에 이미지 처럼 토큰이 생성되고 Access Token과 토큰 만료시간을 JSON형식으로 주고 Refresh Token은 DB에 저장하고 쿠키에 담아서 보내준다.
🍻 Access Token 검증하기
사용자가 클러이언트에 요청할때 마다 필터를 사용해서 헤더에서 Access Token을 꺼내서 검증을 합니다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
// AntPathMatcher 으로 쉽게 동적 경로를 처리할 수 있다.
AntPathMatcher pathMatcher = new AntPathMatcher();
// 인증이 필요 없는 URL
if (pathMatcher.match("/new-token", requestURI) ||
pathMatcher.match("/check-token", requestURI) ||
requestURI.startsWith("/public")) {
filterChain.doFilter(request, response);
return;
}
// 헤더에서 토큰 추출
String token = request.getHeader("Authorization");
if (token == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Access Token is missing");
return;
}
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7); // "Bearer " 부분 제거
try {
// 토큰 검증 및 만료 시간 확인
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();
BlackTokenVO tokenVo = userRepository.checkBlackList(token); // 블랙리스트에 있는 토큰인지 검사
if(tokenVo != null){ // 블랙리스트에 토큰이 있으면 보안이 위험
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Danger Security Token!!");
return;
}
// 토큰이 유효한 경우 추가 작업 가능
request.setAttribute("user", claims.getSubject());
} catch (ExpiredJwtException e) { // Access Token 만료 시 발생
response.setStatus(HttpServletResponse.SC_RESET_CONTENT);
response.getWriter().write("Access Token Expired");
return;
}catch (Exception e) {
log.error("Exception e = {}", e.getMessage());
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Access Invalid Token");
return;
}
}
// 다음 필터로 요청 전달
filterChain.doFilter(request, response);
}
💩 Refresh Token으로 Access Token 생성하기
CookieValue를 이용해서 Refresh Token을 검증해서 유효 기간이 유효하고 DB에있는 토큰이랑 똑같으면 응답쿼리 헤더에 새로운 Access Token을 만들어서 응답해줍니다.
@ResponseBody
@GetMapping(value="/new-token", produces="application/json; charset=UTF-8")
public ResponseEntity<?> getCookieValue(@CookieValue(name = "RefreshToken", required = false) String RefreshToken, HttpServletRequest request) {
log.info("RefreshToken = {}", RefreshToken);
if (RefreshToken == null) { // 쿠키에 토큰이 없음
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Token is missing");
}
try{
Claims claims = Jwts.parser()
.setSigningKey(REFRESH_SECRET_KEY)
.parseClaimsJws(RefreshToken)
.getBody();
TokenVO isRefresh = userService.findRefreshTokenByUserId(RefreshToken); // DB 확인
if(isRefresh == null){ // 토큰이 DB에 없으면
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Danger Security Token!!");
}
// 아무런 문제 없으면 토큰 만들어 주기
String newToken = newcreateAccessToken(claims);
// DB에 새로운 토큰 저장
AccessVO access = userService.updateNewToken(newToken, claims.getSubject());
return ResponseEntity.ok(access);
} catch (ExpiredJwtException e) { // Token 만료 시 발생
log.error("ExpiredJwtException e = {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("리플 만료 재로그인 바람");
}catch (Exception e) { // 유효하지 않으면
log.error("Exception e = {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(null);
}
}
public String newcreateAccessToken(Claims expiredClaims) {
// 새 Access Token 발급
String newAccessToken = Jwts.builder()
.setSubject(expiredClaims.getSubject())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60)) // 1시간 후 만료
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
return newAccessToken;
}
🤬 로그아웃
로그아웃 할때 세션하고 RefreshToken쿠키를 지워줘야 한다. 이때 쿠키를 만들어 줬을때 해줬던 설정을 똑같이 맞추어 주어야한다.
@PostMapping("/{userId}/google-logout")
public ResponseEntity<String> logout(@PathVariable String userId, HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
if (authentication != null) {
new SecurityContextLogoutHandler().logout(request, response, authentication); // 세션 무효화 및 SecurityContext 초기화
}
deleteRefreshTokenCookie(response);
String result = userService.blackToken(userId);
String message = "토큰이 없음".equals(result) ? "토큰은 없지만 로그아웃 성공" : "로그아웃 성공";
return ResponseEntity.ok(message);
}
private void deleteRefreshTokenCookie(HttpServletResponse response) {
Cookie myCookie = new Cookie("RefreshToken", null);
myCookie.setMaxAge(0); // 쿠키의 expiration 타임을 0으로 하여 없앤다.
myCookie.setPath("/");
myCookie.setHttpOnly(true);
myCookie.setSecure(true);
myCookie.setDomain("api.mildo.xyz");
myCookie.setAttribute("SameSite", "None");
response.addCookie(myCookie);
}
🤭 내 생각 정리
JWT는 정보를 안전하게 전송하기 위해 사용되는 서명된 토큰입니다.
Access Token은 API 요청을 인증하는 짧은 기간 동안 유효한 토큰이며, Refresh Token은 Access Token이 만료되었을 때 새로운 Access Token을 발급받는 데 사용되는 길게 유효한 토큰입니다. 이 두 토큰을 함께 사용하면, 사용자는 로그인 상태를 유지하면서도 보안성을 높일 수 있습니다. Access Token과 Refresh Token은 기존의 인증 방식(예: 세션 기반 인증)에 비해 탈취 위험을 줄이고, 분산된 환경에서 효율적이며 확장 가능한 보안을 제공하며 서버는 토큰의 유효성을 서명(Signature)을 통해 DB조회 할 필요가 없어 빠르다고 판단하였습니다.
'Spring Boot' 카테고리의 다른 글
[Spring-Boot] Redis에 저장 조회 해보기 (1) | 2025.01.11 |
---|---|
[Spring-Boot] Cross-Origin Resource Sharing (0) | 2024.12.25 |
[Spring-Boot] Spring Security 스프링 시큐리티 (0) | 2024.10.28 |
[Spring-Boot] AOP개념 @Aspect Advisor (1) | 2024.10.03 |
Spring-Boot @Transactional 트랜잭션 전파 (0) | 2024.09.15 |