JWT Access 토큰 재발급 시, BadCredentialsException 해결 방법

LeeYulhee·2024년 7월 27일

👉 문제 상황


  • 로그인 후에 Access 토큰이 만료되면 Refresh 토큰으로 Access 토큰을 새로 발급 받는 로직 테스트
  • 토큰 재발급 API 호출 → 401 Error 발생
    {
        "timestamp": "2024-07-27T04:17:43.982+00:00",
        "status": 401,
        "error": "Unauthorized",
        "trace": "org.springframework.security.authentication.BadCredentialsException: 자격 증명에 실패하였습니다.\r\n\tat org.springframework.security.authentication.dao.DaoAuthenticationProvider.additionalAuthenticationChecks(DaoAuthenticationProvider.java:93)\r\n\tat org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider.authenticate(AbstractUserDetailsAuthenticationProvider.java:147)\r\n\tat org.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:182)(...중략)",
        "message": "자격 증명에 실패하였습니다.",
        "path": "/users/regenerate"
    }


👉 원인


@Transactional
public JwtTokenRes reGenerateToken(AuthenticatedUserReq user, String refreshToken) {
    String serverRefreshToken = redisService.getValue(user.getEmail());

    if (refreshToken.equals(serverRefreshToken)) {
        Authentication authentication = getAuthentication(user.getEmail(), user.getPassword());
        String accessToken = tokenProvider.generateAccessToken(authentication);
        return JwtTokenRes.from(accessToken, refreshToken);
    }

    throw new IllegalArgumentException("재로그인해주세요.");
}
  • reGenerateToken 메서드에서 getAuthentication을 호출하며 암호화된 비밀번호를 사용하여 인증을 시도하기 때문이었음
  • login 메서드에서 getAuthentication 호출 시에는 평문 비밀번호(사용자가 입력한 비밀번호)를 사용하므로 문제가 발생하지 않았었음



