Kuma's Curious Paradise
[이룸] 240227 JwtUtil.java 본문
- 클래스명의 이유
- **JwtUtil** : jwt 토큰을 다루는 utility 클래스라는 의미
- **Util 클래스**란? 특정 기능을 수행하는 static 메서드 & static 필드만을 포함하는 클래스로, 여기저기서 많이 사용되는 메서드나 상수 값을 제공해서 코드 중복을 줄이고 유지 보수가 편리하도록 만듦.
- 클래스에 붙은 어노테이션
- **@Slf4j(topic = "JwtUtil")** : Logger 객체를 자동으로 생성하여 log.info() 와 같은 메서드를 쓸 수 있게 해 주는 lombok 라이브러리의 일부. topic 속성을 통해 로거의 이름을 지정할 수 있음.
- @Component: 해당 ‘클래스(!!!)’를 bean으로 관리하는 어노테이션.
[코드 설명]
// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String REFRESH_TOKEN_HEADER = "Refresh-token";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
public static final String BEARER_PREFIX = "Bearer ";
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 1시간
@Value("${jwt.secret.key}")
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 로그 설정
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
- secretKey 와 key : JWT를 암호화(secretKey)하고 서명(Key)하는 데 사용되는 비밀 키. yml 같은 외부 설정 파일에서 키 값을 주입받음. signatureAlgorithm : JWT의 서명 알고리즘. 토큰의 서명을 생성하고 검증하는 데 사용되며, 우리는 HMAC-SHA256 알고리즘 사용!
- public static final → 다른 클래스에서도 접근할 수 있는 변경 불가의 상수값. private final → 클래스 내에서만 사용되며 한번 초기화되면 변경되지 않는 final 값. private → 클래스 내에서만 사용되며 외부에서 접근할 필요 없는 값. @value는 런타임 때 동적으로 외부에서 값을 주입하며 @postconstruct는 객체 생성 이후 실행되기 때문에, 둘 다 compile 타임 때 메모리가 할당되는 static을 붙이지 않는 게 좋음.
- **@Value**란? 외부 설정 파일(applicaiton.properties, application.yml)에서 정의된 값을 코드 내 변수에 바인딩할 수 있음.
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
- **@PostCostruct** : 클래스의 인스턴스가 생성되고, 의존성이 주입된 후 자동으로 실행할 초기화 코드를 지정함. 초기화 메서드 호출할 필요 없이 인스턴스 생성되면 바로 여기에서 초기화됨. 그럼 이 jwtUtil 클래스에서는 어떤 것을, 어떻게 초기화할까?
- **init()** : Base64는 0과 1로 이루어진 바이너리 데이터를 텍스트 형태로 인코딩/디코딩 하는 방식 중 하나. 여기서는 secretKey를 원래의 바이트 배열로 변환하여 bytes에 저장 → 디코딩된 바이트 배열을 사용하여 보안 키 생성. 이 key는 jwt 서명에 사용됨. → 정리하면, jwtUtil 클래스의 객체가 생성되고 의존성 주입이 완료되면 이 메서드가 호출되어 key를 만듦.
// Access Token 생성
public String createAccessToken(String email, MemberRoleEnum role) {
Date now = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(email) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(now.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(now) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
// Refresh Token 생성
public String createRefreshToken(String email) {
Date now = new Date();
return Jwts.builder()
.setSubject(email) // 사용자 식별자값(ID)
.setExpiration(new Date(now.getTime() + (7 * 24 * TOKEN_TIME))) // 만료 시간
.setIssuedAt(now) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
- **createAccessToken(String email, MemberRoleEnum role);** : 사용자의 이메일과 역할(user, admin)을 받아서 access token을 생성하는 메서드. JWT 빌더를 사용. 여기서 BEARER_PREFIX를 붙여서 return 함.
- **JWT 빌더에 왜 이런 메서드를 썼을까?(JwtBuilder 인터페이스 로직 참조)** :
- setSubject(String subject): 토큰의 소유자 설정(누구한테 토큰을 발급하였는가). 우리는 사용자 이메일 주소를 식별자값으로 사용.
- claim(String key, Object value): 사용자 정의 클레임 설정. 클레임은 토큰에 포함될 추가 정보를 제공하는데, 여기서는 사용자 역할 정보를 제공함. 더 자세하게 권한 설정을 하고 싶다면 여기에다 넣으면 됨.
- setExpiration(Date expiration): 토큰 만료 시간. 발급하는 시간에서 토큰 타임을 더해서 지정.
- setIssuedAt(Date issuedAt): 토큰 발급 시간.
- signWith(SignatureAlgorithm alg, byte[] secretKey): 위에서 만든 key와 알고리즘이 여기서 쓰임. 내가 발급했다고 알려주는 흔적같은 것.
- compact(): 설정이 끝나면 이 메서드를 호출하여 JWT를 String 형태로 압축하여 생성.
//생성된 JWT를 Cookie에 저장
public void addJwtToCookie(String token, HttpServletResponse res, String tokenName) throws UnsupportedEncodingException {
token = URLEncoder.encode(tokeStandardCharsets.UTF_8).replaceAll("\\+", "%20");
Cookie cookie = new Cookie(tokenName, token);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setAttribute("SameSite", "None");
int maxAgeInSeconds = 3600; // 1시간
if (tokenName.equals(JwtUtil.AUTHORIZATION_HEADER)) {
cookie.setMaxAge(maxAgeInSeconds);
}
if (tokenName.equals(JwtUtil.REFRESH_TOKEN_HEADER)) {
cookie.setMaxAge(7 * 24 * maxAgeInSeconds);
}
res.addCookie(cookie);
}
- 생성된 JWT를 쿠키에 저장하는 메서드.
- **token = URLEncoder.encode(tokeStandardCharsets.UTF_8).replaceAll("\\\\+", "%20");** : JWT 토큰을 URL 인코딩. 여기서는 bearer 뒤에 들어간 공백이 문제를 일으키지 않도록 하기 위해 이 과정을 거침.
- Cookie cookie = new Cookie(tokenName, token); : 인자로 받는 tokenName은 AT인지, RT인지 구분하기 위해 사용함. access token 만들 때는 JwtUtil.AUTHORIZATION_HEADER 집어 넣어서 만듦.
- 쿠키 설정:
- cookie.setPath("/");: 쿠키가 어떤 곳에서만 유효한지 지정. /app 이라고 설정하면, 해당 경로에서만 현재 발급하는 쿠키를 사용할 수 있음. 지금 발급하는 쿠키는 언제 어디서든 쓰일 수 잇게 범위를 최대화하여 설정함.
- cookie.setHttpOnly(true);: httpOnly 설정이 true가 되면 클라이언트가 쿠키에 접근할 수 없음. 쿠키(를 가진 클라이언트)를 보호하기 위해 꼭 설정해줘야 함.
- cookie.setSecure(true);: 쿠키가 HTTPS를 통해서만 전송되도록 설정. HTTPS는 데이터를 암호화하여 전송하기 때문에, 중간에 공격이 들어와도 데이터 보호가 가능함. http끼리 통신할 때는 false로 두고 개발 진행할 것.
- cookie.setAttribute("SameSite", "None");: 쿠키의 SameSite 속성을 "None"으로 설정. 출처가 다른 사이트의 요청에서 쿠키가 전송되도록 함.
- 쿠키 만료 시간 설정:
- cookie.setMaxAge(maxAgeInSeconds); 또는 cookie.setMaxAge(7 * 24 * maxAgeInSeconds);
- access token은 1시간, refresh token은 1주일로 설정.
- res.addCookie(cookie); : 응답에 쿠키 추가
- URL 인코딩이란? URL 에서 사용될 수 없는 문자를 %뒤에 이어지는 두 자리 16진수로 변환하는 방식. 공백이나 특수문자를 안전하게 전송하기 위해서 사용되며 url의 쿼리 스트링에서 key와 value를 구분하는 = 혹은 & 같은 문자가 포함되어 있다면, 이를 정확히 해석하기 위해 인코딩이 필요.
- SameSite란? 쿠키의 사용 범위를 제한하기 위한 쿠키 옵션. 외부 aceess를 방지하는 데 쓰임. 크게 세 종류로 나뉨. **None** : 외부 도메인에서 우리 서버로 요청을 보낼 때 쿠키를 함께 전송하는 것을 허용함. secure true로 설정해야 Samesite None이 가능(안전한 HTTPS 연결을 통해서만 쿠키가 전송될 수 있도록 보장하기 위함). 이렇게 해야 서버와 도메인이 다른 프론트 측에서 서버로 쿠키를 보낼 수 있음. **Lax** : SameSite 설정을 따로 하지 않으면 지정되는 기본값. 일부 상황에서 쿠키가 접근되도록 허용하는데, 예를 들어 사용자가 링크를 클릭하여 페이지를 이동하는 경우 get 메서드 요청을 통해 쿠키가 전송되는 것을 허용함. 그러나 post처럼 상태 변경을 요하는 요청은 쿠키가 전송되지 않음. **Strict** : 자사 도메인으로만 쿠키 전송이 가능. 다시 말해, 현재 브라우저의 URL과 쿠키의 도메인이 일치하는 경우를 말함.
- 읽어 보기: https://seob.dev/posts/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EC%BF%A0%ED%82%A4%EC%99%80-SameSite-%EC%86%8D%EC%84%B1/
브라우저 쿠키와 SameSite 속성 / seob.dev
브라우저 쿠키에 대한 기본적인 내용들, 그리고 웹 개발자들에게 중요한 "SameSite" 속성을 다룹니다. "SameSite" 속성이 어떤 속성인지, 각 브라우저에서 어떻게 동작하고 있는지 알아봅니다.
seob.dev
// HttpServletRequest 에서 Cookie Value : JWT 가져오기
public String getTokenFromRequest(HttpServletRequest req, String tokenName) {
Cookie[] cookies = req.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(tokenName)) {
return URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8);
}
}
}
return null;
}
- HTTP 요청이 들어오면 거기서 해당 tokenName을 가진 쿠키를 뽑아오는 메서드.
- 우리는 access token과 refresh token, 두 개의 쿠키를 발급했기 때문에 request에서 쿠키를 뽑아내면서 쿠키 배열에 담음. → for문으로 하나하나 돌면서, 해당 tokenName과 일치하면 UTF_8(가장 널리 사용되는 인코딩 방식)을 사용하여 디코딩.
//Cookie에 들어있던 JWT 토큰을 Substring
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
logger.error("Not Found Token");
throw new NullPointerException("Not Found Token");
}
- 토큰이 들어오면 문자가 있는지 확인, bearer prefix로 시작하는지 또 확인한 후 앞의 7자를 잘라냄. 만약 해당 조건에 만족하지 못하면 NPE가 터짐.
// JWT 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
logger.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
- Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); : parseBuilder().build()로 JWT 파서 빌더를 생성 → 검증에 사용될 키(우리가 예전에 만든 그 키!)를 설정 → 이후 parseClaimsJws(token)에서 토큰을 파싱하고 검증. 우리가 넣은 서명이 제대로 있는지, 만료되지는 않았는지, jwt 토큰이 맞는지 등등 유효함을 확인. → 만약 토큰이 유효하다면 밑에 적은 어떤 예외도 발생하지 않고 true를 반환.
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
- 위와 같이 파싱을 수행한 후에, jwt 객체에서 페이로드 부분을 추출하여 반환.
- 이룸의 JWT는 어떻게 생겼을까?
jwt는 json web token의 줄임말로, 다음과 같은 정보를 담고 있는 json 객체를 안전하게 전송하는 토큰을 이야기함. jwt는 크게 세 부분으로 구성됨.
- Header 헤더 : 어떤 알고리즘을 사용했나요? (+ jwt 타입이 맞나요? 와 같은 내용이 추가될 수 있음)
- Payload 페이로드 : 사용자 정보와 메타 데이터 포함. 토큰 만들면서 지정한 친구들이 이곳에 담김!
- Signature 시그니처 / 서명 : 헤더와 페이로드를 조합 후 우리의 비밀 키를 사용하여 서명하여 생성됨. jwt.io에서 보이는 저 부분은 jwt 서명의 생성 과정을 보여주는 것으로, 이렇게 생성된 서명이 시그니처 부분에 추가됨.
{빨간색 부분: Base64EncodedHeader}.{보라색 부분: Base64EncodedPayload}.{파란색 부분: Signature}
'이룸 프로젝트' 카테고리의 다른 글
[이룸] 240302 JwtAuthorizationFilter.java (0) | 2024.03.07 |
---|---|
[이룸] 240229 JwtAuthenticationFilter.java (0) | 2024.03.07 |
[이룸] 240226 intellij / cannot resolve column 노란줄 (0) | 2024.03.07 |
[이룸] 240225 refresh token 관리에 redis 도입 (0) | 2024.03.07 |
[이룸] 240223 토큰 재발급 포스트맨 테스트 (0) | 2024.03.06 |