JWT토큰 예외처리

AuthenticationConfig

.addFilterBefore(new JwtExceptionFilter(), JwtFilter.class)

AuthenticationConfig에서 필터를 거치기 전에 ExceptionFilter를 거치며 에러를 처리할 수 있도록 코드를 추가해준다.

JWTExceptionFilter

package com.jh.jwt.exception;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.SignatureException;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
// 발생할 수 있는 에러를 만들어 setErrorResponse로 전달
@Component
@Slf4j
public class JwtExceptionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (MalformedJwtException e){
log.error("JWT Filter MalformedJwtException Exception");
setErrorResponse(response, HttpStatus.UNAUTHORIZED, e);
} catch (ExpiredJwtException e) {
log.error("JWT Filter ExpiredJwtException Exception");
setErrorResponse(response, HttpStatus.UNAUTHORIZED, e);
} catch (UnsupportedJwtException e) {
log.error("JWT Filter UnsupportedJwtException Exception");
setErrorResponse(response, HttpStatus.UNAUTHORIZED, e);
} catch (SignatureException e) {
log.error("JWT Filter SignatureException Exception");
setErrorResponse(response, HttpStatus.UNAUTHORIZED, e);
} catch (IllegalArgumentException e) {
log.error("JWT Filter IllegalArgumentException Exception");
setErrorResponse(response, HttpStatus.UNAUTHORIZED, e);
}
}
// 에러가 발생되었을 때 전달받은 에러에 맞도록 세팅 후 반환
public void setErrorResponse(HttpServletResponse response, HttpStatus status, Throwable e) {
ObjectMapper objectMapper = new ObjectMapper();
response.setStatus(status.value());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
ErrorResponse errorResponse = new ErrorResponse("error",status, e.getMessage());
try {
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
} catch (IOException ex) {
ex.printStackTrace();
}
}
// 반환 타입 Dto
@Data
public static class ErrorResponse {
private final String status;
private final HttpStatus code;
private final String message;
}
}

JWT토큰을 사용하며 발생할 수있는 에러를 처리한다. 에러가 발생하게 되면 doFilterInternal 함수를 통해 에러가 잡히게 되고 해당 에러는 setErrorResponse를 통하여 사용자에게 전달되도록 되어있다. 이를 통해 JWT에서 발생하는 에러를 관리할 수 있다.


로직 예외처리

package com.jh.jwt.exception;
import lombok.Data;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(UsernameNotFoundException.class)
public ResponseEntity<Object> usernameNotFoundExceptionHandler(UsernameNotFoundException e) {
ErrorResponse errorResponse = new ErrorResponse("error", 404, e.getMessage());
return ResponseEntity.badRequest().body(errorResponse);
}
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<Object> badCredentialsExceptionHandler(BadCredentialsException e) {
ErrorResponse errorResponse = new ErrorResponse("error", 400, e.getMessage());
return ResponseEntity.badRequest().body(errorResponse);
}
@ExceptionHandler(NotLoggedInException.class)
public ResponseEntity<Object> NotLoggedInExceptionHandler(NotLoggedInException e) {
ErrorResponse errorResponse = new ErrorResponse("error", 400, e.getMessage());
return ResponseEntity.badRequest().body(errorResponse);
}
@Data
public static class ErrorResponse {
private final String status;
private final Integer code;
private final String message;
}
}

ControllerAdvice

@ControllerAdvice는 이용하여 모든 컨트롤러에서 발생할 수 있는 예외를 잡아 처리해주는 어노테이션이다. 해당 어노테이션을 사용하여 발생할 수 있는 예외들을 관리할 수 있다.

ExceptionHandler

@ExceptionHandler를 사용하면 value로 원하는 예외를 지정하고 핸들링이 가능하다. 이는 지정한 예외 뿐 아니라 예외의 자식클래스도 모두 캐치하여 지정된 응답을 반환하게 된다.


NotLoggedInException

package com.jh.jwt.exception;
public class NotLoggedInException extends RuntimeException {
public NotLoggedInException() {
}
public NotLoggedInException(String message) {
super(message);
}
public NotLoggedInException(String message, Throwable cause) {
super(message, cause);
}
public NotLoggedInException(Throwable cause) {
super(cause);
}
}

로그인 되지 않았을 때의 에러를 Custom으로 만듬

