JWT는 토큰 기반의 인증이다. 즉, 클라이언트는 인증이 필요한 API를 사용할 때 토큰을 함께 보낸다. 발급받은 JWT를 이용해 인증을 하려면 HTTP 요청 헤더 중에 Authorization 키값에 Bearer + JWT 토큰값을 넣어서 보내야한다.
JWT 구조
JWT는 .을 기준으로 헤더.내용.서명으로 이루어져 있다.
헤더
헤더에는 토큰의 타입과 해싱 알고리즘을 지정하는 정보를 담는다.
{
"typ": "JWT",
"alg": "HS256"
}
내용
내용에는 토큰과 관련된 정보를 담는다. 내용의 한 덩어리를 claim이라고 부르며, 클레임은 키값의 한 쌍으로 이루어져 있다. 그리고 클레임은 등록된 클레임, 공개 클레임, 비공개 클레임으로 나눌 수 있다.
- 등록된 클레임은 토큰에 대한 정보를 담는데 사용한다.
이름 | 설명 |
iss | issuer(토큰 발급자) |
sub | subject(토큰 제목) |
aud | audience(토큰 대상자) |
exp | expiration(토큰의 만료 시간) |
nbf | Not Before(토큰의 활성 날짜) |
iat | issued at(토큰이 발급된 시간) |
jti | JWT의 고유 식별자. 주로 일회용 토큰에 사용됨 |
- 공개 클레임은 공개되어도 상관없는 클레임을 의미한다. 충돌을 방지할 수 있는 이름을 가져야하며, 보통 클레임 이름을 URI로 짓는다.
- 비공개 클레임은 공개되면 안되는 클레임을 의미하며, 클라이언트와 서버간의 통신에 사용된다.
서명
서명은 해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 사용하며, 헤더의 인코딩 값과 내용의 인코딩 값을 합친 후에 주어진 비밀키를 사용해 해시값을 생성한다.
토큰의 유효기간
리프레시 토큰을 이용해서 문제를 해결한다.
JWT 서비스 구현
실제로 JWT를 생성하고, 검증하는 서비스를 구현해보자. 의존성과 토큰 제공자를 추가하고(1), 리프레시 토큰 도메인과 토큰 필터를 구현하면 JWT 서비스를 사용할 준비가 된다.(2)
1. 의존성 추가
implementation 'io.jsonwebtoken:jjwt-api:0.11.5' // 자바 JWT 라이브러리
implementation 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.0' // XML 문서와 Java 객체 간 매핑을 자동화
2. 토큰 제공자 추가
2.1 JWT 토큰을 만들려면 우선 issuer(이슈 발급자)와 secret_key(비밀키)를 application 설정 파일에 추가해준다.
jwt:
issuer: test@email.com
secret_key: study-springboot
2.2 해당 값들을 변수로 접근하는데 사용할 JwtProperties 클래스를 만든다.
@Getter @Setter
@Component
@ConfigurationProperties("jwt") // 자바 클래스에 프로퍼티 값을 가져와서 사용하는 애노테이션
public class JwtProperties { // 설정 파일에 선언된 jwt 토큰 관련 값들을 변수로 접근하는데 사용할 클래스
private String issuer;
private String secretKey;
}
2.3 토큰을 생성하는 메서드를 만든다. 인자는 만료 시간, 유저 정보를 받는다. 이 메서드는 set 계열의 메서드를 통해 여러 값을 지정한다.
- setHeaderParam : 헤더 타입을 JWT로
- claim : 클레임 id를 user id로 설정
- signWith : 비밀값을 이용해 HS256
HS256: HMAC(대칭키) with SHA-256, RS256: RSA(비대칭형) Signature with SHA-256
[JWT] 서명을 위한 알고리즘 HS256과 RS256
JWT를 정리하다보니 JWT에 주로 사용되는 암호화 알고리즘 HS256과 RS256에 대해 언급이 필요할 것 같아 정리를 한다. 암호화 알고리즘 내 자세하고 복잡한 암호화 로직이 있지만 그 부분은 다음,, 언
erjuer.tistory.com
public String generateToken(User user, Duration expiredAt) {
Date now = new Date();
return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
}
// JWT 토큰 생성 메서드
private String makeToken(Date expire, User user) {
Date now = new Date();
SecretKey secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer(jwtProperties.getIssuer())
.setIssuedAt(now)
.setExpiration(expire)
.setSubject(user.getEmail())
.claim("id", user.getId())
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
2.4 올바른 토큰인지 유효성 검사를 한다. 프로퍼티즈 파일에 선언한 비밀값과 함께 토큰 복호화를 진행한다.
// JWT 토큰 유효성 검증 메서드
public boolean validToken(String token) {
try {
SecretKey secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));
Jwts.parserBuilder()
.setSigningKey(secretKey) // 비밀값으로 복호화
.build()
.parseClaimsJws(token);
return true;
} catch (Exception e) { // 복호화 과정에서 에러가 나면 유효하지 않은 토큰이다.
return false;
}
}
2.5 토큰에서 필요한 정보를 가져오는 메서드를 만든다.
- 토큰을 받아 인증 정보를 담은 객체 Authentication을 반환한다. 이때, getSubject에는 사용자 이메일이 들어가있다.
- 토큰 기반으로 사용자 ID를 가져온다. 클레임 정보에서 id 키로 저장된 값을 가져와 반환해준다.
// 토큰 기반으로 인증 정보를 가져오는 메서드
public Authentication getAuthentication(String token) {
Claims claims = getClaims(token);
Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
return new UsernamePasswordAuthenticationToken(new org.springframework.security.core.userdetails.User(claims.getSubject(), "", authorities), token, authorities);
}
// 토큰 기반으로 유저 ID를 가져오는 메서드
public Long getUserId(String token) {
Claims claims = getClaims(token);
return claims.get("id", Long.class);
}
private Claims getClaims(String token) {
SecretKey secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims;
}
3. RefreshToken 도메인 구현
RefreshToken은 데이터베이스에 저장하는 정보이므로 엔티티와 리포지토리를 추가해야 한다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class RefreshToken {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", updatable = false)
private Long id;
@Column(name = "user_id", nullable = false, unique = true)
private Long userId;
@Column(name = "refresh_token", nullable = false)
private String refreshToken;
public RefreshToken(Long userId, String refreshToken) {
this.userId = userId;
this.refreshToken = refreshToken;
}
public RefreshToken update(String newRefreshToken) {
this.refreshToken = newRefreshToken;
return this;
}
}
4. 토큰 필터 구현
그리고 토큰 필터를 만들어야 한다. 필터는 전달되기 전후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공한다. 요청이 오면 헤더값을 비교해서 토큰이 있는지 확인하고, 유효한 토큰이라면 security context holder에 인증 정보를 저장한다.
즉, security context는 인증 객체가 저장되는 보관소로 여기서 인증 정보가 필요할때 언제든지 인증 객체를 꺼내서 사용할 수 있다. 또한 Thread Local에 저장된다. 그리고 이러한 security context 객체를 저장하는 객체가 security context holder 이다.
@RequiredArgsConstructor
@Slf4j
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final static String HEADER_AUTHORIZATION = "Authorization"; // 헤더 정보
private final static String TOKEN_PREFIX = "Bearer"; // 토큰 prefix
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 요청 헤더의 Authorization 키의 값을 조회한다.
String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
log.info("authorizationHeader = {}", authorizationHeader);
// 가져온 값에서 prefix를 제거한다.
String accessToken = getAccessToken(authorizationHeader);
// 가져온 토큰이 유효한지 확인하고, 유효하면 인증 정보를 관리하는 security context에 인증 정보를 설정한다.
if (tokenProvider.validToken(accessToken)) {
Authentication authentication = tokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getAccessToken(String authorizationHeader) {
try {
if (!authorizationHeader.isEmpty() && authorizationHeader.startsWith(TOKEN_PREFIX)) {
return authorizationHeader.substring(TOKEN_PREFIX.length());
}
} catch (NullPointerException e) {
return null;
}
finally {
return null;
}
}
}
참고사항: Deprecated된 메서드에 관하여
결론적으로 말하면 내가 사용했을 때는 Jwts의 parse() 메서드와, signWith() 메서드가 deprecated 되었었다. 그 이유는 다음과 같은데
This method has been deprecated because the key argument for this method can be confusing: keys for cryptographic operations are always binary (byte arrays), and many people were confused as to how bytes were obtained from the String argument. This method always expected a String argument that was effectively the same as the result of the following (pseudocode): String base64EncodedSecretKey = base64Encode(secretKeyBytes); However, a non-trivial number of JJWT users were confused by the method signature and attempted to use raw password strings as the key argument - for example setSigningKey(myPassword) - which is almost always incorrect for cryptographic hashes and can produce erroneous or insecure results. |
내가 시도했을 때도 그렇고, 에러 공식문서를 봐도 설정된 문자열의 바이트 길이를 체크하는 것으로보아 이유는 보안때문인 것 같다.
그래서 parse() 메서드는 parseBuilder() 메서드를 사용하고, signWith() 메서드에는 String 파라미터가 아닌 Key 인스턴스를 생성하는 방안 등을 각자 상황에 맞춰서 사용하면 되겠다.
'Tech > JWT' 카테고리의 다른 글
JWT를 조금 더 안전하게 저장하기 & 쿠키와 웹 스토리지 (1) | 2023.06.30 |
---|