임도현의 성장
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이라는 메세지와 401상태값을 보낸다.
- Access Token이 없으면 Access Token is missing이라는 메세지와 401상태값을 보낸다.
- 만약 Access Token이 만료되었으면 사용자는 Refresh Token을 헤더에 담아서 보내고 클라이언트는 Refresh Token을 확인해 유효기간이 남았고 DB에도 똑같은 Token이 있으면 응답쿼리 헤더에 새로운 Access Token을 넣어 응답한다.
- Refresh Token도 만료 되었으면 Refresh Token has expired 이라는 메세지와 401상태값을 보낸다.
- DB에 저장된 Refresh Token이 서로 다르면 Refresh Token not found or invalid in DB 이라는 메세지와 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();
}
public TokenVO makeToken(String userId) { // 토큰 생성 관련 메서드
TokenVO token = new TokenVO();
UserVO user = userRepository.finduserId(userId); // 존재하는 User인지 검증
if(user == null) {return token;} // user가 없으면 null 보냄
TokenVO vo = userRepository.findToken(userId); // DB에 토큰 검증
String accessToken = jwtTokenProvider.createAccessToken(userId);
String refreshToken = jwtTokenProvider.createRefreshToken(userId);
Date expiration = jwtTokenProvider.getExpirationFromToken(refreshToken);
// refreshToken 만료시간 형변환
java.sql.Timestamp sqlExpiration = new java.sql.Timestamp(expiration.getTime());
token.setUserId(userId);
token.setAccessToken(accessToken);
token.setRefreshToken(refreshToken);
token.setExpirationTime(sqlExpiration);
if(vo == null){ // 토큰이 없으면 INSERT
userRepository.saveToken(token);
} else { // 토큰이 있으면 UPDATE
userRepository.saveUpdateToken(token);
}
return token;
}
🔒 토큰 생성 결과
밑에 이미지 처럼 토큰이 생성되고 Refresh Token과 expirationTime은 나중에 Access 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("/auth/refresh", 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_UNAUTHORIZED);
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 생성하기
Refresh Token을 검증해서 유효 기간이 유효하고 DB에있는 토큰이랑 똑같으면 응답쿼리 헤더에 새로운 Access Token을 만들어서 응답해줍니다.
@PostMapping("/auth/refresh")
public ResponseEntity<?> refreshAccessToken(@RequestHeader("Authorization") String refreshToken) {
Timestamp timestamp = new Timestamp(System.currentTimeMillis());
log.info("date = {}", timestamp);
userService.blackrest(timestamp); // 블랙 리스트 초기화
try {
if (!refreshToken.startsWith("Bearer ")) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Invalid Refresh Token format.");
}
refreshToken = refreshToken.substring(7);
log.info("refreshToken = {}", refreshToken);
// Refresh Token 검증
Claims claims = Jwts.parser()
.setSigningKey(REFRESH_SECRET_KEY)
.parseClaimsJws(refreshToken)
.getBody();
String userId = claims.getSubject();
String storedToken = userService.findRefreshTokenByUserId(userId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Invalid Refresh Token.");
}
// 새 Access Token 발급
String newAccessToken = Jwts.builder()
.setSubject(userId)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60)) // 1시간 후 만료
.signWith(SignatureAlgorithm.HS512, REFRESH_SECRET_KEY)
.compact();
log.info("newAccessToken = {}", newAccessToken);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + newAccessToken);
return ResponseEntity.ok()
.headers(headers)
.body("Access Token refreshed successfully = " + newAccessToken);
} catch (ExpiredJwtException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Refresh Token has expired.");
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Invalid Refresh Token.");
}
}
🤭 내 생각 정리
JWT는 정보를 안전하게 전송하기 위해 사용되는 서명된 토큰입니다.
Access Token은 API 요청을 인증하는 짧은 기간 동안 유효한 토큰이며, Refresh Token은 Access Token이 만료되었을 때 새로운 Access Token을 발급받는 데 사용되는 길게 유효한 토큰입니다. 이 두 토큰을 함께 사용하면, 사용자는 로그인 상태를 유지하면서도 보안성을 높일 수 있습니다. Access Token과 Refresh Token은 기존의 인증 방식(예: 세션 기반 인증)에 비해 탈취 위험을 줄이고, 분산된 환경에서 효율적이며 확장 가능한 보안을 제공합니다.
'Spring Boot' 카테고리의 다른 글
Spring-Boot Spring Security 스프링 시큐리티 (0) | 2024.10.28 |
---|---|
Spring-Boot AOP개념 @Aspect Advisor (0) | 2024.10.03 |
Spring-Boot @Transactional 트랜잭션 전파 (0) | 2024.09.15 |
Spring Data JPA + Query Dsl JPA (2) | 2024.09.07 |
Spring-Boot H2 데이터베이스 설정과 연결 (1) | 2024.09.02 |