👉 원인 분석 과정


  • 🚨 오류 나는 부분 파악
    @PostMapping("/regenerate")
    public ResponseEntity<JwtTokenRes> reGenerateToken(@AuthenticatedUser AuthenticatedUserReq user,
                                                       @RequestHeader("RefreshToken") String refreshToken) {
        JwtTokenRes res = userService.reGenerateToken(user, refreshToken);
        return ResponseEntity.status(OK).body(res);
    }
    @Transactional
    public JwtTokenRes login(LoginUserReq dto) {
        Authentication authentication = getAuthentication(dto.getEmail(), dto.getPassword());
        String accessToken = tokenProvider.generateAccessToken(authentication);
        String refreshToken = tokenProvider.generateRefreshToken(authentication);
    
        redisService.saveValue(dto.getEmail(), refreshToken);
        return JwtTokenRes.from(accessToken, refreshToken);
    }
    
    private Authentication getAuthentication(LoginUserReq dto) {
        UsernamePasswordAuthenticationToken authenticationToken
                = new UsernamePasswordAuthenticationToken(dto.getEmail(), dto.getPassword());
    
        return authenticationManagerBuilder
                .getObject()
                .authenticate(authenticationToken);
    }
    
    @Transactional
    public JwtTokenRes reGenerateToken(AuthenticatedUserReq user, String refreshToken) {
        String serverRefreshToken = redisService.getValue(user.getEmail());
    
        if (refreshToken.equals(serverRefreshToken)) {
            Authentication authentication = getAuthentication(user.getEmail(), user.getPassword());
            String accessToken = tokenProvider.generateAccessToken(authentication);
            return JwtTokenRes.from(accessToken, refreshToken);
        }
    
        throw new IllegalArgumentException("재로그인해주세요.");
    }
    • login 메서드가 getAuthentication 을 호출할 때는 dto.getPassword() 가 평문으로 들어 감
      • 사용자가 로그인 할 때 입력하는 비밀번호라서
    • reGenerateToken 메서드가 getAuthentication 을 호출할 때는 user.getPassword()가 암호화된 값으로 들어감
      • DB에서 조회한 사용자 정보가 들어가 있어서
    • getAuthentication이 로그인에서는 문제 없이 작동하고 토큰 재발급에서는 Exception이 터짐
      • ⇒ 비밀번호 관련 로직의 문제일 거라 추측

  • 🚨 해당 부분 처리 과정 탐색
    1. getAuthentication에서 UsernamePasswordAuthenticationToken 에서 객체를 생성할 때, email과 password를 입력

    2. AuthenticationManagerBuildergetObject 메서드로 AuthenticationManager 객체 빌드

      AuthenticationManager authenticationManager = authenticationManagerBuilder.getObject();
    3. AuthenticationManager(인터페이스)authenticate 메서드를 호출하여 인증 시도

      Authentication authenticate = authenticationManager.authenticate(authenticationToken);
    4. ProviderManagerAuthenticationManager의 기본 구현체로 여러 AuthenticationProvider를 관리하는데, 3번의 과정에서 ProviderManagerauthenticate 메서드가 호출

      • 아래 ProviderManager 코드를 참고하면 getProviders 를 순회(List로 되어 있음)하며 provider의 supports 메서드 호출
      • 만약 처리할 수 있다면 result = provider.authenticateauthenticate 메서드 호출
      • ProviderManager 코드 일부
        public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
        
        	@Override
        	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        		Class<? extends Authentication> toTest = authentication.getClass();
        		AuthenticationException lastException = null;
        		AuthenticationException parentException = null;
        		Authentication result = null;
        		Authentication parentResult = null;
        		int currentPosition = 0;
        		int size = this.providers.size();
        		for (AuthenticationProvider provider : getProviders()) {
        			if (!provider.supports(toTest)) {
        				continue;
        			}
        			if (logger.isTraceEnabled()) {
        				logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
        						provider.getClass().getSimpleName(), ++currentPosition, size));
        			}
        			try {
        				result = provider.authenticate(authentication);
        				if (result != null) {
        					copyDetails(authentication, result);
        					break;
        				}
        			}
        			catch (AccountStatusException | InternalAuthenticationServiceException ex) {
        				prepareException(ex, authentication);
        				// SEC-546: Avoid polling additional providers if auth failure is due to
        				// invalid account status
        				throw ex;
        			}
        			catch (AuthenticationException ex) {
        				lastException = ex;
        			}
        		}
        		
        		// 중략
        	}
        }
    5. DaoAuthenticationProviderAbstractUserDetailsAuthenticationProvider를 상속 받음 → AbstractUserDetailsAuthenticationProviderAuthenticationProvider 인터페이스를 구현하며, authenticate 메서드를 정의 → 그래서 4번 로직에서 DaoAuthenticationProvider 호출됨

      • authenticate 메서드는 주어진 Authentication 객체를 사용하여 인증을 수행
      • AbstractUserDetailsAuthenticationProvider에서 authenticate 메서드 구현 되어 있음
    6. DaoAuthenticationProviderretrieveUser 메서드에서 UserDetailsServiceloadUserByUsername으로 사용자 정보 로드

    7. DaoAuthenticationProvideradditionalAuthenticationChecks메서드에서 사용자 정보와 인증 요청 정보를 사용하여 추가 인증 검사를 수행

      • ⇒ 여기서 passwordEncoder.mathces로 비밀번호 검증이 이루어짐
        @Override
        @SuppressWarnings("deprecation")
        protected void additionalAuthenticationChecks(UserDetails userDetails,
        		UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        	if (authentication.getCredentials() == null) {
        		this.logger.debug("Failed to authenticate since no credentials provided");
        		throw new BadCredentialsException(this.messages
        			.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        	}
        	String presentedPassword = authentication.getCredentials().toString();
        	if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
        		this.logger.debug("Failed to authenticate since password does not match stored value");
        		throw new BadCredentialsException(this.messages
        			.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        	}
        }
    8. PasswordEncodermatches 메서드는 평문의 암호와 인코딩된 암호를 비교하는 로직

      @Override
      	public boolean matches(CharSequence rawPassword, String encodedPassword) {
      		if (rawPassword == null) {
      			throw new IllegalArgumentException("rawPassword cannot be null");
      		}
      		if (encodedPassword == null || encodedPassword.length() == 0) {
      			this.logger.warn("Empty encoded password");
      			return false;
      		}
      		if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
      			this.logger.warn("Encoded password does not look like BCrypt");
      			return false;
      		}
      		return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
      	}
      • reGenerateToken 메서드가 getAuthentication 을 호출할 때는 rawPassword로 인코딩된 암호가 들어가서 제대로된 비교 불가
      • ⇒ 해당 부분 때문에 계속 BadCredentialsException 발생



👉 해결 방법


  • 같은 클래스의 getAuthentication에 email과 password로 사용자 인증 정보(Authentication)을 받아 오지 않고, refreshToken으로 사용자 인증 정보 추출
    @Transactional
    public JwtTokenRes reGenerateToken(AuthenticatedUserReq user, String refreshToken) {
        String serverRefreshToken = redisService.getValue(user.getEmail());
    
        if (refreshToken.equals(serverRefreshToken)) {
            Authentication authentication = tokenProvider.getAuthentication(refreshToken);
            String accessToken = tokenProvider.generateAccessToken(authentication);
            return JwtTokenRes.from(accessToken, refreshToken);
        }
    
        throw new IllegalArgumentException("재로그인해주세요.");
    }
    public class TokenProvider implements InitializingBean {
    
    		// 중략
    
    		public Authentication getAuthentication(String token) {
    		    Claims claims = parseClaims(token);
    		
    		    if (claims.get(AUTHORITIES_KEY) == null) {
    		        throw new RuntimeException("권한 정보가 없는 토큰입니다.");
    		    }
    		
    		    Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("auth").toString().split(","))
    		            .map(SimpleGrantedAuthority::new)
    		            .collect(Collectors.toList());
    		
    		    UserDetails userDetails = customUserDetailsService.loadUserByUsername(claims.getSubject());
    		    return new UsernamePasswordAuthenticationToken(userDetails, token, authorities);
    		}
    }
  • 위처럼 수정하니 Access Token이 만료된 경우 + Refresh Token이 유효한 경우에 문제 없이 Access Token이 새로 발행됨
profile
끝없이 성장하고자 하는 백엔드 개발자입니다.

0개의 댓글