로그아웃 흐름

Redis를 사용해서 로그아웃을 요청한 AccessToken을 블랙리스트에 등록해둬야합니다. AccessToken의 유효기간이 남아있다면 탈취되었을 경우 악용할 수 있기 때문입니다. 그렇기 때문에 Redis에 토큰을 저장해둠으로써 해당 토큰으로 접근하는 것을 막을 수 있습니다.

  1. 클라이언트는 로그아웃을 요청한다.
  2. 토큰값이 유효하다면 로그아웃 처리를 하면서 해당 AccessToken을 Redis에 블랙리스트로 등록한다(Redis DB에 저장).
  3. 블랙리스트로 등록된 AccessToken은 서버에 접근할 수 없다.
  4. 블랙리스트로 등록된 토큰은 만료시간이 지나면 자동으로 삭제된다.

RedisProperties

Redis에 연결하기 위한 기본 정보 Spring.redis에서 데이터를 가져온다.

package com.jh.jwt.configuration;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "spring.redis")
@Data
public class RedisProperties {
private int port;
private String host;
}

RedisConfig

package com.jh.jwt.configuration;
import com.jh.jwt.utils.KeyExpiredMessageListener;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisConfig {
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
// RedisProperties를 통해 host와 port를 가져와 Redis에 연결한다.
return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
// 만료된 토큰을 제거하기 위하여 MessageListenerAdapter, RedisMessageListenerContainer를 구성해서 메시지 리스너를 등록
// "__keyevent@*__:expired"이러한 패턴 채널로부터 만료된 키에 대한 메시지를 수신하고 메시지가 도착하면 KeyExpiredMessageListener // 를 호출하게 된다.
@Bean
public MessageListenerAdapter messageListenerAdapter() {
return new MessageListenerAdapter(new KeyExpiredMessageListener());
}
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory,
MessageListenerAdapter
messageListenerAdapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(messageListenerAdapter, new PatternTopic("__keyevent@*__:expired"));
return container;
}
}

RedisUtil

Redis를 좀 더 쉽게 만들어주기 위해 Util을 생성

package com.jh.jwt.utils;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
@RequiredArgsConstructor
public class RedisUtil {
private final RedisTemplate<String, Object> redisTemplate;
private final RedisTemplate<String, Object> redisBlackListTemplate;
public void set(String key, Object o, int minutes) {
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(o.getClass()));
redisTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
}
// Redis의 저장된 값을 불러온다.
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
// Redis의 저장된 값을 삭제
public boolean delete(String key) {
return Boolean.TRUE.equals(redisTemplate.delete(key));
}
// 키값이 존재하는지 확인
public boolean hasKey(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
// 블랙리스트로 등록(Redis에 저장)
public void setBlackList(String key, Object o, int minutes) {
redisBlackListTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(o.getClass()));
// Key: value 형태로 저장되며 minutes를 통해 key의 유효기간을 설정하여 자동으로 삭제될 시점을 정한다.
redisBlackListTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
}
public Object getBlackList(String key) {
return redisBlackListTemplate.opsForValue().get(key);
}
public boolean deleteBlackList(String key) {
return Boolean.TRUE.equals(redisBlackListTemplate.delete(key));
}
public boolean hasKeyBlackList(String key) {
return Boolean.TRUE.equals(redisBlackListTemplate.hasKey(key));
}
}

KeyExpiredMessageListener

메시지가 도달하였을 때 실행되는 리스너이며 해당 클래스를 통해 삭제 기능이 수행된다.

package com.jh.jwt.utils;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.core.RedisTemplate;
@RequiredArgsConstructor
public class KeyExpiredMessageListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 삭제될 key를 불러온다.
String expiredKey = message.toString();
// 만료된 키를 삭제한다.
redisTemplate.delete(expiredKey);
}
}

인증 API 구현

UserController

