JWT(JSON Web Token)는 클라이언트와 서버 간에 안전하게 정보를 주고받기 위한 토큰 기반 인증 방식입니다. 일반적으로 서버는 클라이언트가 로그인할 때 JWT를 발급하고, 클라이언트는 이 JWT를 사용해 이후 요청을 인증합니다. JWT는 세 가지 주요 부분으로 구성됩니다:
JWT는 서버에 세션을 저장할 필요가 없기 때문에, 서버 부하가 적고, 클라이언트는 이 토큰을 가지고 자유롭게 서버에 요청을 할 수 있습니다.
AccessToken과 RefreshToken은 JWT 기반 인증에서 사용되는 두 가지 중요한 개념입니다.
AccessToken:
RefreshToken:
Token을 클라이언트 측에 저장할 때 로컬스토리지나 쿠키 또는 세션에 저장할 수 있습니다. 각각의 장단점이 설명하고, 이번 프로젝트에서는 쿠키를 선택한 이유를 설명하겠습니다.
저장소 | 장점 | 단점 |
---|---|---|
로컬스토리지 | 브라우저를 닫아도 정보가 남아있음 | XSS 공격에 취약 |
세션 | 브라우저를 닫으면 정보가 사라짐 | XSS 공격에 취약, 브라우저 종속적 |
쿠키 | http only로 저장 시 JS 접근이 불가능 | 탈취에 취약 |
HttpOnly 쿠키는 JavaScript를 통해 접근할 수 없으므로 XSS(크로스사이트 스크립팅) 공격에 대해 상대적으로 안전합니다.
Secure 쿠키는 HTTPS를 통해서만 전송되기 때문에, 네트워크 상에서 토큰이 탈취될 가능성을 줄여줍니다.
쿠키는 기본적으로 요청마다 자동으로 서버로 전송되기 때문에 CSRF(Cross-Site Request Forgery) 공격에 취약할 수 있지만, SameSite 속성을 사용하여 외부 도메인에서 요청되는 CSRF 공격을 차단할 수 있습니다.
쿠키는 브라우저 세션이 종료되거나 쿠키가 만료될 때까지 지속되며, 만료 시간을 설정할 수 있어 사용자 세션 관리에 유리합니다.
특히, Refresh Token을 HttpOnly와 Secure 속성으로 쿠키에 저장하면, 보안성을 유지하면서도 세션 관리를 쉽게 할 수 있습니다.
Refresh Token은 http only, secure Cookie에 저장 & Access Token은 로컬 변수에 저장
=> CSRF 공격 : Cookie에 Access Token이 없기에 인증 불가 상태. http only secure 쿠키 특성상 refresh token 자체를 털 방법이 없음
=> XSS 공격 : Access Token은 로컬 변수에 저장되어 있기에 탈취 불가
=> Access Token을 만료시간을 짧게 가져감으로써 그나마 취약한 XSS를 좀 더 방어할 수 있음
JWT는 서버 상태를 유지하지 않는 무상태(stateless) 토큰입니다. 하지만, JWT를 효과적으로 관리하기 위해 서버 측에서 Refresh Token을 저장하고 검증해야 할 필요가 있습니다. 여기서 Redis가 중요한 역할을 합니다.
Redis는 클라이언트의 토큰 재발급 요청 시, 서버가 Refresh Token의 유효성을 검증하고, 필요할 때마다 갱신된 토큰을 저장 및 삭제하는 용도로 사용됩니다.
RTR (Refresh Token Rotation)은 Refresh Token을 재발급할 때마다 기존의 Refresh Token을 무효화하고, 새로운 Refresh Token을 발급하는 방식입니다. 이 방식은 Refresh Token 도용을 방지하는 데 효과적입니다.
클라이언트가 Access Token을 재발급받기 위해 서버에 Refresh Token을 보낼 때마다, 서버는 기존 Refresh Token을 폐기하고 새로운 Refresh Token을 발급합니다.
서버는 클라이언트로부터 받은 Refresh Token을 검증하고, 유효한 경우 새로운 Access Token과 함께 새로운 Refresh Token을 발급합니다.
서버는 Redis에 저장된 Refresh Token과 요청된 Refresh Token을 비교하여 유효성을 확인한 후에, 새로운 Refresh Token을 Redis에 업데이트합니다.
Refresh Token 도용을 방지할 수 있습니다. 만약 악의적인 사용자가 Refresh Token을 탈취하더라도, 원래의 사용자가 정상적으로 토큰을 재발급받으면 탈취된 Refresh Token은 무효화되므로 더 이상 사용할 수 없게 됩니다.
초기 로그인 시
사용자가 로그인하면, 서버는 Access Token과 Refresh Token을 발급합니다.
Refresh Token은 Redis에 저장되고, 클라이언트는 두 토큰을 받아서 사용합니다.
Access Token 만료 시
클라이언트가 Access Token이 만료되면, Refresh Token을 사용하여 새로운 Access Token을 발급받습니다.
이 때, 서버는 Refresh Token을 검증하고 유효하면 새로운 Access Token과 함께 새로운 Refresh Token을 발급합니다.
서버는 Redis에 저장된 이전 Refresh Token을 삭제하고, 새로운 Refresh Token으로 업데이트합니다.
Refresh Token 도용 방지
만약 악의적인 사용자가 Refresh Token을 탈취하더라도, 사용자가 정상적으로 Access Token을 재발급받으면 이전의 Refresh Token은 무효화되므로 더 이상 사용할 수 없습니다.
Spring Boot에서 Redis를 사용하려면, 먼저 의존성을 추가하고 기본 설정을 해야 합니다.
build.gradle
에 Redis 의존성을 추가합니다.
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Redis의 호스트와 포트를 설정합니다.
spring:
redis:
host: localhost
port: 6379
RedisTemplate을 설정하는 RedisConfig
클래스를 만들어 Spring에서 Redis를 사용할 수 있도록 구성합니다.
package com.example.financialfinalproject.global.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
// 아래 두 라인을 작성하지 않으면, key값이 \xac\xed\x00\x05t\x00\x03sol 이렇게 조회된다.
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
JwtService
클래스는 JWT 발급과 검증, 그리고 Redis를 사용한 Refresh Token 관리를 담당합니다.
@Slf4j
@Getter
@Service
@RequiredArgsConstructor
public class JwtService {
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.access.expiration}")
private Long accessTokenExpirationPeriod;
@Value("${jwt.refresh.expiration}")
private Long refreshTokenExpirationPeriod;
@Value("${jwt.access.header}")
private String accessHeader;
@Value("${jwt.refresh.header}")
private String refreshHeader;
/**
* JWT의 Subject와 Claim으로 email 사용 -> 클레임의 name을 "email"으로 설정
* JWT의 헤더에 들어오는 값 : 'Authorization(Key) = Bearer {토큰} (Value)' 형식
*/
private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
private static final String EMAIL_CLAIM = "email";
private static final String BEARER = "Bearer ";
private final UserRepository userRepository;
private final RedisTemplate<String, String> redisTemplate;
/**
* AccessToken 생성 메소드
*/
public String createAccessToken(String email) {
log.info(email);
Claims claims = Jwts.claims();
claims.put("email", email);
return Jwts.builder()
.setClaims(claims)
.setSubject(ACCESS_TOKEN_SUBJECT)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + accessTokenExpirationPeriod))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
/**
* RefreshToken 생성
* RefreshToken은 Claim에 email도 넣지 않으므로 setClaim() X
*/
public String createRefreshToken() {
return Jwts.builder()
.setSubject(REFRESH_TOKEN_SUBJECT)
.setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpirationPeriod))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
/**
* AccessToken 헤더에 실어서 보내기
*/
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(accessHeader, accessToken);
log.info("재발급된 Access Token : {}", accessToken);
}
/**
* AccessToken + RefreshToken 헤더에 실어서 보내기
*/
public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) {
response.setStatus(HttpServletResponse.SC_OK);
setAccessTokenHeader(response, accessToken);
setRefreshTokenHeader(response, refreshToken);
log.info("Access Token, Refresh Token 헤더 설정 완료");
}
/**
* 헤더에서 RefreshToken 추출
* 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서
* 헤더를 가져온 후 "Bearer"를 삭제(""로 replace)
*/
public Optional<String> extractRefreshToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(refreshHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
/**
* 헤더에서 AccessToken 추출
* 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서
* 헤더를 가져온 후 "Bearer"를 삭제(""로 replace)
*/
public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(accessHeader))
.filter(accessHeader -> accessHeader.startsWith(BEARER))
.map(accessHeader -> accessHeader.replace(BEARER, ""));
}
/**
* AccessToken에서 Email 추출
**/
public String extractEmail(String token) {
try {
Claims body = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token)
.getBody();
log.info("token info:{}", body);
return body.get("email", String.class);
} catch (Exception e) {
log.error("액세스 토큰이 유효하지 않습니다.");
return null;
}
}
/**
* AccessToken 헤더 설정
*/
public void setAccessTokenHeader(HttpServletResponse response, String accessToken) {
response.setHeader(accessHeader, accessToken);
}
/**
* RefreshToken 헤더 설정
*/
public void setRefreshTokenHeader(HttpServletResponse response, String refreshToken) {
response.setHeader(refreshHeader, refreshToken);
}
/**
* RefreshToken Redis 저장(업데이트)
*/
@Transactional
public void updateRefreshToken(String email, String refreshToken) {
log.info("email:{}", email);
log.info("update:{}", refreshToken);
redisTemplate.opsForValue().set("RT:" + email, refreshToken, Duration.ofMillis(refreshTokenExpirationPeriod));
}
/**
* AccessToken 타당성 검증
* 만료되었는지 검증 & Redis를 통해 로그아웃된 토큰인지 검증
*/
public boolean isTokenValid(String token) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
ValueOperations<String, String> logoutValueOperations = redisTemplate.opsForValue();
if (logoutValueOperations.get("blackList:" + token) != null) {
log.info("로그아웃 된 토큰입니다.");
return false;
}
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
/**
* RefreshToken 타당성 검증
* 만료되었는지 검증 & Redis에 저장된 토큰과 동일성 검증
*/
public boolean isRefreshTokenValid(String token, String email) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
String dbToken = redisTemplate.opsForValue().get("RT:" + email);
return dbToken.equals(token) && !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
/**
* 만료시간 가져오기
*/
public Date getExpiredTime(String token) {
Claims body = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
return body.getExpiration();
}
/**
* 로그아웃
* AccessToken 남은 만료시간 계산 후 레디스에 블랙리스트 저장
* 레디스에 저장된 RefreshToken 삭제
*/
@Transactional
public void logout(HttpServletRequest request) {
String accessToken = extractAccessToken(request)
.filter((this::isTokenValid))
.orElse(null);
String email = extractEmail(accessToken);
long expiredAccessTokenTime = getExpiredTime(accessToken).getTime() - new Date().getTime();
redisTemplate.opsForValue().set("blackList:" + accessToken, email, Duration.ofMillis(expiredAccessTokenTime));
redisTemplate.delete("RT:" + email); // Redis에서 유저 리프레시 토큰 삭제
}
}
/**
* Jwt 인증 필터
* NO_CHECK_URL 이외의 URI 요청이 왔을 때 처리하는 필터
* 기본적으로 사용자는 요청 헤더에 AccessToken만 담아서 요청
* AccessToken 만료 시에만 RefreshToken을 요청 헤더에 AccessToken과 함께 요청
* 1. RefreshToken이 없고, AccessToken이 유효한 경우 -> 인증 성공 처리, RefreshToken을 재발급하지는 않는다.
* 2. RefreshToken이 없고, AccessToken이 없거나 유효하지 않은 경우 -> 인증 실패 처리
* 3. RefreshToken이 있는 경우 -> Redis의 RefreshToken과 비교하여 일치하면 AccessToken 재발급, RefreshToken 재발급(RTR 방식)
* 인증 성공 처리는 하지 않고 실패 처리
*/
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter {
private static final String[] NO_CHECK_URL = {
"/swagger-ui/index.html", "/swagger-ui/springfox.css",
"/swagger-ui/swagger-ui.css", "/swagger-ui/swagger-ui-standalone-preset.js",
"/swagger-ui/springfox.js", "/swagger-ui/swagger-ui-bundle.js",
"/swagger-resources/configuration/ui", "/swagger-ui/favicon-32x32.png",
"/swagger-resources/configuration/security", "/swagger-resources",
"/v3/api-docs", "api/v1/users/login", "/"
};
private final JwtService jwtService;
private final UserRepository userRepository;
private final RedisTemplate<String, String> redisTemplate;
private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info(request.getRequestURI());
if (Arrays.stream(NO_CHECK_URL).anyMatch(s -> s.equals(request.getRequestURI()))) {
filterChain.doFilter(request, response);
return; // return으로 이후 현재 필터 진행 막기
}
// 해당 url 요청시 로그아웃 실행
if (request.getRequestURI().equals("/api/v1/users/logout")) {
jwtService.logout(request);
log.info("로그아웃 성공");
return;
}
// 리프레시 토큰이 요청 헤더에 존재했다면, 사용자가 AccessToken이 만료되어서
// RefreshToken까지 보낸 것이므로 리프레시 토큰이 Redis의 리프레시 토큰과 일치하는지 판단 & 만료시간 검증 후,
// 일치한다면 AccessToken을 재발급해준다.
if (request.getRequestURI().equals("/api/v1/users/reissuance")) {
String email = request.getHeader("email");
// 사용자 요청 헤더에서 RefreshToken 추출
// -> RefreshToken이 없거나 유효하지 않다면(Redis에 저장된 RefreshToken과 다르다면) null을 반환
String refreshToken = jwtService.extractRefreshToken(request)
.filter((token) -> jwtService.isRefreshTokenValid(token, email))
.orElse(null);
checkRefreshTokenAndReIssueAccessToken(request, response, email);
log.info("재발급 성공");
return;
}
// RefreshToken이 없거나 유효하지 않다면, AccessToken을 검사하고 인증을 처리하는 로직 수행
// AccessToken이 없거나 유효하지 않다면, 인증 객체가 담기지 않은 상태로 다음 필터로 넘어가기 때문에 403 에러 발생
// AccessToken이 유효하다면, 인증 객체가 담긴 상태로 다음 필터로 넘어가기 때문에 인증 성공
checkAccessTokenAndAuthentication(request, response, filterChain);
}
/**
* [액세스 토큰/리프레시 토큰 재발급 메소드]
* 파라미터로 들어온 헤더에서 추출한 이메일로 유저를 찾고, 해당 유저가 있다면
* JwtService.createAccessToken()으로 AccessToken 생성,
* reIssueRefreshToken()로 리프레시 토큰 재발급 & Redis에 리프레시 토큰 업데이트 메소드 호출
* 그 후 JwtService.sendAccessTokenAndRefreshToken()으로 응답 헤더에 보내기
*/
public void checkRefreshTokenAndReIssueAccessToken(HttpServletRequest request, HttpServletResponse response, String email) {
log.info("checkRefreshTokenAndReIssueAccessToken() 호출");
String reIssuedRefreshToken = reIssueRefreshToken(email);
jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(email), reIssuedRefreshToken);
}
/**
* [리프레시 토큰 재발급 & Redis에 리프레시 토큰 업데이트 메소드]
* jwtService.createRefreshToken()으로 리프레시 토큰 재발급 후
* Redis에 재발급한 리프레시 토큰 업데이트
*/
private String reIssueRefreshToken(String email) {
String reIssuedRefreshToken = jwtService.createRefreshToken();
log.info("refresh-token-re:{}", reIssuedRefreshToken);
redisTemplate.opsForValue().set("RT:" + email, reIssuedRefreshToken);
return reIssuedRefreshToken;
}
/**
* [액세스 토큰 체크 & 인증 처리 메소드]
* request에서 extractAccessToken()으로 액세스 토큰 추출 후, isTokenValid()로 유효한 토큰인지 검증
* 유효한 토큰이면, 액세스 토큰에서 extractEmail로 Email을 추출한 후 findByEmail()로 해당 이메일을 사용하는 유저 객체 반환
* 그 유저 객체를 saveAuthentication()으로 인증 처리하여
* 인증 허가 처리된 객체를 SecurityContextHolder에 담기
* 그 후 다음 인증 필터로 진행
*/
public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("checkAccessTokenAndAuthentication() 호출");
String accessToken = jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid)
.orElse(null);
String email = jwtService.extractEmail(accessToken);
userRepository.findByEmail(email).ifPresent(this::saveAuthentication);
filterChain.doFilter(request, response);
}
/**
* [인증 허가 메소드]
* 파라미터의 유저 : 우리가 만든 회원 객체 / 빌더의 유저 : UserDetails의 User 객체
* new UsernamePasswordAuthenticationToken()로 인증 객체인 Authentication 객체 생성
*
* UsernamePasswordAuthenticationToken의 파라미터
* 1. 위에서 만든 UserDetailsUser 객체 (유저 정보)
* 2. credential(보통 비밀번호로, 인증 시에는 보통 null로 제거)
* 3. Collection < ? extends GrantedAuthority>로,
* UserDetails의 User 객체 안에 Set<GrantedAuthority> authorities이 있어서 getter로 호출한 후에,
* new NullAuthoritiesMapper()로 GrantedAuthoritiesMapper 객체를 생성하고 mapAuthorities()에 담기
*
* SecurityContextHolder.getContext()로 SecurityContext를 꺼낸 후,
* setAuthentication()을 이용하여 위에서 만든 Authentication 객체에 대한 인증 허가 처리
*/
public void saveAuthentication(User myUser) {
log.info("savedUserName:{}",myUser.getUserName());
String password = myUser.getPassword();
log.info("password:{}", password);
if (password == null) { // 소셜 로그인 유저의 비밀번호 임의로 설정 하여 소셜 로그인 유저도 인증 되도록 설정
password = PasswordUtil.generateRandomPassword();
}
UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder()
.username(myUser.getEmail())
.password(password)
.roles(myUser.getUserRole().name())
.build();
log.info("author:{}", userDetailsUser.getAuthorities());
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetailsUser, null,
authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
위와 같이 JWT 관련 설정을 마쳤습니다.
다음에는 커스텀 로그인에 대해 작성해보겠습니다.