
프로젝트에서 JWT 기반 인증 인가 구현을 담당하게 되었다. 시간이 충분하지 않아 스프링 시큐리티와 Refresh 토큰 없이, Access 토큰만 가지고 구현하였다.
웹상의 대부분의 자료가 스프링 시큐리티 + Refresh 토큰을 기반으로 하고 있어, 이번처럼 순수 JWT만 사용하는 구조로 직접 구현한 내용을 정리해두려 한다.
| 파트 | 내용 | 설명 |
|---|---|---|
| Header | xxxxx | 서명 알고리즘, 타입 등 |
| Payload | yyyyy | 유저 정보, 권한 같은 내용 (Base64로 인코딩) |
| Signature | zzzzz | Header + Payload를 비밀키로 서명 (변조 방지) |
공개 클레임(Public Claim)
공개 클레임은 사용자 정의 클레임으로, 공개용 정보를 위해 사용된다. 충돌 방지를 위해 URI 포맷을 이용한다.
{
"https://www.example.com": true}
비공개 클레임(Private Claim)
비공개 클레임은 사용자 정의 클레임으로, 서버와 클라이언트 사이에 임의로 지정한 정보를 저장한다.
{
"access_token": access
}

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
# jwt-properties
jwt.secret.key=${JWT_SECRET_KEY}
application.properties에서 설정한 JWT 시크릿 키 변수명대로 환경변수를 설정한다.
openssl rand -base64 32
JwtUtil 과 JwtFilter로 구성된다.
@Component
@RequiredArgsConstructor
public class JwtUtil {
// 토큰 유효시간
private static final Long TOKEN_TIME = 60 * 60 * 1000L; // 60분
// Token 식별자
public static final String BEARER_PREFIX = "Bearer "; // 관례
@Value("${jwt.secret.key}")
private String secretKey;
private Key key; // 비밀키(secretKey)를 통해 키 생성
// 비밀키 알고리즘
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 스프링 컨테이너 초기화 이후 1회 초기화 실행
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
// 토큰 발급
public String createToken(Long userId, String email, Role role) {
// 페이로드
Date now = new Date();
return Jwts.builder()
.setSubject(String.valueOf(userId)) // 토큰의 주체
.setHeaderParam("typ", "JWT") // 헤더 설정
.setIssuedAt(now) // 발행 시간
.setExpiration(new Date(now.getTime() + TOKEN_TIME)) // 토큰 만료기한 (발급 일시 +60분)
.claim("userId", userId) // Private Claims (Key-Value)
.claim("email", email)
.claim("role", role)
.signWith(key, signatureAlgorithm) // 서명 (사용 알고리즘, 서명 생성-검증 용 비밀 키)
.compact();
}
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
throw new BaseException(MISSING_TOKEN);
}
// 토큰 바디(Claims) 반환
public Claims extractClaims(String token) {
return Jwts.parserBuilder().setSigningKey(key).build()
.parseClaimsJws(token).getBody();
}
// 토큰에서 userId 획득
public Long getUserIdFromToken(String token) {
return Long.valueOf(Jwts.parserBuilder().setSigningKey(key).build()
.parseClaimsJws(token).getBody().getSubject());
}
}
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
// 로그인 필터 제외 대상 URL
private static final List<String> WHITE_LIST = List.of(
"/signup", // 회원가입
"/login" // 로그아웃
);
// 사장 전용 URL
private static final List<String> OWNER_LIST = List.of(
"/owners",
"/stores/*/categories/**",
"/reviews/*/comments/**"
);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String url = request.getRequestURI();
log.info("Request url :" + url);
// WHITE_LIST 는 필터 적용 제외
for (String matcher : WHITE_LIST) {
if (url.startsWith(matcher)) {
log.info("WHITE LIST REQUEST");
filterChain.doFilter(request, response);
return;
}
}
String bearerToken = request.getHeader("Authorization");
log.info("Authorization Header: {}", bearerToken);
// 토큰이 비어있다면
if (bearerToken == null) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "로그인 해주세요.");
return;
}
// 유효한 토큰인지 검사
String token = jwtUtil.substringToken(bearerToken);
Claims claims = null;
try {
claims = jwtUtil.extractClaims(token);
// 유저 역할 검증 // TODO: 추후 early return 하도록 변경
for (String matcher : OWNER_LIST) {
// 사장만 접근 가능 URL 에
// if (url.startsWith(matcher)) {
if (pathMatcher.match(matcher, url)) {
Role userRole = Role.valueOf(claims.get("role", String.class));
// 만약 유저 role 이 일반 사용자(USER)라면
if (!Role.OWNER.equals(userRole)) {
log.info("JwtFilter Role : Invalid");
response.sendError(HttpServletResponse.SC_FORBIDDEN, "유효하지 않은 접근입니다.");
return;
}
}
}
// request attributes 세팅
request.setAttribute("userId", Long.valueOf(claims.getSubject()));
request.setAttribute("role", claims.get("role"));
request.setAttribute("email", claims.get("email"));
filterChain.doFilter(request, response);
} catch (SecurityException | MalformedJwtException | SignatureException e) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "유효하지 않은 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
} catch (Exception e) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰에 문제가 있습니다.");
}
}
}
JWT 토큰 Claims에서 바로 값 꺼내오기
https://velog.io/@yoon17710/TIL-outsourcing-project-ts
Postman API 테스트 시 JWT 토큰 자동 세팅 방법:
https://velog.io/@yoon17710/TIL-Postman-JWT-Token-Automated-Testing
JWT+스프링 시큐리티 로그아웃
https://velog.io/@wjdtjdrhkd01/Spring-Security-JWT-%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0