package com.jh.jwt.controller;
import com.jh.jwt.service.UserService;
import lombok.Builder;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.apache.tomcat.util.http.parser.Authorization;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/login")
public ResponseEntity<LoginResponseDto> login(@RequestBody LoginRequestDto dto) {
return ResponseEntity.ok().body(userService.login(dto.getUsername(), dto.getPassword()));
}
@PostMapping("/sign")
public Long sign(@RequestBody RequestSignupDto dto) {
return ResponseEntity.ok().body(userService.join(dto.getUsername(), dto.getPassword(), dto.getAuth())).getBody();
}
@PostMapping("/refresh")
public ResponseEntity<LoginResponseDto> refresh(HttpServletRequest request) {
return ResponseEntity.ok().body(userService.refresh(request));
}
@PostMapping("/logout")
public ResponseEntity<String> logout(HttpServletRequest request) {
return ResponseEntity.ok().body(userService.logout(request));
}
@Data
public static class RefreshTokenRequestDto {
private String token;
}
@Data
public static class LoginRequestDto {
private String username;
private String password;
}
@Data
public static class RequestSignupDto {
String username;
String password;
String auth;
}
}
  • 기본적으로 로그인, 회원가입, 리프레시 기능이 있음
  • 리프레시는 accessToken이 만료되었을 때 호출하는 API
  • Dto는 요청 받을 때 필요한 타입들을 명시해둠

LoginResponseDto

package com.jh.jwt.controller;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class LoginResponseDto {
private String accessToken;
private String refreshToken;
public LoginResponseDto(String accessToken, String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}
  • 로그인이나 리프레시가 성공적으로 수행되었을 때 반환되는 형태

UserService

package com.jh.jwt.service;
import com.jh.jwt.controller.LoginResponseDto;
import com.jh.jwt.controller.UserController;
import com.jh.jwt.domain.Authority;
import com.jh.jwt.domain.CustomUserDetails;
import com.jh.jwt.domain.User;
import com.jh.jwt.repository.UserRepository;
import com.jh.jwt.utils.RedisUtil;
import com.jh.jwt.utils.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletRequest;
import java.util.*;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final RedisUtil redisUtil;
protected final Logger logger = LoggerFactory.getLogger(UserService.class);
private final PasswordEncoder passwordEncoder;
private final TokenProvider tokenProvider;
@Transactional
public Long join(String username, String password, String auth){
User user = User.builder()
.username(username)
.password(passwordEncoder.encode(password))
.authority(Authority.valueOf(auth))
.build();
return userRepository.save(user);
}
@Transactional
public LoginResponseDto login(String username, String password){
// 회원 validation
List<User> users = userRepository.findByUsername(username);
if (users.isEmpty()) {
throw new UsernameNotFoundException("존재하지 않는 회원입니다.");
}
// 비밀번호 체크
if (!passwordEncoder.matches(password, users.get(0).getPassword())) {
throw new BadCredentialsException("비밀번호가 틀렸습니다.");
}
// username과 password를 통하여 UsernamePasswordAuthenticationToken을 생성
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
logger.debug("JwtAuthenticationFilter : 토큰생성완료");
// authenticationToken을 통하여 Authentication을 생성
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// authentication을 통해 미리 정의했던 CustomUserDetails형태의 principal을 생성
CustomUserDetails principal = (CustomUserDetails) authentication.getPrincipal();
// principal을 이용하여 Jwt토큰 생성
String accessToken = tokenProvider.createAccessJwt(principal);
String refreshToken = tokenProvider.createRefreshJwt(principal);
users.get(0).setRefreshToken(refreshToken);
return LoginResponseDto.builder().accessToken(accessToken).refreshToken(refreshToken).build();
}
// AccessToken이 만료되었지만 refreshToken이 유효하다면 Token들을 재발급 해준다.
public LoginResponseDto refresh(HttpServletRequest request) {
// 헤더에서 refreshToken정보를 꺼내온다.
String refreshToken = tokenProvider.resolveToken(request);
// refreshToken 유효성 검사
boolean validate = tokenProvider.validateRefreshToken(refreshToken);
// user 정보로 정의했던 CustomUserDetails를 만들어 Jwt토큰 생성
String username = tokenProvider.getRefreshUsername(refreshToken);
List<User> user = userRepository.findByUsername(username);
if (!user.get(0).getRefreshToken().equals(refreshToken)){
throw new BadCredentialsException("잘못된 접근입니다.");
}
CustomUserDetails cud = new CustomUserDetails(user.get(0));
String newAccessToken = tokenProvider.createAccessJwt(cud);
String newRefreshToken = tokenProvider.createRefreshJwt(cud);
return LoginResponseDto.builder().accessToken(newAccessToken).refreshToken(newRefreshToken).build();
}
// 레디스 블랙리스트에 accessToken값을 저장
// accessToken이 만료되지 않았을 때 로그아웃하면 재사용이 불가능 하도록 Redis에 저장해둠
public String logout(HttpServletRequest request) {
String accessToken = tokenProvider.resolveToken(request);
String username = tokenProvider.getUsername(accessToken);
List<User> user = userRepository.findByUsername(username);
user.get(0).setRefreshToken("");
redisUtil.setBlackList(accessToken, "accessToken", 5);
return "로그아웃";
}
}

