{
"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()가 암호화된 값으로 들어감getAuthentication이 로그인에서는 문제 없이 작동하고 토큰 재발급에서는 Exception이 터짐getAuthentication에서 UsernamePasswordAuthenticationToken 에서 객체를 생성할 때, email과 password를 입력
AuthenticationManagerBuilder의 getObject 메서드로 AuthenticationManager 객체 빌드
AuthenticationManager authenticationManager = authenticationManagerBuilder.getObject();
AuthenticationManager(인터페이스)의 authenticate 메서드를 호출하여 인증 시도
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
ProviderManager는 AuthenticationManager의 기본 구현체로 여러 AuthenticationProvider를 관리하는데, 3번의 과정에서 ProviderManager의 authenticate 메서드가 호출
ProviderManager 코드를 참고하면 getProviders 를 순회(List로 되어 있음)하며 provider의 supports 메서드 호출result = provider.authenticate 로 authenticate 메서드 호출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;
}
}
// 중략
}
}DaoAuthenticationProvider는 AbstractUserDetailsAuthenticationProvider를 상속 받음 → AbstractUserDetailsAuthenticationProvider는 AuthenticationProvider 인터페이스를 구현하며, authenticate 메서드를 정의 → 그래서 4번 로직에서 DaoAuthenticationProvider 호출됨
authenticate 메서드는 주어진 Authentication 객체를 사용하여 인증을 수행AbstractUserDetailsAuthenticationProvider에서 authenticate 메서드 구현 되어 있음DaoAuthenticationProvider의 retrieveUser 메서드에서 UserDetailsService의 loadUserByUsername으로 사용자 정보 로드
DaoAuthenticationProvider의 additionalAuthenticationChecks메서드에서 사용자 정보와 인증 요청 정보를 사용하여 추가 인증 검사를 수행
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"));
}
}PasswordEncoder의 matches 메서드는 평문의 암호와 인코딩된 암호를 비교하는 로직
@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로 인코딩된 암호가 들어가서 제대로된 비교 불가@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이 새로 발행됨