수 많은 강의자료와, 유튜브 영상들을 보면서..
똑같이 코드를 작성해도 스프링 시큐리티와 jjwt 의 버전 업이 진행됨에 따라 수많은, 여러가지 오류들이 발생했다.
이 글은 https://www.youtube.com/watch?v=DCKE-bWYFxg 해당 강의 영상을 토대로 작성하였다.
그러한 오류들을 해결하고, 드디어 jwt 발행에 성공했다.
나처럼 비슷한 고난을 겪고 있을 사람들에게 도움이 되고자, 그리고 내 머리속에 남기고자 글을 남긴다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'org.springframework.boot:spring-boot-starter-security'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
우선, 위와 같이 의존성을 추가한다.
유의할 점은 jjwt 의 가장 최신 버전이 0.12.3 버전의 api를 사용한 다는 것.
이는 jjwt 깃헙의 installation을 보고 의존성을 추가했다.
https://github.com/jwtk/jjwt#quickstart
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(requests -> {
requests.requestMatchers("/api/users/login", "/api/users/join").permitAll();
requests.requestMatchers(HttpMethod.POST, "/api/articles").authenticated();
})
.sessionManagement(
sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.build();
}
}
SecurityFilterChain 이 가장 많은 에러를 아마 뿜어 냈으리라 생각한다.
SpringSecurity 가 버전 업 되면서, 기존 코드들과 변한점이 많다.
그것도 미세하게...
아무튼 보통 httpBasic, csrf, cors 를 disable 시킬 텐데, 저런식으로 코드를 작성해야 에러가 발생하지 않는다.
public class JwtUtil {
public static String createJwt(String userName, String secretKey, Long expiredMs){
// Claims claims = Jwts.claims();
// claims.put("userName", userName);
>
return Jwts.builder()
.claim("userName", userName)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
}
jjwt 의 버전이 업데이트 됨에 따라, 더 이상 Claims를 따로 생성하지 않는 듯 하다..
(Claims claims = Jwts.claims();
에서 오류가 발생했다.)
공식 문서의 가이드를 참고하여
위 코드와 같이 claim을 추가해준다.
@Service
public class UserService {
@Value("${jwt.secret}")
private String secretKey;
private Long expiredMs = 1000 * 60 * 60L;
public String login(String userName, String password) {
return JwtUtil.createJwt(userName, secretKey, expiredMs);
}
}
서비스로 사용할 UserService 클래스는 강의 영상과 동일하게 작성해도 무방했다.
반드시 기억해야할 점은 secretKey를 절대로 절대로 공개해서는 안된다는 점!!
그렇기에 application.yaml에 선언해준다.
너무 짧은 키를 선언하면
io.jsonwebtoken.security.WeakKeyException: The signing key's size is 96 bits which is not secure enough for the HS256 algorithm.
해당 에러를 보게 될 테니 충분히 긴 키를 선언하도록 하자.
또한, 특수문자가 포함되면
io.jsonwebtoken.io.DecodingException: Illegal base64 character: '!'
에러를 뿜뿜하니.. 이것도 빼자
모든 오류들을 방지하면, 비로소 jwt 를 발행 받을 수 있다!!
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final UserService userService;
@Value("${jwt.secret}")
private String secretKey;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(requests -> {
requests.requestMatchers("/api/users/login", "/api/users/join").permitAll();
requests.requestMatchers(HttpMethod.POST, "/api/**").authenticated();
})
.sessionManagement(
sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(new JwtFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
.build();
}
}
/api/users/login
, /api/users/join
을 제외한 모든 /api/**
요청은 발행된 토큰의 인증이 이루어진 경우에만 접근 할 수 있도록 변경한다.
그리고, addFilterBefore
를 추가하여, 우리가 원하는 jwt 필터를 적용함으로써, 유효 토큰인지 확인한다.
토큰이 없거나, 유효하지 않은 경우 아래와 같이 요정이 정상적으로 제한된다.
헤더에 발급 받은 토큰을 넣어 보내면, 아래와 같이 정상적으로 접근이 가능한 걸 확인 할 수 있다.
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final UserService userService;
private final String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
logger.info("authorization = " + authorization);
if(authorization == null || !authorization.startsWith("Bearer ")){
logger.error("authorization 이 없습니다.");
filterChain.doFilter(request, response);
return;
}
// Token 꺼내기
String token = authorization.split(" ")[1];
// Token Expired 되었는지 여부
if(JwtUtil.isExpired(token, secretKey)){
logger.error("Token 이 만료되었습니다.");
filterChain.doFilter(request, response);
return;
}
// UserName Token에서 꺼내기
String userName = "";
// 권한 부여
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userName, null, List.of(new SimpleGrantedAuthority("USER")));
// Detail을 넣어준다.
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
요청이 있을 때마다 동작하는 필터를 만들기 위해서 OncePerRequestFilter
를 상속받는다.
내부적으로 동작할 doFilterInternal
메소드를 위 코드와 같이 오버라이드한다.
요청의 헤더에서 AUTHORIZATION
을 받아와 해당 authorization 이 토큰의 시작인 Bearer 로 시작하는지 판단하고, 해당하지 않는 경우 에러를 발생시킨다.
또는, 토큰의 만료시간이 지난 경우 역시 에러를 발생 시킨다.
모든 경우에 만족을 하는 경우 토큰에서 userName
을 꺼내와 할당하고, 새로운 권한과 디테일들을 넣어준 뒤 SecurityContextHolder
의 context
에 디테일들이 추가된 authentication
값을 설정해준다.
public class JwtUtil {
public static String createJwt(String userName, String secretKey, Long expiredMs) {
return Jwts.builder()
.claim("userName", userName)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public static boolean isExpired(String token, String secretKey) {
return Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.getExpiration()
.before(new Date());
}
}
해당 토큰의 유효기간을 검사하는 isExpired(String token, String secretKey)
메소드를 추가한다.
JwtParserBuilder 를 이용하여 동일한 암호 키로 빌드하여, 해당 JwtParser 에서 토큰의 claim 들을 받아와 해당 토큰의 유효기간을 현재 시간과 비교하여 결과 값을 리턴해준다.