UserRepository

package com.jh.jwt.repository;
import com.jh.jwt.domain.User;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.List;
@Repository
public class UserRepository{
@PersistenceContext
private EntityManager em;
// 데이터베이스에 사용자 저장
public Long save(User user) { em.persist(user); return user.getId();}
// 사용자이름을 통해 사용자 조회
public List<User> findByUsername(String username) {
return em.createQuery("select u from User u where u.username = :username", User.class)
.setParameter("username", username)
.getResultList();
}
// 사용자 id를 통해 사용자 조회
public User findOne(Long id) { return em.find(User.class, id);}
}

로그아웃과 관련된 내용은 다음 게시글에 Redis 설정부터 올리겠습니다.

Configuration 구현

AuthenticationConfig

스프링 시큐리티의 기본적인 웹 보안 활성화하고, 메서드를 재정의하여 인증과 권한을 설정할 수 있는 설정파일

package com.jh.jwt.configuration;
import com.jh.jwt.exception.JwtExceptionFilter;
import com.jh.jwt.utils.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class AuthenticationConfig {
private final TokenProvider tokenProvider;
// 비밀번호 인코딩에 BcryptPasswordEncoder 사용
@Bean
public BCryptPasswordEncoder encodePassword() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
// JWT를 사용하기 때문에 httpBasic, csrf를 disable
.httpBasic().disable()
.csrf().disable()
.cors().and()
.authorizeRequests()
// API 경로 설정
.antMatchers("/api/v1/users/login").permitAll() // 권한없이 사용할 수 있는 api
.antMatchers("/api/v1/users/sign").permitAll() // 권한없이 사용할 수 있는 api
.antMatchers(HttpMethod.POST, "/api/v1/reviews").authenticated() // JWT인증이 필요한 api
// 세션도 사용하지 않기때문에 STATELESS로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 필터를 거치기 이전에 JwtFilter를 먼저 거치도록 설정
.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
// Jwt 필터를 거치기 전에 Exception 관리를 위해 JwtException Filter를 등록
.addFilterBefore(new JwtExceptionFilter(), JwtFilter.class)
.build();
}
}
  • @EnableWebSecurity는 스프링 스큐리티의 기본적인 웹 보안을 활성화 합니다.
  • 추가로 antMathcer.hasRole("ADMIN")을 이용하면 ADMIN권한을 가진사람만 api를 사용할 수 있도록 설정할 수 있다.

TokenProvider

토큰을 생성하고 검증하는 역할과 토큰의 정보를 꺼내는 역할을 수행

