유저 로그인 인증 및 인가를 JWT로 하기로 한다.
다음의 요구사항들이 있다.
Filter를 이용해 구현해보자.
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
JwtProvider
@Slf4j
@Component
public class JwtProvider {
@Value("${jwt.secret-key}")
private String secretKey;
private static final long TOKEN_VALID_TIME = 24 * 60 * 60 * 1000L;
private static final long REFRESH_TOKEN_VALID_TIME = 30 * 24 * 60 * 60 * 1000L;
public String createToken(Member member) {
Claims claims = Jwts.claims();
claims.put("id", member.getId());
claims.put("username", member.getUsername());
Date now = new Date();
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + TOKEN_VALID_TIME))
.signWith(
Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)),
SignatureAlgorithm.HS256
)
.compact();
}
}
JwtProvider
@Slf4j
@Component
public class JwtProvider {
...
public boolean validateToken(String token) {
try {
return !Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)))
.build()
.parseClaimsJws(removeBearer(token))
.getBody()
.getExpiration().before(new Date());
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("토큰 검증 실패");
}
}
}
필드 | 설명 |
---|---|
method | http method |
pattern | 검증이 필요한 url 패턴 |
required | 검증 필수 여부 |
@Getter
@Builder
public class Resource {
private Long id;
private String method;
private String pattern;
private boolean required;
}
@Repository
public class MemoryResourceRepository {
private static final List<Resource> resources = new ArrayList<>();
public MemoryResourceRepository() {
resources.add(
Resource.builder()
.id(1L)
.method(HttpMethod.GET.name())
.pattern("/api/token/required")
.required(true)
.build()
);
resources.add(
Resource.builder()
.id(2L)
.method(HttpMethod.GET.name())
.pattern("/api/token/optional")
.required(false)
.build()
);
}
public List<Resource> findAll() {
return resources;
}
}
AntPathMatcher
클래스를 상속받아 커스텀CustomAntPathMatcher
@Component
public class CustomAntPathMatcher extends AntPathMatcher {
private final MemoryResourceRepository memoryResourceRepository;
private List<Resource> resources;
// 생성 시 리소스 전체 리스트를 조회하여 저장
public CustomAntPathMatcher(MemoryResourceRepository memoryResourceRepository) {
this.memoryResourceRepository = memoryResourceRepository;
this.resources = memoryResourceRepository.findAll();
}
// 리소스 변경이 일어나면 리소스 리스트 갱신
public void refresh() {
this.resources = memoryResourceRepository.findAll();
}
// 요청의 method, path를 리소스 리스트에 있는지 확인
public boolean match(String method, String path) {
return resources.stream()
.anyMatch(r -> r.getMethod().equals(method) && super.match(r.getPattern(), path));
}
// 토큰 검증 필수 인지 확인
public boolean required(String method, String path) {
return resources.stream()
.filter(r -> r.getMethod().equals(method) && super.match(r.getPattern(), path))
.findAny()
.orElseThrow()
.isRequired();
}
}
JwtContext 에 저장할 값
MemberInfo
@Getter @Builder @NoArgsConstructor @AllArgsConstructor @ToString public class MemberInfo { private Long id; private String username; }
JwtContext
public class JwtContext {
private static final ThreadLocal<MemberInfo> CONTEXT = new ThreadLocal<>();
// 데이터 조회
public static MemberInfo getMemberInfo() {
MemberInfo memberInfo = CONTEXT.get();
return memberInfo == null ? new MemberInfo() : memberInfo;
}
// 데이터 저장
public static void setMemberInfo(MemberInfo memberInfo) {
CONTEXT.set(memberInfo);
}
// 데이터 삭제(스프링은 쓰레드풀을 사용하므로 쓰레드 종료시 반드시 지워주는 작업이 필요)
public static void clear() {
CONTEXT.remove();
}
}
JwtContext
에 저장@Slf4j
@Component
@RequiredArgsConstructor
public class AuthFilter extends OncePerRequestFilter {
private final CustomAntPathMatcher customAntPathMatcher;
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
JwtContext.clear();
log.info("auth filter");
String requestURI = request.getRequestURI();
String method = request.getMethod();
if (customAntPathMatcher.match(method, requestURI)) { // 토큰 검증 필요한 uri 인지 확인
String token = request.getHeader("Authorization");
if (token != null) { // 헤더의 토큰 확인
if (jwtProvider.validateToken(token)) { // 토큰 검증
JwtContext.setMemberInfo(jwtProvider.getClaim(token)); // 토큰 payload 값을 쓰레드 변수에 저장
filterChain.doFilter(request, response);
}
} else { // 헤더에 토큰 없을 경우
if (!customAntPathMatcher.required(method, requestURI)){ // 토큰이 필수 인지 확인
JwtContext.setMemberInfo(new MemberInfo()); // 기본 객체 쓰레드 변수에 저장
filterChain.doFilter(request, response);
} else { // 토큰이 없고 토큰이 필수라면 에러
throw new RuntimeException("권한 없음");
}
}
} else { // 토큰 검증 필요 없다면 통과
filterChain.doFilter(request, response);
}
}
}
@Slf4j
@RestController
@RequestMapping("/api")
public class FilterTestController {
@GetMapping("/token/none")
public String none() {
log.info("token none");
return "success";
}
}
GET http://localhost:8080/api/token/none
@Slf4j
@RestController
@RequestMapping("/api")
public class FilterTestController {
...
@GetMapping("/token/required")
public String required() {
log.info("token payload : {}", JwtContext.getMemberInfo());
return "success";
}
}
GET http://localhost:8080/api/token/required
GET http://localhost:8080/api/token/required
@Slf4j
@RestController
@RequestMapping("/api")
public class FilterTestController {
...
@GetMapping("/token/optional")
public String optional() {
log.info("token payload : {}", JwtContext.getMemberInfo());
return "success";
}
}
GET http://localhost:8080/api/token/optional
GET http://localhost:8080/api/token/optional