Jwt 토큰 예외처리
Jwt 토큰을 발급하고 검증하는 기본 로직은 구현이 되었다.
하지만 토큰이 만료가되었는지 잘못된 토큰인지 구분하여 응답해주는 로직이 없어서 추가해야했다. 또한 로그인 성공시
리프레쉬 토큰을 발급하여 디비에 저장 또는 업데이트하는 로직도 추가했다.
먼저 리프레쉬 토큰 정보가 들어갈 테이블에 매핑될 도메인이다.
@Entity
@Table(name = "refresh_token")
@Getter
@NoArgsConstructor
public class RefreshToken extends BaseEntity{
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "refresh_token_id")
private Long id;
private Long userId;
private String tokenName;
private Date expireDt;
@Builder
public RefreshToken(Long userId, String tokenName, Date expireDt) {
this.userId = userId;
this.tokenName = tokenName;
this.expireDt = expireDt;
}
public void updateTokenName(String tokenName,Date expireDt){
this.tokenName = tokenName;
this.expireDt = expireDt;
}
}
여기서 일단 유저테이블과 연관관계매핑은 해두지 않았다. 논리적으론 당연히 유저테이블에 존재하지 않는 유저 아이디가 리프레쉬 토큰테이블에 들어가선 안된다 근데 뭐 둘이일단 조인해야할 비즈니스 로직이 일단 필요없어서 해두지 않았다.
@Service
@RequiredArgsConstructor
@Transactional
public class TokenService {
private final TokenRepository tokenRepository;
public RefreshToken findById(Long userId){
return tokenRepository.findByUserId(userId);
}
public void saveRefreshToken(Long id, String tokenName, Date expireDt){
RefreshToken getOne = tokenRepository.findByUserId(id);
if(getOne != null){
getOne.updateTokenName(tokenName, expireDt);
}else{
RefreshToken refreshTokenEntity = RefreshToken.builder()
.userId(id)
.tokenName(tokenName)
.expireDt(expireDt)
.build();
tokenRepository.save(refreshTokenEntity);
}
}
}
로그인 성공시 리프레쉬 토큰을 db에 저장또는 업데이트하기 위한 서비스 로직이다.
유저 id로 셀렉트해서 존재하지 않으면 builder 패턴을 이용해 오브젝트 생성후 Persist하고
이미 존재한다면 만료시간과 토큰정보만 변경해서 더티체킹하여 업데이트시킨다.
사실 setter를 너무 남발하는 코딩을 했다보니 솔직히 저렇게 로직마다 set메소드를 만들어주는게 좀 귀찮은데 확실히 가독성은 좋아진다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final TokenService tokenService;
// /login 요청을하면 로그인 시도를 위해서 실행됨
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// username,password 받아서 정상 계정인지 조회
//authenticationManager로 시도하면 PrincipalDetailService의 loadUserByUsername 함수 실행됨
//principaldetails를 세션에담아서 jwt리턴
ObjectMapper om = new ObjectMapper();
User user = null;
try {
user = om.readValue(request.getInputStream(), User.class);
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(user.getEmail(),user.getPassword());
//principaldetailsservice loadUserByUsername함수가실행됨
//password는 스프링에서 알아서 처리해줌
Authentication authentication = authenticationManager.authenticate(token);
//세션에 authentication저장
return authentication;
} catch (Exception e) {
throw new UsernameNotFoundException("잘못된 정보입니다.");
}
}
//attemptAuthentication이후 정상인증시 실행됨
//jwt토큰 생성후 클라이언트에게 jwt토큰을 응답해줌
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
Date accessTokenExpireDt = new Date(System.currentTimeMillis() + 60000);
Date refreshTokenExpireDt = new Date(System.currentTimeMillis() + Integer.parseInt(JwtTokenInfo.getInfoByKey("refreshExpiration")));
String token = JwtTokenProvider.createAccessToken(principalDetails.getUser().getId(),principalDetails.getUser().getEmail(),accessTokenExpireDt);
String refreshToken = JwtTokenProvider.createRefreshToken(principalDetails.getUser().getId(),principalDetails.getUser().getEmail(),refreshTokenExpireDt);
tokenService.saveRefreshToken(principalDetails.getUser().getId(), refreshToken, refreshTokenExpireDt);
HashMap<String,String> responseMap = new HashMap<>();
responseMap.put("status","success");
response.addHeader(JwtTokenInfo.getInfoByKey("header"),JwtTokenInfo.getInfoByKey("prefix") + token);
response.addHeader(JwtTokenInfo.getInfoByKey("refreshHeader"),JwtTokenInfo.getInfoByKey("prefix") + refreshToken);
new ObjectMapper().writeValue(response.getOutputStream(), responseMap);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
String errorMessage = "";
if(failed instanceof BadCredentialsException) {
errorMessage = "아이디 또는 비밀번호가 맞지 않습니다. 다시 확인해주세요.";
} else if (failed instanceof InternalAuthenticationServiceException) {
errorMessage = "내부 시스템 문제로 로그인 요청을 처리할 수 없습니다. 관리자에게 문의하세요. ";
} else if (failed instanceof UsernameNotFoundException) {
errorMessage = "존재하지 않는 계정입니다. 회원가입 후 로그인해주세요.";
} else if (failed instanceof AuthenticationCredentialsNotFoundException) {
errorMessage = "인증 요청이 거부되었습니다. 관리자에게 문의하세요.";
} else {
errorMessage = "알 수 없는 오류로 로그인 요청을 처리할 수 없습니다. 관리자에게 문의하세요.";
}
HashMap<String,String> responseMap = new HashMap<>();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
responseMap.put("status","fail");
responseMap.put("errorMessage",errorMessage);
new ObjectMapper().writeValue(response.getOutputStream(), responseMap);
}
}
/login 으로 접근시 타게되는 필터다 저번시간에는 성공시 시큐리티 세션에 인증정보를 저장만 했지 리프레쉬 토큰 발급 및 실패시 예외처리는 하지 않았다. 먼저 로그인 성공시 엑세스토큰과 리프레쉬 토큰을 만들어 응답으로 쏴준다. 아직 클라이언트 친구와 얘기는 안해봤는데 리프레시토큰을 클라이언트에서 가지고있을 필요가없다면 추후에 로직을 수정할거다.
만약 잘못된 계정정보를 입력하여 인증을 요청하면
unsuccessfulAuthentication
이 메소드가 호출되게 된다 exception 타입에 따라서 에러메시지를 설정하고 상태값을 실패로 세팅후 응답코드 401로 맞추고 응답한다.
만약 클라이언트가 입력도 하지않은채 request를 요청하면 그냥 아이디를 찾을수 없다는 usernameexception을 떨구게했다. 물론 당연히 클라이언트에서 1차적으로 valid절차를 거쳐서 오는게 맞겠지만...
다음은 인증이 필요한 url로접근시 인가 검증하는 필터에 예외처리를 하였다.
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private UserRepository userRepository;
private TokenService tokenService;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository, TokenService tokenService){
super(authenticationManager);
this.userRepository = userRepository;
this.tokenService = tokenService;
}
//인증이나 권한 요청이 필요한주소가 들어오면 타게되는 메소드
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String jwtHeader = request.getHeader(JwtTokenInfo.getInfoByKey("header"));
if(jwtHeader == null || !jwtHeader.startsWith(JwtTokenInfo.getInfoByKey("prefix"))){
chain.doFilter(request,response);
return;
}
String jwtToken = jwtHeader.replace(JwtTokenInfo.getInfoByKey("prefix"),"");
String email = JwtTokenProvider.validToken(jwtToken, request);
if(StringUtils.hasLength(email)){
User user = userRepository.findByEmail(email);
PrincipalDetails principalDetails = new PrincipalDetails(user);
//jwt토큰 서명이 완료되면 객체생성
Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails,null,principalDetails.getAuthorities());
//강제로 시큐리티 세션에 접근하여 객체저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request,response);
}
}
여기서는 검증로직을 호출만 한다.
토큰관련 생성 및 검증은 따로 스태틱으로 만들어두었다.
@Slf4j
public class JwtTokenProvider {
public static String createAccessToken(Long id, String email, Date expireDt){
return JWT.create()
.withSubject("ATK")
.withExpiresAt(expireDt)
.withClaim("id",id)
.withClaim("email",email)
.sign(Algorithm.HMAC512(JwtTokenInfo.getInfoByKey("secret")));
}
public static String createRefreshToken(Long id, String email, Date expireDt){
return JWT.create()
.withSubject("RTK")
.withExpiresAt(expireDt)
.withClaim("id",id)
.withClaim("email",email)
.sign(Algorithm.HMAC512(JwtTokenInfo.getInfoByKey("secret")));
}
public static String validToken(String jwtToken, HttpServletRequest request){
String email = null;
try{
email = JWT.require(Algorithm.HMAC512(JwtTokenInfo.getInfoByKey("secret"))).build().verify(jwtToken).getClaim("email").asString();
}catch (SignatureVerificationException | SignatureGenerationException e) {
log.info("잘못된 JWT 서명입니다.");
setRequest(request,"SignatureException");
} catch (TokenExpiredException e) {
log.info("만료된 JWT 토큰입니다.");
setRequest(request,"TokenExpiredException");
} catch (IllegalArgumentException | JWTVerificationException e) {
log.info("JWT 토큰이 잘못되었습니다.");
setRequest(request,"VerificationException");
} catch (Exception e){
log.info(e.getMessage());
setRequest(request,"Exception");
}
return email;
}
public static void setRequest(HttpServletRequest request, String errMsg) {
request.setAttribute("exception",errMsg);
}
}
validtoken 메소드에서 클라이언트 측에서 보낸 토큰을 검증하여 실패할경우 각각 exception을 request에 담게된다.
응답은 다른곳에서 처리하게한다.
@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
String exception = (String)request.getAttribute("exception");
if("TokenExpiredException".equals(exception)){
setResponse(response,"tokenExpire");
}else{
setResponse(response,"tokenErr");
}
}
private void setResponse(HttpServletResponse response,String errorMessage) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
HashMap<String,String> responseMap = new HashMap<>();
responseMap.put("status","fail");
responseMap.put("errorMessage",errorMessage);
new ObjectMapper().writeValue(response.getOutputStream(), responseMap);
}
}
이 클래스는 AuthenticationEntryPoint 인터페이스를 구현하여 만약 인증이 정상적으로 되지 않는 경우 호출된다. 토큰만료, 혹은 잘못된 토큰일 경우 응답코드를 401로 세팅해 상태값 및 에러메시지와 함께 응답한다.
만약 인증은 되었는데 해당 유저가 접근권한이 없는 url에 접근할땐
@Component
@Slf4j
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
HashMap<String,String> responseMap = new HashMap<>();
responseMap.put("status","fail");
responseMap.put("errorMessage","accessDenied");
new ObjectMapper().writeValue(response.getOutputStream(), responseMap);
}
}
AccessDenitedHandler 인터페이스를 구현한 클래스가 호출된다.
여기서도 상태코드 403으로 세팅한뒤 응답한다.
예외처리 인터페이스를 구현만 한다고해서 호출되는건 아니라고한다. 시큐리티 컨피그에 별도로 등록해주어야 한다.
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
해당 클래스를 di받아 시큐리티 설정에 추가해주면 된다.
일단 어느정도 예외처리도 하긴했는데 지금 문제가 하나있다.
인가인증을 하는 필터가 시큐리티 컨피그에 설정된 인가 인증이 필요한 url로 요청들어올때만 동작할줄알았는데 어떤 url이든 일단 필터를 타게된다...
뭐물론 인증이 필요없는 url에 토큰값을 넣어보내진 않을거고 안넣어보내면 그냥 필터에서 아무런동작도하지않고 리턴하지만.. 만약 토큰값을 이상한값으로 세팅하고 요청하면 인증이 필요없는 url인데 토큰이 잘못되었다고 요청을 거부하게된다..
필터에 특정url들만 타게하도록 하면되긴하지만 이건 좀 하드코딩 방식이라 맘에 들지 않는다..(분명 인강에선 인증이필요한 url만 필터를 타게된다고 했는데) 이거 해결방안을 좀 생각해봐야겠다. 다음시간엔 엑세스 토큰 만료시 리프레쉬토큰을 이용해 재발급해주는 api와 로그아웃 로직을 한번 만들어 봐야겠다. 끝