버전
- SpringBoot 3.3.0
- Java 17
- JWT 0.12.5
목표
- SpringSecurity + JWT 적용하기
- 토큰을 통해 인가된 사용자 구분하기
- Deprecated 된 함수 사용하지 않기
구현
의존성 추가
`build.gradle`
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
//JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
Security와 JWT의 의존성 추가부터 진행해 준다. 필자와 같은 버전을 사용 중이라면 위의 코드를 복사하여 사용해도 무관하지만 다른 버전이 필요할 경우 아래 사이트에서 검색하여 맞는 버전으로 설정해 준다.
JWT의 경우 jjwt로 검색하여 api, impl, jackson 3개를 추가해야 한다.
Token Info
본 포스팅에서는 로그인 시 위 사진처럼 토큰과 GrantType을 명시해 줄 예정이다. refreshToken은 생략한다.
data안에 들어갈 grantType과 accessToken을 가지고 있는 TokenInfo를 먼저 만들어보자
공통으로 사용하는 common 패키지 안에 authority 패키지를 추가하였다.
`TokenInfo`
package com.study.springStarter.common.authority;
import lombok.Data;
@Data
public class TokenInfo {
private String grantType;
private String accessToken;
public TokenInfo(String grantType, String accessToken) {
this.grantType = grantType;
this.accessToken = accessToken;
}
}
JwtTokenProvider
토큰을 생성하고 검증하며 토큰 안에 정보를 추출하는 JwtTokenProvider를 구현한다.
private final String secretKey;
private static final long EXPIRATION_TIME = 1000 * 60 * 60 * 24; // 1일
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
this.secretKey = secretKey;
}
secretKey 변수를 선언하고 생성자에서 @Value를 통해 아까 application.yml에 작성해 둔 키값을 대입해 주었다.
EXPIRATION_TIME은 토큰의 만료시간이다.
토큰 생성
/**
* JWT 토큰 생성
*/
public TokenInfo createToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
Date now = new Date();
Date accessExpiration = new Date(now.getTime() + EXPIRATION_TIME);
String jwt = Jwts.builder()
.subject(authentication.getName())
.claim("auth", authorities)
.claim("userId", ((CustomUser) authentication.getPrincipal()).getUserId())
.issuedAt(now)
.expiration(accessExpiration)
.signWith(getKey(), Jwts.SIG.HS256)
.compact();
return new TokenInfo("Bearer", jwt);
}
public SecretKey getKey() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
}
`authorities`에 권한들을 ', '로 구분하여 String 타입으로 담아 주었다.
토큰의 만료시간을 현재 시간부터 1일 뒤까지로 설정하였다.
claim에 권한과 사용자의 id를 담았다.
`. signWith`는 기존의 사용하던 함수들은 전부 Deprecated 되었다.
`. signWith(SignatureAlgorithm.HS512,"KEY")` 같은 방법을 사용할 수 없는 것이다.
1.0 버전에서는 사라질 예정이라고 하니 위처럼 `Jwts.SIG.HS256` 등으로 암호화 알고리즘을 설정하고 SecretKey를 매개변수로 받는다.
토큰 정보 추출
private Claims getClaims(String jwt) {
return Jwts.parser()
.verifyWith(getKey())
.build()
.parseSignedClaims(jwt)
.getPayload();
}
/**
* JWT 토큰 정보 추출
*/
public Authentication getAuthentication(String jwt) {
Claims claims = getClaims(jwt);
String auth = Optional.ofNullable(claims.get("auth", String.class))
.orElseThrow(() -> new RuntimeException("잘못된 토큰입니다."));
Long userId = Optional.ofNullable(claims.get("userId", Long.class))
.orElseThrow(() -> new RuntimeException("잘못된 토큰입니다."));
Collection<GrantedAuthority> authorities = Arrays.stream(auth.split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new CustomUser(userId, claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
토큰에서 정보를 추출하는 함수이다.
이후 필터에서 토큰을 이용해 인증정보를 설정한다.
UserDetails에 넣는 `CustomUser` 클래스를 정의해야 한다.
package com.study.springStarter.common.authority;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import java.util.Collection;
public class CustomUser extends User {
private Long userId;
private String userName;
private String password;
private Collection<GrantedAuthority> authorities;
public CustomUser(Long userId, String username, String password, Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
this.userId = userId;
}
public Long getUserId() {
return userId;
}
}
User를 상속받고 필요한 userId 값을 추가해 주었다.
토큰 검증
마지막으로 토큰 검증하는 코드를 추가해 준다. response를 통일해 주기 위해 아래처럼 예외를 던저주 었다.
/**
* 토큰 검증
*/
public boolean validateToken(String token) {
try {
getClaims(token);
return true;
} catch (Exception e) {
if (e instanceof SecurityException) {
log.debug("[SecurityException] 잘못된 토큰");
throw new JwtException("[SecurityException] 잘못된 토큰입니다.");
} else if (e instanceof MalformedJwtException) {
log.debug("[MalformedJwtException] 잘못된 토큰");
throw new JwtException("[MalformedJwtException] 잘못된 토큰입니다.");
} else if (e instanceof ExpiredJwtException) {
log.debug("[ExpiredJwtException] 토큰 만료");
throw new JwtException("[ExpiredJwtException] 토큰 만료");
} else if (e instanceof UnsupportedJwtException) {
log.debug("[UnsupportedJwtException] 잘못된 형식의 토큰");
throw new JwtException("[UnsupportedJwtException] 잘못된 형식의 토큰");
} else if (e instanceof IllegalArgumentException) {
log.debug("[IllegalArgumentException]");
throw new JwtException("[IllegalArgumentException]");
} else {
log.debug("[토큰검증 오류]" + e.getClass());
throw new JwtException("[토큰검증 오류] 미처리 토큰 오류");
}
}
}
전체코드
`JwtTokenProvider`의 전체 코드이다.
package com.study.springStarter.common.authority;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Component
public class JwtTokenProvider {
private final String secretKey;
private static final long EXPIRATION_TIME = 1000 * 60 * 60 * 24; // 1일
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
this.secretKey = secretKey;
}
public SecretKey getKey() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
}
/**
* JWT 토큰 생성
*/
public TokenInfo createToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
Date now = new Date();
Date accessExpiration = new Date(now.getTime() + EXPIRATION_TIME);
String jwt = Jwts.builder()
.subject(authentication.getName())
.claim("auth", authorities)
.claim("userId", ((CustomUser) authentication.getPrincipal()).getUserId())
.issuedAt(now)
.expiration(accessExpiration)
.signWith(getKey(), Jwts.SIG.HS256)
.signWith(SignatureAlgorithm.HS512, "123123")
.compact();
return new TokenInfo("Bearer", jwt);
}
/**
* JWT 토큰 정보 추출
*/
public Authentication getAuthentication(String jwt) {
Claims claims = getClaims(jwt);
String auth = Optional.ofNullable(claims.get("auth", String.class))
.orElseThrow(() -> new RuntimeException("잘못된 토큰입니다."));
Long userId = Optional.ofNullable(claims.get("userId", Long.class))
.orElseThrow(() -> new RuntimeException("잘못된 토큰입니다."));
Collection<GrantedAuthority> authorities = Arrays.stream(auth.split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new CustomUser(userId, claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
/**
* 토큰 검증
*/
public boolean validateToken(String token) {
try {
getClaims(token);
return true;
} catch (Exception e) {
if (e instanceof SecurityException) {
log.debug("[SecurityException] 잘못된 토큰");
throw new JwtException("[SecurityException] 잘못된 토큰입니다.");
} else if (e instanceof MalformedJwtException) {
log.debug("[MalformedJwtException] 잘못된 토큰");
throw new JwtException("[MalformedJwtException] 잘못된 토큰입니다.");
} else if (e instanceof ExpiredJwtException) {
log.debug("[ExpiredJwtException] 토큰 만료");
throw new JwtException("[ExpiredJwtException] 토큰 만료");
} else if (e instanceof UnsupportedJwtException) {
log.debug("[UnsupportedJwtException] 잘못된 형식의 토큰");
throw new JwtException("[UnsupportedJwtException] 잘못된 형식의 토큰");
} else if (e instanceof IllegalArgumentException) {
log.debug("[IllegalArgumentException]");
throw new JwtException("[IllegalArgumentException]");
} else {
log.debug("[토큰검증 오류]" + e.getClass());
throw new JwtException("[토큰검증 오류] 미처리 토큰 오류");
}
}
}
private Claims getClaims(String jwt) {
return Jwts.parser()
.verifyWith(getKey())
.build()
.parseSignedClaims(jwt)
.getPayload();
}
}
JwtAuthenticationFilter
이제 검증에 사용할 필터를 작성해 보자.
package com.study.springStarter.common.authority;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.study.springStarter.common.response.BaseResponse;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
String token = resolveToken((HttpServletRequest) request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
} catch (JwtException | IllegalArgumentException e) {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setStatus(HttpServletResponse.SC_OK);
httpServletResponse.setContentType("application/json;charset=UTF-8");
Map<String, String> errors = new HashMap<>();
errors.put("token", e.getMessage());
BaseResponse<?> errorResponse = BaseResponse.error(HttpStatus.UNAUTHORIZED, errors);
httpServletResponse.getWriter().write(new ObjectMapper().writeValueAsString(errorResponse));
}
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
}
return null;
}
@Override
public void destroy() {
super.destroy();
}
}
GenericFilter를 상속받는 필터 클래스를 생성한다.
resolveToken()을 통해 해더에서 토큰을 찾고 이전에 작성한 JwtTokenProvider로 토큰의 유효성을 검사한다. 그 후 그 토큰의 정보를 가져와 SecurityContextHolder에 기록하여 사용하게 된다.
토큰이 유효하지 않을 경우 JwtTokenProvider에서 던졌던 예외들을 받게 된다.
이전 포스팅에서 만든 BaseResponse를 통해 예외값을 반환해 주기 위해 위처럼 직접 HttpServletResponse를 만져줘야 한다.
참고
본 포스팅은 아래 강의를 참고하여 작성하였습니다.
[지금 무료] [초급] 찍어먹자! 코틀린과 Spring Security + JWT로 회원가입 만들기 강의 | 김대디 - 인프
김대디 | Spring Security와 JWT 실습을 통해 권한 관리를 쉽고 간단하게 찍어먹어 보세요., 떠오르는 백엔드 강자 코프링, 회원가입 & 권한 관리 실습으로 확실하게! Kotlin + Spring Boot찍먹하며 배우는
www.inflearn.com