package com.jh.jwt.utils;
import com.jh.jwt.domain.CustomUserDetails;
import com.jh.jwt.exception.NotLoggedInException;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import java.security.Key;
import java.util.*;
import java.util.stream.Collectors;
public class TokenProvider {
protected final String secret;
protected final String refreshSecret;
protected final long accessTokenExpired;
protected final long refreshTokenExpired;
private final RedisUtil redisUtil;
protected Key accessKey;
protected Key refreshKey;
public TokenProvider(String secret, Long expiredMs, String refreshSecret, Long refreshTokenExpired, RedisUtil redisUtil){
this.secret = secret;
this.accessTokenExpired = expiredMs;
this.refreshSecret = refreshSecret;
this.refreshTokenExpired = refreshTokenExpired;
byte[] accessKeyBytes = Base64.getDecoder().decode(secret);
this.accessKey = Keys.hmacShaKeyFor(accessKeyBytes);
byte[] refreshKeyBytes = Base64.getDecoder().decode(refreshSecret);
this.refreshKey = Keys.hmacShaKeyFor(refreshKeyBytes);
this.redisUtil = redisUtil;
}
// Access 토큰에서 사용자명 반환
public String getUsername(String token) {
return Jwts.parserBuilder().setSigningKey(accessKey).build().parseClaimsJws(token).getBody().get("username", String.class);
}
// Refresh토큰에서 사용자명 반환
public String getRefreshUsername(String token) {
return Jwts.parserBuilder().setSigningKey(refreshKey).build().parseClaimsJws(token).getBody().get("username", String.class);
}
// 토큰에서 권한정보 반환
public Collection<? extends GrantedAuthority> getAuthority(String token) {
Claims claims = Jwts.parserBuilder().setSigningKey(accessKey).build().parseClaimsJws(token).getBody();
return Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
// Access 토큰 생성
public String createAccessJwt(CustomUserDetails principal) {
Claims claims = Jwts.claims();
claims.put("username", principal.getUsername());
claims.put("auth", principal.getAuthorities());
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + accessTokenExpired))
.signWith(accessKey, SignatureAlgorithm.HS512)
.compact();
}
// Refresh 토큰 생성
public String createRefreshJwt(CustomUserDetails principal) {
Claims claims = Jwts.claims();
claims.put("username", principal.getUsername());
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpired))
.signWith(refreshKey, SignatureAlgorithm.HS512)
.compact();
}
// 헤더 Authorization에서 토큰정보 추출
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
// Access토큰 토큰 유효성 검사
public boolean validateToken(String token) {
try {
Claims claims = Jwts.parserBuilder().setSigningKey(accessKey).build().parseClaimsJws(token).getBody();
if (redisUtil.hasKeyBlackList(token)) {
throw new SignatureException("로그인을 해주세요.");
}
return true;
} catch(MalformedJwtException | SignatureException e) {
throw new MalformedJwtException("잘못된 JWT 서명입니다.");
} catch(ExpiredJwtException e) {
throw new ExpiredJwtException(e.getHeader(), e.getClaims(), "만료된 JWT 토큰입니다.");
} catch(UnsupportedJwtException e) {
throw new UnsupportedJwtException("지원하지 않는 JWT 토큰입니다.");
} catch(IllegalArgumentException e) {
throw new IllegalArgumentException("JWT 토큰이 잘못되었습니다.");
}
}
// Refresh 토큰 유효성 검사
public boolean validateRefreshToken(String token) {
try {
Claims claims = Jwts.parserBuilder().setSigningKey(refreshKey).build().parseClaimsJws(token).getBody();
return true;
} catch(MalformedJwtException | SignatureException | ExpiredJwtException | UnsupportedJwtException | IllegalArgumentException e) {
System.out.println("e = " + e);
throw new NotLoggedInException("로그인을 해주세요.");
}
}
}

JwtFilter

실직적인 인증절차를 진행하는 설정파일

package com.jh.jwt.configuration;
import com.jh.jwt.utils.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.*;
// 토큰 인증 계층
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
protected final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// authorization이 null이면 SecurityFilterChain을 거치도록한다.
final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if(authorization == null){
filterChain.doFilter(request, response);
return;
}
String token = tokenProvider.resolveToken(request);
if (tokenProvider.validateToken(token)){
// Username, Authorities Token에서 꺼내기
String username = tokenProvider.getUsername(token);
Collection<? extends GrantedAuthority> authority = tokenProvider.getAuthority(token);
logger.info("username: {}", username);
logger.info("authority: {}", authority);
// 권한 부여
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(username, null, authority);
// 해당 authenticationToken을 스프링 시큐리티 컨텍스트에 저장한다.
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
}
  • OncePerRequestFilter는 Request한번에 한번만 실행되는 Filter입니다.
  • doFilterInternal에서 토큰의 유효성을 검증하고, 토큰에 들어있는 username과 authority를 가져와 authentication을 생성하고 스프링 시큐리티 컨텍스트에 저장하는 역할을 합니다.

JWTConfig

TokenProvider에 의존성을 주입하고 빈을 생성하는 역할을 하는 설정파일

