본래 JWT는 따로 저장소를 사용하지 않기 때문에 Domain이나 Repository가 필요하지 않지만 이 프로젝트에서는 RTR방식을 사용해 Redis에 Token을 저장하고 있기 때문에 Redis를 활용하기 위한 Domain과 Repository 패키지를 추가로 만들었다.
1. Domain
com.security_board.security.jwt.domain.Token
package com.security_board.security.jwt.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;
import org.springframework.data.redis.core.index.Indexed;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@RedisHash(value = "token")
public class Token {
@Id
public String email;
@Indexed
public String refreshToken;
@TimeToLive
private long ttl;
}
Id를 email 즉 key를 email, value를 refreshToken으로 하는 Redis 저장용 객체를 만들기 위한 entity이다.
email과 refreshToken 외에 @TimeToLive 어노테이션을 단 ttl과 함께 저장해 refreshToken의 유효기간이 만료되면 저장소에서도 삭제되도록 만들었다.
2. Repository
com.security_board.security.jwt.repository.TokenRepository
package com.security_board.security.jwt.repository;
import java.util.Optional;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import com.security_board.security.jwt.domain.Token;
@Repository
public interface TokenRepository extends CrudRepository<Token, String>{
Optional<Token> findByEmail(String email);
}
Redis를 사용하게되면 JpaRepository 대신에 CRUDRepository를 상속받게 되는데 실제 사용법은 JPA와 크게 다르지 않은 것 같았다.
현재 프로젝트가 단순한만큼 아직 깊게 사용해보지 않아서 그럴 수 있으니 후에 더 복잡하게 사용하게 된다면 제대로 공부하고 사용해볼 생각이다.
3. Service
Service 패키지는 데이터베이스에 직접적으로 접근하는 로직들이 담겨 있는 2개의 클래스로 이루어져 있다.
JWT와 관련된 제일 핵심 로직들이 이 클래스에서 구현된다.
com.security_board.security.jwt.service.CustomUserDetailsService
package com.security_board.security.jwt.service;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import com.security_board.security.jwt.util.UserPrincipal;
import com.security_board.security.member.domain.Member;
import com.security_board.security.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService{
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(username)
.orElseThrow(()-> new UsernameNotFoundException("Member Not Fount"));
return new UserPrincipal(member);
}
}
Spring Security는 Filter chain 과정을 거치며 UserDetailService의 loadUserByUsername()을 호출해 DB에 저장된 사용자 정보를 가져온다.
그리고 해당 정보를 UserDetails 객체로 변환해 반환한다.
이 클래스는 UserDetailService를 구현한 클래스로 loadUserByUsername()을 필요에 맞게 재정의 했다.
파라미터로 전달받은 username을 사용해 DB에서 Member 객체를 가져오고 가져온 Member 객체를 UserDetails를 구현한 UserPrincipal의 생성자의 파라미터로 넘겨서 생성된 UserPrincipal 객체를 반환하도록 했다.
com.security_board.security.jwt.service.JwtService
package com.security_board.security.jwt.service;
@Service
@Transactional(readOnly = true)
@Slf4j
public class JwtService {
private final CustomUserDetailsService customUserDetailsService;
private final JwtGenerator jwtGenerator;
private final JwtUtil jwtUtil;
private final TokenRepository tokenRepository;
private final Key ACCESS_SECRET_KEY;
private final Key REFRESH_SECRET_KEY;
private final long ACCESS_EXPIRATION;
private final long REFRESH_EXPIRATION;
public JwtService(CustomUserDetailsService customUserDetailService, JwtGenerator jwtGenerator, JwtUtil jwtUtil,
TokenRepository tokenRepository, @Value("${jwt.access-secret}") String ACCESS_SECRET_KEY,
@Value("${jwt.refresh-secret}") String REFRESH_SECRET_KEY,
@Value("${jwt.access-expiration}") long ACCESS_EXPIRATION,
@Value("${jwt.refresh-expiration}") long REFRESH_EXPIRATION) {
super();
this.customUserDetailsService = customUserDetailService;
this.jwtGenerator = jwtGenerator;
this.jwtUtil = jwtUtil;
this.tokenRepository = tokenRepository;
this.ACCESS_SECRET_KEY = jwtUtil.getSigningKey(ACCESS_SECRET_KEY);
this.REFRESH_SECRET_KEY = jwtUtil.getSigningKey(REFRESH_SECRET_KEY);
this.ACCESS_EXPIRATION = ACCESS_EXPIRATION;
this.REFRESH_EXPIRATION = REFRESH_EXPIRATION;
}
// Member 객체가 가입된 유저인지 확인하는 메서드
// 회원이 아직 등록되지 않은 사용자일 경우 인증되지 않은 사용자로 간주하고 예외를 던짐
public void validateUser(Member requestUser) {
if (requestUser.getRole() == Role.NOT_REGISTERED) {
throw new RuntimeException("NOT AUTHENTICATED USER");
}
}
// AccessToken을 생성하고 해당 토큰을 Cookie에 설정하는 메서드
// 생성된 AccessToken은 클라이언트에게 발급되어 Authorization 헤더로 전달됨
public String generateAccessToken(HttpServletResponse response, Member requestUser) {
String accessToken = jwtGenerator.generateAccessToken(ACCESS_SECRET_KEY, ACCESS_EXPIRATION, requestUser);
ResponseCookie cookie = setTokenToCookie(JwtRule.ACCESS_PREFIX.getValue(), accessToken, ACCESS_EXPIRATION / 1000);
response.addHeader(JwtRule.JWT_ISSUE_HEADER.getValue(), cookie.toString());
return accessToken;
}
// RefreshToken을 생성하고 해당 토큰을 Cookie에 설정하는 메서드
// RefreshToken은 데이터베이스에 저장되며, Redis를 사용하여 저장하는 예시임
@Transactional
public String generateRefreshToken(HttpServletResponse response, Member requestUser) {
String refreshToken = jwtGenerator.generateRefreshToken(REFRESH_SECRET_KEY, REFRESH_EXPIRATION, requestUser);
ResponseCookie cookie = setTokenToCookie(JwtRule.REFRESH_PREFIX.getValue(), refreshToken, REFRESH_EXPIRATION / 1000);
response.addHeader(JwtRule.JWT_ISSUE_HEADER.getValue(), cookie.toString());
Token token = Token.builder().email(requestUser.getEmail()).refreshToken(refreshToken).ttl(REFRESH_EXPIRATION / 1000).build();
tokenRepository.save(token);
return refreshToken;
}
// JWT 토큰을 Cookie에 설정하는 메서드
// HttpOnly 및 Secure 속성으로 보안을 강화하며, 토큰의 만료 시간도 설정됨
private ResponseCookie setTokenToCookie(String tokenPrefix, String token, long maxAgeSeconds) {
return ResponseCookie.from(tokenPrefix, token).path("/").maxAge(maxAgeSeconds).httpOnly(true).sameSite("Lax").secure(true).build();
}
// Authorization 헤더에서 AccessToken을 추출하는 메서드
// "Bearer "로 시작하는 헤더 값에서 실제 토큰 값을 추출함
public String resolveTokenFromHeader(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // "Bearer " 제거 후 토큰 반환
}
return null;
}
// Cookie에서 특정 토큰을 추출하는 메서드
// 주어진 tokenPrefix에 해당하는 토큰을 Cookie에서 찾음
public String resolveTokenFromCookie(HttpServletRequest request, JwtRule tokenPrefix) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
throw new RuntimeException("JWT TOKEN NOT FOUND");
}
return jwtUtil.resolveTokenFromCookie(cookies, tokenPrefix);
}
// AccessToken의 유효성을 검사하는 메서드
// JWT 유틸리티 클래스를 사용하여 토큰의 상태가 유효한지 확인함
public boolean validateAccessToken(String token) {
return jwtUtil.getTokenStatus(token, ACCESS_SECRET_KEY) == TokenStatus.AUTHENTICATED;
}
// RefreshToken의 유효성을 검사하는 메서드
// 데이터베이스에 저장된 토큰과 일치하는지 여부도 추가로 확인함
public boolean validateRefreshToken(String token, String email) {
Optional<Token> optionalStoredToken = tokenRepository.findByEmail(email);
if (!optionalStoredToken.isPresent()) {
return false;
}
Token storedToken = optionalStoredToken.get();
boolean isRefreshValid = jwtUtil.getTokenStatus(token, REFRESH_SECRET_KEY) == TokenStatus.AUTHENTICATED;
boolean isTokenMatched = storedToken.getRefreshToken().equals(token);
return isRefreshValid && isTokenMatched;
}
// JWT 토큰에서 인증 정보를 추출하여 Authentication 객체를 반환하는 메서드
// CustomUserDetailsService를 통해 사용자 정보를 로드함
public Authentication getAuthentication(String token) {
UserDetails principal = customUserDetailsService.loadUserByUsername(getUserPk(token, ACCESS_SECRET_KEY));
return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities());
}
// JWT 토큰에서 사용자 PK를 추출하는 메서드
// 토큰을 파싱하여 사용자 정보를 반환함
private String getUserPk(String token, Key secretKey) {
return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().getSubject();
}
// RefreshToken에서 이메일 정보를 추출하는 메서드
// RefreshToken이 유효하지 않을 경우 예외를 던짐
public String getEmailFromRefresh(String refreshToken) {
try {
return Jwts.parserBuilder().setSigningKey(REFRESH_SECRET_KEY).build().parseClaimsJws(refreshToken).getBody().getSubject();
} catch (Exception e) {
throw new RuntimeException("INVALID JWT");
}
}
// 로그아웃 처리 메서드
// 저장된 토큰을 삭제하고, AccessToken과 RefreshToken을 쿠키에서 초기화함
public void logout(Member requestUser, HttpServletResponse response) {
tokenRepository.deleteById(requestUser.getEmail());
Cookie accessCookie = jwtUtil.resetToken(JwtRule.ACCESS_PREFIX);
Cookie refreshCookie = jwtUtil.resetToken(JwtRule.REFRESH_PREFIX);
response.addCookie(accessCookie);
response.addCookie(refreshCookie);
}
}
public JwtService(CustomUserDetailsService customUserDetailsService, JwtGenerator jwtGenerator,
JwtUtil jwtUtil, TokenRepository tokenRepository,
@Value("${jwt.access-secret}") String ACCESS_SECRET_KEY,
@Value("${jwt.refresh-secret}") String REFRESH_SECRET_KEY,
@Value("${jwt.access-expiration}") long ACCESS_EXPIRATION,
@Value("${jwt.refresh-expiration}") long REFRESH_EXPIRATION) {
this.customUserDetailsService = customUserDetailsService;
this.jwtGenerator = jwtGenerator;
this.jwtUtil = jwtUtil;
this.tokenRepository = tokenRepository;
this.ACCESS_SECRET_KEY = jwtUtil.getSigningKey(ACCESS_SECRET_KEY);
this.REFRESH_SECRET_KEY = jwtUtil.getSigningKey(REFRESH_SECRET_KEY);
this.ACCESS_EXPIRATION = ACCESS_EXPIRATION;
this.REFRESH_EXPIRATION = REFRESH_EXPIRATION;
}
멤버 변수가 많은 만큼 생성자에 필요한 매개변수도 많다.
그 중 Access Toekn과 Refresh Token에서 사용할 secret key(시크릿 키)와 expiration(유효 기간)은 가능하면 숨기는 것이 좋기 때문에 yml파일에 값을 저장하고 @value 어노테이션을 통해 값을 불러오도록 했다.
application-security.yml
jwt:
access-secret: secretkey
refresh-secret: secretkeysecretkey
access-expiration: 600000 # 10 min
refresh-expiration: 10800000 # 3 hours
당연히 secret key 값은 원하는만큼 더 복잡하게 바꿔주는 게 좋고 시간 역시 시*분*초*1000(millisecond)값으로 계산해서 필요한 값을 넣으면 된다.
그리고 이렇게 따로 yml파일을 만들어 줄 경우 application.yml 파일에 아래와 같이 추가해줘야 정상적으로 불러오기 할 수 있다.
profiles:
include : security
// AccessToken을 생성하고 해당 토큰을 Cookie에 설정하는 메서드
// 생성된 AccessToken은 클라이언트에게 발급되어 Authorization 헤더로 전달됨
public String generateAccessToken(HttpServletResponse response, Member requestUser) {
String accessToken = jwtGenerator.generateAccessToken(ACCESS_SECRET_KEY, ACCESS_EXPIRATION, requestUser);
ResponseCookie cookie = setTokenToCookie(JwtRule.ACCESS_PREFIX.getValue(), accessToken, ACCESS_EXPIRATION / 1000);
response.addHeader(JwtRule.JWT_ISSUE_HEADER.getValue(), cookie.toString());
return accessToken;
}
// RefreshToken을 생성하고 해당 토큰을 Cookie에 설정하는 메서드
// RefreshToken은 email과 쌍으로 Redis에 저장
@Transactional
public String generateRefreshToken(HttpServletResponse response, Member requestUser) {
String refreshToken = jwtGenerator.generateRefreshToken(REFRESH_SECRET_KEY, REFRESH_EXPIRATION, requestUser);
ResponseCookie cookie = setTokenToCookie(JwtRule.REFRESH_PREFIX.getValue(), refreshToken, REFRESH_EXPIRATION / 1000);
response.addHeader(JwtRule.JWT_ISSUE_HEADER.getValue(), cookie.toString());
Token token = Token.builder().email(requestUser.getEmail()).refreshToken(refreshToken).ttl(REFRESH_EXPIRATION / 1000).build();
tokenRepository.save(token);
return refreshToken;
}
이전에 만들어 뒀던 JWTGenarator를 통해 Access Token과 Refresh Token을 생성하고 Cookie에 설정하는 메서드들이다.
Access Token과 Refresh Token 모두 JwtGenarator를 통해 JWT를 생성하고 쿠키에 저장하는 것 까지는 동일하다.
다만 Refresh Token의 경우 RTR 방식을 채용하고 있기 때문에 제일 최근에 발급된 Refresh Token을 저장해두어야 한다.
따라서 미리 만들어둔 tokenRepository를 통해 Redis에 Token 객체를 발급과 동시에 저장하도록 했다.
Cookie sameSite
쿠키는 설정할 떼 여러가지 속성을 정할 수 있는데 그 중 하나가 sameSite 속성이다.
sameSite 속성에는 None과 Strict, Lax 3개의 값을 넣을 수 있는데 따로 설정하지 않으면 Lax로 명시한 것과 동일하게 동작한다.
하나하나 알아보자면
None : 동일사이트 와 크로스 사이트 모두 쿠키 전송이 가능
Strict : 서로 다른 도메인에서는 아예 전송이 불가능. 따라서 CSRF(Cross-site request forgery)를 완전 방지할 수 있지만 사용자 편의성을 해침
Lax : Strict 설정에 일부 예외(HTTP GET method/a href/link href)를 두어 적용되는 설정
만약 sameSite 속성이 Strict 혹은 Lax인데 요청 도메인과 응답 도메인이 일치하지 않는다면 Cookie가 전달되지 않을 수 있다.
예를 들어 로컬에서는 http://localhost로 도메인이 동일(포트번호는 무관)해서 Lax 또는 Strict여도 상관 없지만 프론트와 백 모두 배포를 진행해 도메인이 다르게 설정된다면 Cookie가 제대로 전달되지 않는다.
서로 도메인이 맞지 않는 경우는 1)도메인을 동일하게 설정하거나 2)sameSite=None, secure=true로 설정해야 한다.
다만 2번의 방법은 CSRF에 무방비하므로 가능하면 1번 방법을 사용하는 것이 좋다.
// AccessToken의 유효성을 검사하는 메서드
// JWT 유틸리티 클래스를 사용하여 토큰의 상태가 유효한지 확인함
public boolean validateAccessToken(String token) {
return jwtUtil.getTokenStatus(token, ACCESS_SECRET_KEY) == TokenStatus.AUTHENTICATED;
}
// RefreshToken의 유효성을 검사하는 메서드
// 데이터베이스에 저장된 토큰과 일치하는지 여부도 추가로 확인함
public boolean validateRefreshToken(String token, String email) {
boolean isRefreshValid = jwtUtil.getTokenStatus(token, REFRESH_SECRET_KEY) == TokenStatus.AUTHENTICATED;
Optional<Token> optionalStoredToken = tokenRepository.findByEmail(email);
boolean isTokenMatched = optionalStoredToken.get().getRefreshToken().equals(token);
return isRefreshValid && isTokenMatched;
}
JWTUtil 클래스의 getTokenStatus() 메서드를 통해 전달받은 token이 유효한지 확인한다.
Refresh Token의 경우 RTR 기법을 사용하고 있으므로 tokenRepository를 통해 유효한 Refresh Token인지 확인하는 로직이 추가됐다.
// JWT 토큰에서 인증 정보를 추출하여 Authentication 객체를 반환하는 메서드
// CustomUserDetailsService를 통해 사용자 정보를 로드함
public Authentication getAuthentication(String token) {
UserDetails principal = customUserDetailsService.loadUserByUsername(getUserPk(token, ACCESS_SECRET_KEY));
return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities());
}
// JWT 토큰에서 사용자 PK를 추출하는 메서드
// 토큰을 파싱하여 사용자 정보를 반환함
private String getUserPk(String token, Key secretKey) {
return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().getSubject();
}
Filter Chain을 거치면서 SecurityHolder에 저장할 Authenticaion 객체를 생성한다.
우선 getUserPK()를 통해 전달받은 Access Token에서 사용자를 식별할 수 있는 데이터를 찾는다.
이후 위에서 구현한 CustomUserDetailsService의 loadUserByUsername()을 통해 UserDetails 타입의 객체를 반환받는다.
다음 Filter인 UsernamePasswordAuthenticationFilter에서 활용할 UsernamePasswordAuethenticationToken 객체를 생성하여 반환한다.