Refresh token의 필요성
Access Token만을 통한 인증 방식의 문제는 제 3자에게 탈취당할 경우 보안에 취약하다는 점임.
엑세스 토큰은 발급 된 이후, 서버에 저장되지 않고 토큰 자체로 검증을 하며 사용자 권한을 인증하기 때문에, 엑세스 토큰이 탈취되면 토큰이 만료되기 전까지, 토큰을 획득한 사람은 누구나 권한 접근이 가능해짐
JWT는 발급한 후 삭제가 불가능하기 때문에, 접근에 관여하는 토큰에 유효시간을 부여하는 식으로 탈취 문제에 대해 대응해야 함
토큰의 유효기간을 짧게하면 토큰 남용을 방지하는 것의 해결책이 될 수 있지만, 유효기간이 짧은 토큰의 경우 그만큼 사용자는 로그인을 자주해서 새롭게 Token을 발급받아야 하므로 불편하다는 단점이 있음.
유효기간을 짧게 하면서 위의 문제를 해결할 수 있는 방법이 Refresh Token임
Refresh Token도 이름은 다르지만 Access Token처럼 JWT임, 단지 엑세스 토큰은 접근에 관여하는 토큰이고, 리프레시 토큰은 재발급에 관여하는 토큰이므로 토큰으로써 역할이 다름.
로그인 시 서버는 클라이언트에 엑세스 토큰과 리프레시 토큰을 발급한다. 서버는 데이터베이스에 리프레시 토큰을 저장하고, 클라이언트는 엑세스 토큰과 리프레시 토큰을 쿠키, 세션 혹은 웹 스토리지에 저장하고 요청이 있을때 헤더에 담아서 보낸다.
리프레시 토큰은 긴 유효기간을 가지면서 엑세스 토큰 만료 시 재발급해주는 열쇠가 됨, 만료된 엑세스 토큰을 서버에 보내면, 서버는 같이 보내진 리프레시 토큰을 DB에 저장된 것과 비교해서 일치할 경우 엑세스 토큰을 재발급한다.
사용자가 로그아웃을 할 때 저장소에서 리프레시 토큰을 삭제해 사용 불가하게 만들고 로그인 시 다시 발급해서 DB에 저장함
Access / Refresh Token 재발급 원리
1. 로그인 과정을 하면 Access Token과 Refresh Token을 모두 발급한다
- 이때, Refresh Token만 서버의 DB에 저장하고, Refresh Token과 Access Token을 쿠키 혹은 웹스토리지에 저장함
2. 사용자가 인증이 필요한 API에 접근하고자 하면, 가장 먼저 토큰을 검사한다
- 엑세스 토큰과 리프레시 토큰 모두 만료된 경우 → 에러 발생(재 로그인을 통해 두 토큰을 새로 발급)
- 엑세스 토큰은 만료되고 리프레시 토큰은 유효한 경우 → 리프레시 토큰을 검증하여 엑세스 토큰 재발급
- 엑세스 토큰과 리프레시 토큰 모두가 유효한 경우 → 정상 처리
3. 로그아웃 시 Access Token과 Refresh Token을 모두 만료
Refresh Token 인증 과정
1. 사용자가 ID, PW를 통해 로그인
2. 서버에서는 회원 DB에서 값을 비교
3~4. 로그인이 완료되면 Access Token, Refresh Token을 발급, 이때 회원 DB에도 Refresh Token을 저장
5. 사용자는 Refresh Token을 안전한 저장소에 저장 후, Access Token을 헤더에 실어 요청을 보냄
6~7. Access Token을 검증하여 이에 맞는 데이터를 보냄
8. 시간이 지나 Access Token이 만료 됨
9. 사용자는 이전과 동일하게 Access Token을 헤더에 실어 요청을 보냄
10~11. 서버는 Access Token이 만료됨을 확인하고 권한 없음을 신호로 보냄
12. 사용자는 Refresh Token과 Access Token을 함께 서버로 보냄
13. 서버는 받은 Access Token의 조작 여부를 확인 후, Refresh Token과 회원 DB에 저장된 Refresh Token을 비교해서 토큰이 동일하고 유효기간이 만료되지 않았을 경우 새로운 Access Token을 발급
14. 서버는 새로운 Access Token을 헤더에 실어 다시 API 요청에 응답을 진행
Refresh Token 구현
Refresh Token Entity
@Entity
@Getter
@Table(name = "refreshtoken")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {
@Id
@Schema(description = "refresh token")
private String token;
@Schema(description = "토큰 발급 사용자 email 정보")
private String email;
@Schema(description = "토큰 만료 시간")
private Date expiryDate;
@Builder
public RefreshToken(String token, String email, Date expiryDate) {
this.token = token;
this.email = email;
this.expiryDate = expiryDate;
}
}
Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
}
RefreshTokenDto
@Getter
public class RefreshTokenRequest {
private String refreshToken;
}
Controller(Access Token 갱신용)
@Tag(name = "Auth Controller", description = "리프레시 토큰을 통한 엑세스 토큰 갱신")
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final RefreshTokenService refreshTokenService;
public AuthController(RefreshTokenService refreshTokenService) {
this.refreshTokenService = refreshTokenService;
}
/*엑세스 토큰 갱신 API 호출*/
@PostMapping("/refresh")
@Operation(summary = "엑세스 토큰 갱신", description = "리프레시 토큰 검증 및 새 엑세스 토큰 발급")
@ApiResponse(responseCode = "200", description = "엑세스 토큰 발급 성공")
public ResponseEntity<String> refreshAccessToken(@RequestBody RefreshTokenRequest request) {
/*리프레시 토큰이 없으면 badRequest 반환*/
String refreshToken = request.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty()) {
return ResponseEntity.badRequest().body("토큰이 입력되지 않았습니다.");
}
/*리프레시 토큰 검증*/
boolean isValidRefreshToken = refreshTokenService.validateRefreshToken(refreshToken);
if (!isValidRefreshToken) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("리프레시 토큰 검증이 실패했습니다.");
}
/*리프레시 토큰으로 새로운 엑세스 토큰 발급*/
return refreshTokenService.refreshAccessToken(refreshToken)
.map(accessToken -> {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.AUTHORIZATION, accessToken);
return ResponseEntity.ok().headers(headers).body("엑세스 토큰이 발급되었습니다.");
})
.orElseGet(() -> ResponseEntity.status(HttpStatus.BAD_REQUEST).body("엑세스 토큰 발급에 실패했습니다."));
}
}
RefreshTokenService
@Slf4j(topic = "리프레시 토큰 관리")
@Service
@Transactional
public class RefreshTokenService {
/*리프레시 토큰 관리 로직
* 토큰 생성, 저장, 검증, 삭제, 액세스 토큰 갱신 기능 수행*/
private final RefreshTokenRepository refreshTokenRepository;
private final JwtUtil jwtUtil;
public RefreshTokenService(RefreshTokenRepository refreshTokenRepository, JwtUtil jwtUtil) {
this.refreshTokenRepository = refreshTokenRepository;
this.jwtUtil = jwtUtil;
}
/*리프레시 토큰 생성 및 저장*/
public void createAndSaveRefreshToken(String email, String refreshTokenString) {
Date expiryDate = new Date(System.currentTimeMillis() + JwtUtil.REFRESH_TOKEN_VALIDITY_MS);
RefreshToken refreshToken = RefreshToken.builder()
.token(refreshTokenString)
.email(email)
.expiryDate(expiryDate)
.build();
refreshTokenRepository.save(refreshToken);
}
/*리프레시 토큰 검증*/
public boolean validateRefreshToken(String token) {
return refreshTokenRepository.findByToken(token)
.map(RefreshToken::getExpiryDate)
.map(expiryDate -> !expiryDate.before(new Date()))
.orElse(false);
}
/*리프레시 토큰으로 새 액세스 토큰 발급*/
public Optional<String> refreshAccessToken(String refreshToken) {
return refreshTokenRepository.findByToken(refreshToken)
.filter(token -> !token.getExpiryDate().before(new Date())) /*토큰 만료 여부 검사*/
.map(RefreshToken::getEmail)
.map(email -> jwtUtil.createAccessToken(email, UserRoleEnum.USER)); /*새 액세스 토큰 생성*/
}
/*리프레시 토큰 삭제*/
public void deleteRefreshToken(String refreshTokenString) {
/*DB에 해당 토큰이 있는 지 확인*/
Optional<RefreshToken> refreshTokenOptional = refreshTokenRepository.findByToken(refreshTokenString);
if (refreshTokenOptional.isPresent()) {
refreshTokenRepository.delete(refreshTokenOptional.get());
/*추가적인 로직이 필요하다면 여기에 구현합니다.
*(예를 들어, 로깅이나 다른 데이터베이스 정리 작업 등이 될 수 있습니다.*/
} else {
/*DB에 해당 토큰이 존재하지 않는 경우, 예외를 던집니다.*/
throw new CustomApiException("리프레시 토큰이 유효하지 않거나 이미 삭제되었습니다.");
}
}
}
JwtUtil
package com.sparta.balance.global.jwt;
import com.sparta.balance.domain.user.entity.UserRoleEnum;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {
/*Header KEY 값*/
public static final String AUTHORIZATION_HEADER = "Authorization";
/* 사용자 권한 값의 KEY*/
public static final String AUTHORIZATION_KEY = "auth";
/* Token 식별자*/
public static final String BEARER_PREFIX = "Bearer ";
/* refresh token 유효 시간*/
public static final long REFRESH_TOKEN_VALIDITY_MS = 14 * 24 * 60 * 60 * 1000L; // 14 days
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
/*토큰 생성*/
public String createAccessToken(String email, UserRoleEnum role) {
Date date = new Date();
/*토큰 만료시간*/
// long TOKEN_TIME = 60 * 60 * 1000L; /*60분*/
long TOKEN_TIME = 10 * 60 * 1000L; /*10분 - 테스트를 위해 변경*/
return BEARER_PREFIX +
Jwts.builder()
.setSubject(email) /*사용자 식별자값(ID)*/
.claim(AUTHORIZATION_KEY, role) /*사용자 권한*/
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) /*만료 시간*/
.setIssuedAt(date) /*발급일*/
.signWith(key, signatureAlgorithm) /*암호화 알고리즘*/
.compact();
}
/*리프레시 토큰 생성 메서드*/
public String createRefreshToken(String email, UserRoleEnum role) {
long REFRESH_TOKEN_TIME = 14 * 24 * 60 * 60 * 1000L; /*14일*/
Date now = new Date();
return Jwts.builder()
.setSubject(email) /*사용자 식별자값(ID)*/
.claim(AUTHORIZATION_KEY, role) /*사용자 권한*/
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_TIME)) /*만료 시간*/
.setIssuedAt(now) /*발급일*/
.signWith(key, signatureAlgorithm) /*암호화 알고리즘*/
.compact();
}
/*header 에서 JWT 가져오기*/
public String getJwtFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}
UserController(로그인, 로그아웃)
@PostMapping("/login")
@Operation(summary = "로그인", description = "회원 이메일(아이디), 비밀번호를 입력해 로그인할 수 있습니다.")
@ApiResponse(responseCode = "200", description = "로그인 완료")
/*로그인 기능 호출*/
public ResponseEntity<LoginResponseDto> loginUser(@RequestBody LoginRequestDto requestDto) {
UserResponseDto userResponseDto = userService.loginUser(requestDto);
/*엑세스 토큰을 Authorization Header로 반환*/
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.AUTHORIZATION, userResponseDto.getAccessToken());
LoginResponseDto loginResponseDto = new LoginResponseDto(
userResponseDto.getUsername(), userResponseDto.getRefreshToken(), "로그인에 성공했습니다."
);
/*로그인 완료 후 리프레시 토큰, 유저이름, 성공 메시지 반환
* 실패 시 에러 메시지 반환*/
return ResponseEntity.ok()
.headers(headers)
.body(loginResponseDto);
}
/*로그아웃 기능 호출*/
@PostMapping("/logout")
@Operation(summary = "로그아웃", description = "로그아웃 시 JWT 토큰을 만료처리 합니다.")
@ApiResponse(responseCode = "200", description = "로그아웃 완료")
public ResponseEntity<String> logoutUser(@RequestBody RefreshTokenRequest request) {
/*리프레시 토큰이 없으면 badRequest 반환*/
String refreshTokenString = request.getRefreshToken();
if (refreshTokenString == null || refreshTokenString.isEmpty()) {
return ResponseEntity.badRequest().build();
}
/*로그아웃 API 호출*/
userService.logoutUser(refreshTokenString);
/*로그아웃 메시지 반환*/
return ResponseEntity.status(HttpStatus.OK).body("로그아웃 되었습니다.");
}
UserService(로그인, 로그아웃)
/*로그인 로직
* 입력된 이메일과 비밀번호 일치 여부 검사 후 유저 정보로 JWT 토큰 생성 후 유저 이름과 함께 반환*/
public UserResponseDto loginUser(LoginRequestDto requestDto) {
String email = requestDto.getEmail();
String password = requestDto.getPassword();
/*이메일 확인*/
User user = userRepository.findByEmail(email).orElseThrow(
() -> new CustomApiException("등록된 사용자가 없습니다."));
/*비밀번호 확인*/
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new CustomApiException("비밀번호가 일치하지 않습니다.");
}
/*JWT 토큰 발급*/
String accessToken = jwtUtil.createAccessToken(user.getEmail(), user.getRole());
String refreshTokenString = jwtUtil.createRefreshToken(user.getEmail(), user.getRole());
refreshTokenService.createAndSaveRefreshToken(user.getEmail(), refreshTokenString);
return new UserResponseDto(user.getUsername(), accessToken, refreshTokenString);
}
/*로그아웃 로직*/
public void logoutUser(String refreshTokenString) {
/*리프레시 토큰 삭제*/
refreshTokenService.deleteRefreshToken(refreshTokenString);
}
'항해 99 > Spring' 카테고리의 다른 글
AWS로 HTTPS 연결 (0) | 2024.03.16 |
---|---|
LogBack을 통한 로그 관리 (0) | 2024.03.15 |
REST API URI 규칙 (0) | 2024.03.13 |
ExceptionHandler (0) | 2024.03.11 |
QueryDSL (0) | 2024.03.08 |