package com.jh.jwt.configuration;
import com.jh.jwt.utils.RedisUtil;
import com.jh.jwt.utils.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
// TokenProvider에 의존성을 주입하고 빈을 생성하는 설정 파일
@Configuration
@RequiredArgsConstructor
public class JwtConfig {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access_token_expired}")
private Long accessTokenExpired;
@Value("${jwt.refresh_secret}")
private String refreshSecret;
@Value("${jwt.refresh_token_expired}")
private Long refreshTokenExpired;
private final RedisUtil redisUtil;
@Bean(name = "tokenProvider")
public TokenProvider tokenProvider() {
return new TokenProvider(secret, accessTokenExpired, refreshSecret, refreshTokenExpired, redisUtil);
}
}

참고

멋사 SpringSecurity 인증인가 - 04 Token유효한지 만료되었는지 검사하기
https://velog.io/@suhongkim98/Spring-Security-JWT로 인증인가구현하기

엔티티 구현

User

인증에 사용되는 사용자 클래스 구현

package com.jh.jwt.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 100, nullable = false, unique = true)
private String username;
@Column(length = 300, nullable = false)
private String password;
@Enumerated(EnumType.STRING)
private Authority authority;
private String refreshToken;
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}

 

Authority

인가에 사용되는 권한 Enum 클래스 구현

package com.jh.jwt.domain;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Authority {
ADMIN("ROLE_ADMIN"), USER("ROLE_USER");
private final String key;
}

 

CustomUserDetails구현

CustomUserDetails

Spring Security의 UserDetail을 상속받아 User엔티티에 맞도록 CustomUserDetails 클래스 구현

package com.jh.jwt.domain;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
@Data
public class CustomUserDetails implements UserDetails {
private final User user;
public CustomUserDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(() -> user.getAuthority().getKey());
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

JWT를 선택하는 이유

JWT를 선택하는 것에는 다양한 이유가 존재합니다.

  1. Stateless
    • 서버 측에서 세션을 유지하지 않기 떄문에 상태 정보를 저장하고 확인하지 않아 서버 부하의 원인이 되지 않고, 여러 대의 서버나 분산 시스템으로 구성된 환경에서도 동일한 토큰을 사용하여 인증 처리가 가능합니다.
  2. Scalable
    • 디바이스별 요청마다 발급되는 토큰의 값만 확인하고 체크하는 행위로 안전하게 접근 제어 및 권한 부여등 작업을 처리할 수 있어 확장에 용이합니다.
  3. Extensibility
    • 로그인 정보가 사용되는 분야의 확장이 가능합니다. 이는 토큰을 사용하여 다른 서비스에서 권한을 공유할 수있는 것을 말합니다.(소셜 로그인)
  4. Security
    • HTTPS와 함께 사용하면 더 안전하게 사용할 수 있습니다.

하지만 이 토이 프로젝트를 진행한 이유는 한번도 사용해본적 없고 많은 기업에서 사용하기에 구현해보기 위해 JWT를 이용해보기로 하였습니다.

 

JWT 인증방식은 이전글에서 설명하여 생략

 

1. 스프링부트 프로젝트 생성

JAVA 버전 11로 환경을 구성하여 세팅하였습니다.

 

2. 의존성 설정

스프링 스타터 패키지, JPA, security, redis, jwt, lombok, mariadb, configuration-processor에 관한 의존성을 추가해줍니다.

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}

 

3. application.yml 세팅

DB는 Mariadb를 사용하기 때문에 MariaDb에 관한 세팅을 해주었습니다.
redis는 로그아웃할 떄 이용하기 때문에 설정을 추가해줍니다.
jwt의 secret관련 부분은 HS512알고리즘을 사용하기 때문에 64바이트 이상의 길이를 가진 secret으로 설정해줍니다.

spring:
datasource:
url: jdbc:mariadb://localhost:3306/{DbName}?characterEncoding=UTF-8&serverTimezone=UTC
username: root
password: 1234
driver-class-name: org.mariadb.jdbc.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100
redis:
pool:
min-idle: 0
max-idle: 8
max-active: 8
port: 6379
host: localhost
logging:
level:
org.hibernate.SQL: debug
org.hibernate.type: trace
jwt:
header: Authorization
secret: eyJSb2xlIjoiQWRtaW4iLCJJc3N1ZXIiOiJQc3NyIERldiIsIlVzZXJuYW1lIjoiUHJlc2lkZW50aWFsIFByb3RlY3Rpb24gQW5kIFNlY3VyaXR5IiwiaWF0IjoxNjg3MDA0Mjg1fQ
refresh_secret: RUUyNkIwREQ0QUY3RTc0OUFBMUE4RUUzQzEwQUU5OTIzRjYxODk4MDc3MkU0NzNGODgxOUE1RDQ5NDBFMERCMjdBQzE4NUY4QTBFMUQ1Rjg0Rjg4QkM4ODdGRDY3QjE0MzczMkMzMDRDQzVGQTlBRDhFNkY1N0Y1MDAyOEE4RkY=
access_token_expired: 360000
refresh_token_expired: 1209600000

