이제 본격적으로 JWT를 발급하는 기능을 만들어보자.
package com.cheering.auth.jwt;
import static com.cheering.auth.constant.JwtConstant.ACCESS_TOKEN_EXPIRE_TIME;
import static com.cheering.auth.constant.JwtConstant.GRANT_TYPE;
import static com.cheering.auth.constant.JwtConstant.REFRESH_TOKEN_EXPIRE_TIME;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.util.Date;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class JwtGenerator {
private final Key key;
// application.yml에서 secret 값 가져와서 key에 저장
public JwtGenerator(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// Member 정보를 가지고 AccessToken, RefreshToken을 생성하는 메서드
public JWToken generateToken(Long userId) {
// 권한 가져오기
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(String.valueOf(userId))
// .claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JWToken.builder()
.grantType(GRANT_TYPE)
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
}
먼저 JWT를 만들어주는 기능이 필요하다.
이렇게 토큰을 발행하면 Access Token과 Refresh Token 두 가지가 JwtDto에 담겨서 반환된다. 이를 컨트롤러 단에서 ResponseEntity를 반환할 때 헤더에 AccessToken을 넣어서 응답을 내릴 예정이다.
코드를 좀 살펴보면 서명에 사용할 Key 객체 값을 하나 생성한다. String secretKey에는 랜덤 String 문자열이 들어가게 되는데 이는 구글링 하면 랜덤한 문자열을 생성하는 툴이나 사이트가 나올 것이다.
generateToken 함수를 보면 Jwts.builder() 이후 메소드 체이닝으로 토큰을 만든다.
앞선 포스트에서 알아본 토큰을 만드는 과정이다.
이후 마지막에 JWToken.builder()를 통해 토큰 스펙을 완성하고 반환한다.
package com.cheering.auth.jwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
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.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class JwtProvider {
private final Key key;
// application.yml에서 secret 값 가져와서 key에 저장
public JwtProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// Jwt 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(String accessToken) {
// Jwt 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.toList();
// UserDetails 객체를 만들어서 Authentication return
// UserDetails: interface, User: UserDetails를 구현한 class
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
// 토큰 정보를 검증하는 메서드
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
// accessToken
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
JwtProvider 클래스는 JWT와 관련된 유틸 기능을 제공하는 클래스이다.
서명에 사용한 Key 값으로 복호화를 진행하기 때문에 동일한 Key 값이 필요하며
복호화 메소드(getAuthentication), 검증 메소드(validateToken)가 존재한다.
package com.cheering.auth.jwt;
import com.cheering.auth.constant.JwtConstant;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 1. Request Header에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);
// 2. validateToken으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
// 에러 핸들링 필요
chain.doFilter(request, response);
}
// Request Header에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(JwtConstant.GRANT_TYPE)) {
return bearerToken.substring(7);
}
return null;
}
}
JwtAuthenticationFilter는 Filter에 등록할 필터를 구현한 클래스이다.
필터에 대해서는 추후 다른 포스트에서 공부해보자.
doFilter 함수 내부에 //에러 핸들링 필요 부분에서 에러에 대한 핸들링이 필요하다.
이 부분은 추가로 구현할 예정이다.
즉, 전체적인 흐름을 보면 클라이언트에서 요청이 들어온 경우 등록한 Jwt필터가 동작하여 해당 요청의 토큰 유효성을 검사한다. 유효성 검사를 통과하면 chain.doFilter를 호출하여 다음 필터로 넘어가고 아니면 에러 핸들링한 로직에 의해 예외 처리된다.
package com.cheering.auth.security;
import com.cheering.auth.jwt.JwtAuthenticationFilter;
import com.cheering.auth.jwt.JwtProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final JwtProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
// REST API이므로 basic auth 및 csrf 보안을 사용하지 않음
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
// JWT를 사용하기 때문에 세션을 사용하지 않음
.sessionManagement(sessionManagementConfigurer -> sessionManagementConfigurer
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
// 해당 API에 대해서는 모든 요청을 허가
// .requestMatchers("/api/signup", "/api/signin").permitAll()
// 이 밖에 모든 요청에 대해서 인증을 필요로 한다는 설정
.anyRequest().permitAll())
// // USER 권한이 있어야 요청할 수 있음
// .requestMatchers("/members/test").hasRole("USER"))
// JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class
)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt Encoder 사용
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
추가로 기존의 세션 인증 방식이 아닌 JWT 인증 방식을 사용할 것이기 때문에 Security 관련한 설정 변경이 필요하다.
어노테이션으로 @Configure, @EnableWebSecurity를 붙여주면 설정이 가능하다.
코드를 살펴보면, httpBasic() 그러나 이를 사용하지 않기 때문에 disable해주고 csrf도 disable. formLogin 기능도 꺼준다.
또한 세션 객체를 사용하지 않기 때문에 SessionPolicy.STATELESS로 해주는데, 다 구현하고 보니 JSeesionId는 쿠키로 계속 넘어온다. 이 부분은 좀 더 공부 후에 추가해야겠다.
여기까지 하면 토큰 발행 기능을 완벽하진 않지만 구현한 상태이다(리프레시 토큰 미 사용)
이후 컨트롤러 혹은 서비스 단에서
JWToken jwToken = jwtGenerator.generateToken(joinUser.getId());
다음과 같이 토큰을 발행하여 응답 헤더에 넣어서 반환하면 된다.
세션 인증 방식이 최근에는 잘 사용되지 않는다는 것을 알게 되었다.
또한 토큰 인증 방식의 장, 단점에 대해 공부할 수 있었다.
Spring Security를 처음 사용해보는데 이 부분도 공부할 부분이 많은 것 같다.
필터 체인 동작 원리와 스프링 인터셉터에 대해서도 추가로 공부해보자.