JWT란
JWT(Json Web Token)은 클라이언트와 서버 간의 사용자 인증을 위해 사용되는 토큰이다.
자세한 내용은 이전 포스팅을 참고해주길 바란다.
스프링 부트에서 인증 기능을 구현하기 위해 Spring Security
와 JWT
를 사용할 것이다.
Spring Security : 스프링 서버에 필요한 인증 및 인가를 위해 많은 기능을 제공하는 프레임워크이다.
dependencies {
...
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
...
}
- Spring security 5.7버전 이상부터 WebSecurityConfigurerAdapter가 deprecated 되었다.
- 때문에 Spring boot 버전을
2.6.x
로 변경하고 진행했다. ( Spring security 5.7 아래를 사용하는 버전)plugins { id 'java' id 'org.springframework.boot' version '2.6.11' //<-3.0.1 id 'io.spring.dependency-management' version '1.1.0' }
hello.springmvc.first.Config.WebSecurityConfig.java
@RequiredArgsConstructor
@EnableWebSecurity //Spring Security 설정 활성화
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final ObjectMapper objectMapper;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
//암호화에 필요한 PasswordEncoder Bean 등록
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
//authenticationManager Bean 등록
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override public void configure(WebSecurity web) throws Exception {
// 허용하는 url 등록
web.ignoring().antMatchers("/", "/first/**", "/swagger-ui/**", "/swagger-resources/**", "/v2/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
//세션 사용 안함
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
//URL 관리
.authorizeRequests()
.antMatchers().permitAll() //허용 url
.anyRequest().authenticated()
.and()
// JwtAuthenticationFilter를 먼저 적용
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
// 인증예외 발생시 수행 메서드
.authenticationEntryPoint(((request, response, authException) -> {
// 이 부부은 토큰 만료의 경우이며 뒷부분에 설명하도록 하겠다.
if(request.getAttribute("exception") == ErrorCode.TOKEN_EXPIRED){
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(),
ErrorResponse.of(ErrorCode.TOKEN_EXPIRED)
);
}
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(),
ErrorResponse.of(ErrorCode.FAIL_AUTHENTICATION)
);
}))
// 인가예외 발생시 수행 메서드
.accessDeniedHandler(((request, response, accessDeniedException) -> {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(),
ErrorResponse.of(ErrorCode.FAIL_AUTHORIZATION)
);
}));
}
}
@EnableWebSecurity
: 웹보안 활성화를 위한 애노테이션이다. 스프링 시큐리티 지원을 가능하게 한다.
authenticationEntryPoint
: 인증예외 발생시 수행되는 메서드이다. 해당 코드에서는 조건문을 통해 토큰이 만료된 경우와 인증실패의 경우로 나누어 처리한다.
accessDeniedHandler
: 인가예외 발생시 수행되는 메서드이다.
ErrorResponse.of
: 예외처리 포스팅에서 생성한 예외처리 메서드이다.
ErrorCode.~
: 예외처리 포스팅에서 생성한 에러코드 Enum 파일이다.
이전에 생성해둔 ErrorCode Enum 파일에 JWT 관련 에러코드를 추가하자.
hello.springmvc.first.Exceptions.ErrorCode.java
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "INVALID INPUT VALUE"),
FAIL_AUTHENTICATION(HttpStatus.UNAUTHORIZED, "FAIL AUTHENTICATION"), // 로그인X 유저의 요청 OR 토큰 불일(?)
TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "TOKEN EXPIRED"), // 엑세스 토큰 만료
FAIL_AUTHORIZATION(HttpStatus.FORBIDDEN, "FAIL AUTHORIZATION"), // 권한 없는 요청
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR"); // 예상치 못한 에러
private final HttpStatus status;
private final String message;
}
- throw할 에러들을 정의하기 위해 상속할 Exception Class를 하나 생성하자.
RuntimeException
을 상속 받는다.
hello.springmvc.first.Exceptions.BusinessException.java
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(String message, ErrorCode errorCode) {
super(message);
this.errorCode = errorCode;
}
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
JwtException
을 throw하기 위해 생성한다.- 앞서 만들어 둔
BusinessException
을 상속 받자.
- 다른 에러들도 추가하고 싶다면 이 방법대로 클래스를 하나씩 생성하여 추가하면 된다.
hello.springmvc.first.Exceptions.JwtException.java
public class JwtException extends BusinessException {
public JwtException() {
super(ErrorCode.JWT_EXCEPTION);
}
}
dependencies {
...
// jwut
implementation 'io.jsonwebtoken:jjwt:0.9.1'
...
}
- 토큰 관련 메서드들을 정의할 클래스이다.
토큰 생성
,토큰 페이로드 반환
,토큰 검증
등의 로직을 처리한다.
hello.springmvc.first.JWT.JwtTokenProvider.java
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
// secret key
private String secretKey = "secretKey";
// 토큰 유효시간 1분 (테스트)
private long tokenValidTime = 1 * 60 * 1000L;
// JWT 토큰 생성
private String createToken(final long payload, final String secretKey, final Long tokenValidTime) {
return Jwts.builder()
.setSubject(String.valueOf(payload))
.signWith(SignatureAlgorithm.HS256, secretKey)
.setExpiration(new Date(System.currentTimeMillis() + tokenValidTime))
.compact();
}
public String createAccessToken(final long payload) {
return createToken(payload, secretKey, tokenValidTime);
}
// 토큰으로부터 페이로드 가져오기
public Long getAccessTokenPayload(String accessToken) {
try {
var claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(accessToken)
.getBody();
return Long.parseLong(claims.getSubject());
} catch (ExpiredJwtException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
throw new JwtException();
}
}
// 토큰 검증 (정의해 둔 ErrorCode 반환 -> 에러 구분 위해)
public ErrorCode validateToken(String accessToken) {
try {
var claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(accessToken);
if(!claims.getBody().getExpiration().before(new Date()) == false) {
return ErrorCode.FAIL_AUTHENTICATION;
}
return null;
} catch (ExpiredJwtException e){
return ErrorCode.TOKEN_EXPIRED;
} catch (Exception e) {
return ErrorCode.FAIL_AUTHENTICATION;
}
}
}
validateToken
: null 또는 두 가지 에러를 반환한다.
null : 토큰 검증 성공
ErrorCode.FAIL_AUTHENTICATION : 토큰 검증 실패 (인증 실패=401)
ErrorCode.TOKEN_EXPIRED : 토큰 만료 (인증 실패=401)
- 토큰으로부터 얻은 Payload (유저 정보)를 통해 유저 정보를 가져오는 클래스이다.
- 해당하는 유저가 없다면
NotFoundException
을 Throw한다.
hello.springmvc.first.JWT.CustomUserDetailService.java
@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final FirstMapper firstMapper;
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
FirstDTO findUser = Optional.ofNullable(firstMapper.selectUserById(Integer.valueOf(userId))).orElseThrow(() -> new NotFoundException());
return UserPrincipal.create(findUser);
}
}
사용자 ID
를 사용했으므로 파라미터는 userId이다.
- UserDetails를 상속받는 클래스이다.
hello.springmvc.first.JWT.UserPrincipal.java
public class UserPrincipal implements UserDetails {
private final Integer userId;
private final Collection<? extends GrantedAuthority> authorities;
public UserPrincipal(Integer userId, Collection<? extends GrantedAuthority> authorities) {
this.userId = userId;
this.authorities = authorities;
}
public static UserPrincipal create(FirstDTO user) {
var authorities = Collections.singletonList(new SimpleGrantedAuthority(user.getName()));
return new UserPrincipal(user.getId(), authorities);
}
public Integer getUserId() {
return userId;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return null;
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
JWT 토큰
과JwtTokenProvider
을 이용해 인증처리를 진행하는 필터이다.OncePerRequestFilter
를 상속받아 작성했다.- 해당 필터에서 토큰의 유효성 검사가 진행된다.
hello.springmvc.first.JWT.JwtAuthenticationFilter.java
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String PREFIX_BEARER = "Bearer ";
private final UserDetailsService userDetailsService;
private final JwtTokenProvider jwtProvider;
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
var accessToken = resolveToken(request);
ErrorCode errorCode = jwtProvider.validateToken(accessToken);
if (accessToken != null && errorCode==null) {
try {
UserDetails userDetails = userDetailsService.loadUserByUsername(String.valueOf(jwtProvider.getAccessTokenPayload(accessToken)));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(userDetails.getUsername(), "", userDetails.getAuthorities()));
} catch (Exception e) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(),
ErrorResponse.of(ErrorCode.FAIL_AUTHENTICATION)
);
return;
}
}
request.setAttribute("exception", errorCode);
filterChain.doFilter(request, response);
}
// 토큰 가져오기
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(PREFIX_BEARER)) {
return bearerToken.substring(7);
}
return null;
}
}
doFilterInternal
: jwt 토큰의 인증정보를 SecurityContext에 저장하는 역할을 수행한다.filterChain.doFilter
: 다음 Filter를 실행하기 위한 코드. 마지막 필터라면 필터 실행 후 리소스를 반환한다.
- 기존의 방식에서는 토큰 불일치와 토큰 만료의 경우 모두 401 (FAIL AUTHENTICATION) 에러를 반환했다.
- 이는 클라이언트 입장에서 어떤 이유의 에러인지 알 수 없다는 단점이 있다.
- 때문에 아래와 같이 응답을 분리했다.
- 토큰 불일치 : 401 + "FAIL AUTHENTICATION"
- 토큰 만료 : 401 + "TOKEN EXPIRED"
Filter
는Dispatch Servlet
앞단에 존재한다. 스프링의 관할이 아니다.- 때문에
Filter
에서 던진 예외는 뒤에 존재하는Exception Handler
에서 처리할 수 없다.
- 때문에 앞서 설정한
WebSecurityConfig
파일의exceptionHandling()
부분에서 처리를 해주었다.
exceptionHandling
: 예외처리 기능 작동
.authenticationEntryPoint
: 인증 실패 시 처리.accessDeniedHandler
: 인가 실패 시 처리
WebSecurityConfig의 exceptionHandling 부분
.exceptionHandling()
// 인증 실패
.authenticationEntryPoint(((request, response, authException) -> {
// request.setAttribute로 저장했던 ErrorCode를 통해 구분한다.
// 토큰 만료의 경우
if(request.getAttribute("exception") == ErrorCode.TOKEN_EXPIRED){
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(),
ErrorResponse.of(ErrorCode.TOKEN_EXPIRED)
);
}
// 토큰 불일치의 경우
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(),
ErrorResponse.of(ErrorCode.FAIL_AUTHENTICATION)
);
}))
// 인가 실패
.accessDeniedHandler(((request, response, accessDeniedException) -> {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(),
ErrorResponse.of(ErrorCode.FAIL_AUTHORIZATION)
);
}));
https://velog.io/@hellonayeon/spring-boot-jwt-expire-exception