JWT(Json Web Token) 이란 무엇인가?

사전적 정의
JWT는 Json Web Token의 약자로, 웹 표준으로 지정된 RFC 7519에 따라 생성되는 인증 토큰이다. 인증에 필요한 최소한의 정보를 암호화시켜 만든 토큰을 통해 서버와 클라이언트가 안전하게 정보를 교환할 수 있다.

 

세션 인증 방식의 경우 Stateful 방식으로 매 요청마다 서버와 통신을 해왔고, 이는 사용자가 많아질수록 많은 트래픽이 발생하여 서버 부하의 원인이 되었습니다. JWT는 이러한 문제점들을 해결하기 위해 등장하게 되었고, 토큰안에 인증에 필요한 정보를 보관함으로 요청이 될 때 마다 서버와 통신을 할 필요가 없게 되어 서버 부하와 같은 문제들을 해결할 수 있었다.

 

하지만 JWT는 한번 발급되면 만료기간까지 유효하여 탈취에 대한 보안적인 이슈가 생길 수 있습니다. 이를 해결하기 위해 토큰 만료기간을 짧게 설정하여 토큰이 자주 변경되게 할 수 있고, JWT는 토큰을 저장할 때 httpOnly 쿠키에 저장하는 것을 권장한다.

 

JWT 인증 방식

AccessToken과 RefreshToken을 사용했을 때 인증 방식

 

1. ID, PW로 로그인을 하게되면 AccessToken과 RefreshToken을 발급받는다.

2. AccessToken를 통해 인증을 받아 API를 실행할 수 있다.

3. AccessToken이 만료되면 만료 에러가 발생된다.

4. 만료에러가 발생하였을 때 AT을 재발급 받기 위해 RT를 통해 /refresh를 호출한다.

5. RT가 정상적이라면 NEW AT를 사용자에게 전달해준다.

 

JWT 구조

JWT는 세 부분으로 구성됩니다.

1. Header

헤더는 일반적으로 토큰 유형(JWT)과 사용 중인 서명 알고리즘(예: HMAC SHA256 또는 RSA)의 두 부분으로 구성되어 있다.

HEADER:ALGORITHM & TOKEN TYPE
{
  "alg": "HS256",
  "typ": "JWT"
}

 

2. Payload

페이로드는 세가지 유형으로 구성되어있다.

1). Registered claims

필수는 아니지만 상호 운용 가능한 클레임을 제공하기 위해 권장되는 미리 정의된 클레임의 집합, 그중 일부는 iss(issuer), exp(expiration time), sub(subject), aud(audience), 기타 

 

2). Public claims

JWT를 사용하는 사람들이 마음대로 정의할 수 있지만 클레임 이름의 충돌을 방지하기 위해 IANA에 정의하거나 충돌이 방지된 URI로 정의해야 한다.

 

3). Private claims

사용자와 서버의 협의하에 사용되는 클레임의 이름으로 Registered, Public 둘다 아니다.

PAYLOAD:DATA
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

 

3. Signature

서명 부분은 인코딩된 헤더, 인코딩된 페이로드, 비밀키와 헤더에 지정된 알고리즘을 통해 서명된다.

VERIFY SIGNATURE
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  your-256-bit-secret

)

 

이렇게 만들어진 세가지 요소를 JWT키로 만들게 되면 헤더와 페이로드가 인코딩되고 암호로 서명된 JWT를 보여준다.

인코딩 디코딩 참고: https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

의존관계 자동주입

