24.08.28 - JWT in Spring
오늘 배운 것
- JWT in Spring
개요
JWT를 사용해 Spring에서 로그인을 구현하려고 한다.
여기에서 사용하는 것들이 이후에 스프링 시큐리티를 사용해 로그인을 구현할 때에도 사용이 된다.
회원 가입은 입력 받은 Id, Password를 DB에 저장하는 것이다.
로그인은 입력 받은 Id와 Password가 DB와 같은지 비교하는 것이다.
JWT나 세션은 결국 아래와 같은 일을 하는 것이였다.
- 인증 (사용자가 로그인한 사용자인지 아닌지 판단)
- 인가 (사용자가 필요한 권한 가진 사용자인지 아닌지 판단)
이 인증과 인가가 애플리케이션의 보안에서 중요한 부분이라고 한다.
그저 입력 받은 입력 받은 Id와 Password가 DB와 같은지 비교하는 것이 전부가 아니였다.
그래서 로그인을 내가 직접 구현하던 스프링 시큐리티를 사용하던 JWT를 사용하는 부분은 공통적인 것이다.
그래서 JWT를 Spring에서 사용하는 부분을 살펴보자.
이 JWT를 사용하는데 필요한 메서드들은 JwtUtil이라는 클래스에 몰아두고 사용할 것이다.
JWT 설정
JWT를 사용하려면 dependency를 추가해줘야 한다.
// JWT
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
그리고 이전 글에서 말했던 JWT secret key가 필요하다.
2024.08.28 - [TIL] - 24.08.27 TIL - JWT
24.08.27 TIL - JWT
오늘 배운 것JWT JWT란?JWT는 Json Web Token의 약자다. JSON 포맷을 이용해 사용자에 대한 속성 저장하는 Web Token이라는 의미다. Token은 동전이라는 의미인데 Web Cookie처럼 정보를 담고 있는 것이다.
dev-gongmyoung.tistory.com
JWT secret key는 특정 기관에서 발급 받는 것이 아니라 개발자가 직접 생성해야 한다고 한다.
이 secret key는 서버 측에서 생성되고 관리되어야 하며 외부에 유출되어서는 안된다고 한다.
secret key는 충분히 복잡하고 예측 불가능하게 만들어야 한다.
최소 256비트 이상의 키를 사용하는 것이 권장된다고 한다.
JWT 생성해주는 사이트도 있으니 거기서 생성해도 될 것 같다.
또는 리눅스 or Git Bash에서 아래 명령어로도 생성할 수 있다고 한다.
openssl rand -hex 64
이제 이렇게 생성한 JWT secret key를 application.properties에 넣으면 된다.
하지만 application.properties에 바로 넣기보다는 properties 파일 분리를 통해 넣고.gitignore에 properties 파일을 추가해 GitHub에 올리는 일이 없도록 하자.
2024.08.03 - [TIL] - 24.08.02 TIL - application.properties 분리
24.08.02 TIL - application.properties 분리
오늘 배운 것application.properties 분리 appcliation.properties란?Spring Framework 프로젝트에서 사용되는 설정 파일이다. 이 파일을 통해 애플리케이션의 다양한 설정을 정의할 수 있다. 데이터베이스 연
dev-gongmyoung.tistory.com
그리고 그냥 JWT secret key를 문자열로 넣는 것보다 더 안전성을 높이기 위해 Base64로 인코딩한 후 넣는 것이 좋다.
아래에서는 JWT secret key를 Base64로 디코딩해서 사용할 것이다.
JWT 기본 값 설정
아래는 JWT 관련 필요한 값이나 필드를 설정해둔 것이다.
@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 ";
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@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);
}
}
AUTHORIZATION_HEADER는 쿠키가 여러 개 있을 수 있는데 그 중 JWT를 넣은 쿠키를 지칭하기 위한 상수다.
AUTHORIZATION_KEY는 JWT의 claims에 넣을 key-value에서 key이고 value는 사용자 권한을 넣을 것이다.
JWT에는 사용자를 구분하는데 필요한 것 (ex: id, username, nickname)과 사용자 권한 (USER, ADMIN)을 주로 넣는다고 한다.
그리고 BEARER_PREFIX는 JWT라고 알려주는 규칙으로 JWT 토큰 앞에 'Bearer '이 붙어있어야 한다.
그리고 토큰 만료 시간를 지정하고 secret key를 properties 파일로부터 @Value로 가져오는 것이다.
그리고 Key 객체는 secret key를 담는 객체이다.
@PostConstruct로 생성자 호출 후 secret key를 Base64로 디코딩한 후 Key 객체에 넣는 것이다.
그리고 SignatureAlgorithm은 JWT에서 사용할 암호화 알고리즘을 지정하는 것이다.
많은 암호화 알고리즘이 있어서 골라서 사용하면 된다.
JWT 생성
이제 위처럼 설정한 값들을 가지고 JWT 토큰을 생성하면 된다.
아래 메서드는 JWT 토큰을 생성하는 메서드이다.
// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, role) // 사용자 권한
.setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
위에서도 말했듯이 JWT에는 사용자를 구분하는 값 = username과 사용자 권한 = role을 자주 넣는다고 했다.
그래서 2개를 받아와서 JWT 토큰 만드는 것이다.
Jwts.builder()를 통해서 만드는 것이다.
사용자를 식별할 값을 setSubject()를 통해서 subject에 넣어주면 된다.
그리고 claim()을 통해 위에서 지정한 key와 함께 role을 key-value로 넣은 것이다.
추후에 권한 정보 가져올 때 key 값으로 claim에서 꺼내서 사용하는 것이다.
그리고 필요한 설정들을 상황에 맞게 추가하면 된다.
만료시간, 발급일, 암호화 알고리즘을 설정하고 compact()로 JWT를 생성한 것이다.
그후에 위에서 지정한 'Bearer '를 붙여줘서 JWT를 완성한 것이다.
JWT cookie에 저장
이제 만든 JWT를 클라이언트에게 보내줘야 한다.
JWT를 클라이언트에게 반환하는데 방법이 2가지가 있다.
- Response Header에 넣어서 보내는 방법
- 작성해야 하는 코드가 줄어듬
- 클라이언트가 보관하고 있다가 요청마다 넣어서 요청 보내야 함
- 쿠키에 JWT 클라이언트가 넣어서 요청마다 보내야 함
- 쿠키에 JWT 담아 Response에 쿠키 담아 보내는 방법
- 쿠키의 만료 기한 등 다른 옵션 추가할 수 있음
- 자동으로 쿠키가 클라이언트에게 저장되어 요청마다 JWT 넘어옴
- 코드가 좀 더 복잡해짐
이런 장단점들이 있다.
하지만 여기서는 서버에서 쿠키에 JWT를 담아 보내는 방법을 보자.
// JWT Cookie 에 저장
public void addJwtToCookie(String token, HttpServletResponse res) {
try {
token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
cookie.setPath("/");
//-----------------------------------------------------------------------------
// SameSite=None 설정 및 Secure 설정
cookie.setSecure(true); // HTTPS 환경에서만 쿠키 전송
cookie.setHttpOnly(true); // 자바스크립트에서 쿠키 접근 차단
cookie.setMaxAge((int) TOKEN_TIME / 1000); // 쿠키의 만료 시간 설정 (초 단위)
// SameSite 설정은 HttpServletResponse에 직접 설정해야 함
cookie.setAttribute("SameSite", "None"); // SameSite=None 설정
//-----------------------------------------------------------------------------
// Response 객체에 Cookie 추가
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
logger.error(e.getMessage());
}
}
코드를 살펴보자.
우선 URLEncoder를 통해서 JWT 값에 공백을 인코딩하는 것이다.
Cookie의 value에는 공백이 들어갈 수 없기 때문이다.
그리고 Cookie 객체를 생성해서 key에 위에서 설정한 AUTHORIZATION_HEADER를 넣고 JWT 값을 value로 넣은 것이다.
그 후 Cookie에 필요한 설정들을 넣고 Response에 addCookie() 메서드로 Cookie 객체를 넣으면 된다.
---------- 부분은 쿠키에 설정을 해주는 부분이다.
프론트 서버와 백엔드 서버를 사용해서 웹 애플리케이션을 구현하면 도메인 이름이 다른 경우가 있다.
이때 쿠키가 백엔드 서버에서 프론트 서버로 넘어가지 않는 일들이 생긴다.
SameOrigin이 어쩌고 저쩌고 때문에 발생한 일인데 이런 일을 방지하기 위한 설정이다.
꽤 어려운 내용인데 나도 아직 이해를 잘 하지 못해 좀 더 공부하고 블로그에 작성해보겠다.
궁금하다면 우선 아래 블로그의 글을 읽어보자.
https://ilikezzi.tistory.com/70
[SeoulSync82] HTTPS와 SameSite로 해결한 서로 다른 도메인의 Cookie 전송 이슈
오늘은 이전에 만들었던 소셜로그인에 이슈가 생겼는데 2주정도 머리 쥐어뜯다가 해결을 해서 관련 내용을 포스팅 해보려고 한다. https://ilikezzi.tistory.com/65 [Nest.JS] 구글, 네이버, 카카오 소셜로
ilikezzi.tistory.com
JWT 파싱
JWT에서 정보를 가져오려면 순수한 JWT 값만 얻어와야 한다.
위에서 JWT 값에 'Bearer '를 붙여서 쿠키에 넣은 것을 기억할 것이다.
그래서 이 'Bearer '를 제거해주는 작업을 해야 한다.
subString 메서드를 사용해 제거해주는 것이다.
// 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");
}
JWT 검증
이제 순수한 JWT를 가지고 JWT를 검증해야 한다.
JWT가 위변조 되지 않았는지 우리가 가지고 있는 JWT secret key를 가지고 검증하는 것이다.
JWT의 장점인 secret key 없이는 위변조 할 수 없다는 점이 여기에서 드러나는 것 같다.
아래 메서드를 바로 사용하면 될 것 같다.
Jwts.parserBuilder().setSigningKey(Key객체).build().parseClaimsJws(JWT값);
위에서도 봤듯이 Key 객체는 secret key를 담고 있는 객체이다.
그리고 예외에 대해서 세세하게 처리해 놓은 것이다.
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException 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;
}
JWT에서 사용자 정보 가져오기
이제 JWT가 변조되지 않았음을 확인했으면 JWT에서 필요한 정보들을 꺼내오면 된다.
정보를 꺼내올 때에도 secret key가 필요하다.
아래의 코드를 사용하면 Claims 객체를 반환하는 것이다.
getBody 메서드로 Claims를 가져오는 것이다.
Claims 안에 위에서 넣은 username, role이 key-value로 들어가 있는 것이다.
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
username은 setSubject()로 넣었기 때문에 claims.getSubject()로 받아올 수 있다.
role은 key-value로 넣었기 때문에 claims.get(key)로 받아올 수 있다.
아래는 정보를 꺼내서 사용하는 예시 코드이다.
// 토큰에서 사용자 정보 가져오기
Claims info = jwtUtil.getUserInfoFromToken(token);
// 사용자 username
String username = info.getSubject();
System.out.println("username = " + username);
// 사용자 권한
String authority = (String) info.get(JwtUtil.AUTHORIZATION_KEY);
System.out.println("authority = " + authority);