다양한 의존관계 주입 방법

  • 의존관계 주입은 크게 4가지 방법이 있습니다.
  • 생성자 주입
    • 생성자를 통해서 의존관계를 주입 받는 방법입니다.
    • 생성자 호출시점에 딱 1번만 호출되는 것이 보장됩니다.
    • 불변, 필수 의존관계에 사용

  • 수정자 주입(setter 주입)
    • setter라 불리는 필드의 값을 변경하는 수정자 메서드를 통해서 의존관계를 주입하는 방법입니다.
    • 자바빈 프로퍼티 규약의 수정자 메서드 방식을 사용하는 방법입니다.
    • 선택, 변경 가능성이 있는 의존관계에 사용

  • 필드 주입
    • 이름 그대로 필드에 바로 주입하는 방법입니다.
    • 코드가 간결해서 많은 개발자들을 유혹하지만 외부에서 변경이 불가능하기 때문에 테스트하기 힘들다는 단점이 있습니다.
    • DI 프레임워크가 없으면 아무것도 할 수 없습니다.
    • 사용하지 않는 것이 좋음

    • 일반 메서드를 통해서 주입 받을 수 있습니다.
    • 한번에 여러 필드를 주입 받을 수 있습니다.
    • 일반적으로 잘 사용하지 않습니다.일반 메서드 주입

옵션 처리

  • 주입할 스프링 빈이 없어도 동작해야 할 때가 있는데, @Autowired만 사용하면 required옵션의 기본값이 true로 되어 있어서 자동 주입 대상이 없으면 오류가 발생합니다.
  • @Autowired(required=false): 자동 주입할 대상이 없으면 수정자 메서드 자체가 호출되지 않습니다.
  • org.springframework.lang.@Nullable: 자동 주입할 대상이 없으면 null이 입력됩니다.
  • Optional<>: 자동 주입할 대상이 없으면 Optional.empty가 입력됩니다.

생성자 주입을 선택해야 한다!

  • 과거에는 수정자 주입과 필드 주입을 많이 사용했지만, 최근에는 스프링을 포함한 DI프레임워크 대부분이 생성자 주입을 권장합니다. 이유는 다음과 같음
  • 불변
    • 대부분의 의존관계 주입은 한번 일어나면 애플리케이션 종료시점까지 의존관계를 변경할 일이 없다. 오히려 대부분의 의존관계는 애플리케이션 종료 전까지 변하면 안된다.(불변해야한다.)
    • 수정자 주입을 사용하면, setXxx 메서드를 public으로 열어두여아 한다.
    • 누군가 실수로 변경할 수 있고, 변경하면 안되는 메서드를 열어두는 것을 좋은 설계방법이 아님
    • 생성자 주입은 객체를 생성할 때 딱 1번만 호출되므로 이후에 호출되는 일이 없기 때문에 불변하게 설계할 수 있다.
  • 누락
    • 수정자 주입을 사용했을 때(위 수정자 주입 코드 참고), 아래의 코드를 수행하면 실행은 되지만 결과는 Null Point Exception이 발생하게 된다. 이는 OrderServiceImple을 생성할 때 의존관계 주입이 누락되었기 때문입니다.
    • 하지만 생성자 주입을 사용하면 아래 코드처럼 의존관계 주입이 누락 되었을 때 컴파일 오류가 발생하게 됩니다.
    • 생성자 주입을 사용하면 필드에 final 키워드를 사용할 수 있는데 혹시라도 값이 설정되지 않는 오류를 컴파일 시점에서 막아준다.
       
@Test void createOrder() {
OrderServiceImpl orderService = new OrderServiceImpl();
orderService.createOrder(1L, "itemA", 10000);
}

정리

  • 생성자 주입 방식을 선택하는 이유는 여러가지가 있지만, 프레임워크에 의존하지 않고, 순수한 자바 언어의 특징을 잘 살리는 방법이기도 합니다.
  • 기본으로 생성자 주입을 사용하고, 필수 값이 아닌 경우에는 수정자 주입 방식을 옵션으로 부여하면 됩니다.
  • 항상 생성자 주입을 선택! 가끔 옵션이 필요하다면 수정자 주입을 선택하지만 필드 주입은 사용하지 않는게 좋습니다.

롬복

  • 롬복 적용은 구글 참고
  • 롬복 라이브러리가 제공하는 @RequiredArgsConstructor 기능을 사용하면 final이 붙은 필드를 모아서 생성자를 자동으로 만들어주는 역할을 합니다. 그러므로 생성자를 만드는 코드를 작성하지 않아 짧고 간결한 코드작성이 가능합니다.
@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
}

}

+ Recent posts