Notice
Recent Posts
Recent Comments
Link
«   2025/10   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

Kuma's Curious Paradise

테스트 코드2 - 시큐리티 필터 테스트 만들기 본문

스프링

테스트 코드2 - 시큐리티 필터 테스트 만들기

쿠마냥 2024. 6. 2. 16:14

[ 문제와 해결 ]

1. Filter 테스트 코드를 작성하던 중 문제가 생겼다. 

 

2. 오류 메시지와 원인은 다음과 같다. 

java.lang.ClassCastException: class java.lang.String cannot be cast to class org.springframework.security.core.userdetails.UserDetails (java.lang.String is in module java.base of loader 'bootstrap'; org.springframework.security.core.userdetails.UserDetails is in unnamed module of loader 'app')

 

- 오류 메시지의 의미는 String은 UserDetails 객체로 캐스팅할 수 없다는 뜻. 

 

- 이 부분에서 authResult를 loginRequest를 인자로 받아 만들려 하자

Authentication authResult = new UsernamePasswordAuthenticationToken(loginRequest.email(), loginRequest.password());

 

-  아래의 필터 부분을 통과하면서 문제가 생긴 것. authReulst의 Principal에는 String이 아니라 UserDetails 타입이 들어 있어야 했다. 

 

3. 따라서 테스트를 아래와 같이 수정하였다. 

Authentication authResult = new UsernamePasswordAuthenticationToken(memberDetails, null, memberDetails.getAuthorities());

 

[ 혼란스러운 지점 ] 

- 'MemberJwtLoginFilter'에서 해당 토큰인 UsernamePasswordAuthenticationToken을 만드는 부분이다. 

- 문제가 되는 테스트 코드에 적은 것과 같이 로그인 정보인 이메일(requestDto.email())과 패스워드(requestDto.password())를 인자로 받아서 Authentication 객체를 생성하고 있다. 

- 그런데 왜 문제가 발생하는 것일까?

 

[ 시큐리티의 authenticate() 과정 살펴보기 ] 

1. 다시 이곳으로 돌아가 보자. 이 메서드의 이름은 attemptAuthentication. 즉 인증을 시도하는 메서드이다. 

시큐리티는 인증을 시도하며 Authentication 객체(=UsernamePasswordAuthenticationToken)을 하나 만드는데, 이건 인증을 할 때 쓰이는 용도다.

시큐리티는 getAuthenticationManager().authenticate()를 호출하여 해당 Authentication 객체를 provider manager에게 건넨다. 

 

이 객체를 들어가 보면, setAuthenticated(false) 부분을 볼 수 있는데, 이는 아직 이 토큰이 인증을 받지 못햇다는 의미다. 

 

 

2. 시큐리티는 해당 Authentication 객체를 provider manager에게 건넨다. provider manager는 'MemberSecurityConfig'에서 지정한 DaoAuthenticationProvider를 사용한다. 

 

3. 이제 DaoAuthenticationProvider 클래스로 들어가 보자. authenticate() 메서드를 찾아 보지만 보이지 않는다. DaoAuthenticationProvider의 상위 클래스인 AbstractUserDetailsAuthenticationProvider로 향한다. 

 

4. 이곳(AbstractUserDetailsAuthenticationProvider.class)에 authenticate()가 있다. 

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
            () -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
                    "Only UsernamePasswordAuthenticationToken is supported"));
    String username = determineUsername(authentication);
    boolean cacheWasUsed = true;
    UserDetails user = this.userCache.getUserFromCache(username);
    if (user == null) {
        cacheWasUsed = false;
        try {
            user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
        }
        catch (UsernameNotFoundException ex) {
            this.logger.debug("Failed to find user '" + username + "'");
            if (!this.hideUserNotFoundExceptions) {
                throw ex;
            }
            throw new BadCredentialsException(this.messages
                .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        }
        Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
    }
    try {
        this.preAuthenticationChecks.check(user);
        additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
    }
    catch (AuthenticationException ex) {
        if (!cacheWasUsed) {
            throw ex;
        }
        // There was a problem, so try again after checking
        // we're using latest data (i.e. not from the cache)
        cacheWasUsed = false;
        user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
        this.preAuthenticationChecks.check(user);
        additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
    }
    this.postAuthenticationChecks.check(user);
    if (!cacheWasUsed) {
        this.userCache.putUserInCache(user);
    }
    Object principalToReturn = user;
    if (this.forcePrincipalAsString) {
        principalToReturn = user.getUsername();
    }
    return createSuccessAuthentication(principalToReturn, authentication, user);
}

 

cache 부분은 일단 이해하지 않고 넘어가기로 한다. 그 부분을 빼면 이 메서드는 크게 다섯 단계로 나누어진 간단한 메서드다. 

 

4-1. retrieveUser()로 DB에서 user를 불러온다. 해당 메서드는 DaoAuthenticationProvider 클래스에 구현되어 있다. 

user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
@Override
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
        throws AuthenticationException {
    prepareTimingAttackProtection();
    try {
        UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
        if (loadedUser == null) {
            throw new InternalAuthenticationServiceException(
                    "UserDetailsService returned null, which is an interface contract violation");
        }
        return loadedUser;
    }
    catch (UsernameNotFoundException ex) {
        mitigateAgainstTimingAttack(authentication);
        throw ex;
    }
    catch (InternalAuthenticationServiceException ex) {
        throw ex;
    }
    catch (Exception ex) {
        throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
    }
}

 

4-2. preAuthenticationChekcs.check()에서는 userDetails 객체의 기본적인 것들(해당 user가 locked되어 있지는 않은지, isEnabled인지, Expired되어 있지는 않은지)을 체크한다. 해당 메서드는 내부 클래스로 아래와 같이 구현되어 있다. 

this.preAuthenticationChecks.check(user);
private class DefaultPreAuthenticationChecks implements UserDetailsChecker {

    @Override
    public void check(UserDetails user) {
        if (!user.isAccountNonLocked()) {
            AbstractUserDetailsAuthenticationProvider.this.logger
                .debug("Failed to authenticate since user account is locked");
            throw new LockedException(AbstractUserDetailsAuthenticationProvider.this.messages
                .getMessage("AbstractUserDetailsAuthenticationProvider.locked", "User account is locked"));
        }
        if (!user.isEnabled()) {
            AbstractUserDetailsAuthenticationProvider.this.logger
                .debug("Failed to authenticate since user account is disabled");
            throw new DisabledException(AbstractUserDetailsAuthenticationProvider.this.messages
                .getMessage("AbstractUserDetailsAuthenticationProvider.disabled", "User is disabled"));
        }
        if (!user.isAccountNonExpired()) {
            AbstractUserDetailsAuthenticationProvider.this.logger
                .debug("Failed to authenticate since user account has expired");
            throw new AccountExpiredException(AbstractUserDetailsAuthenticationProvider.this.messages
                .getMessage("AbstractUserDetailsAuthenticationProvider.expired", "User account has expired"));
        }
    }

}

 

4-3. additionalAuthenticationChecks()에서는 passwordEncoder.matches()를 호출하여  사용자가 입력한 패스워드가 DB에 저장되어 있는 패스워드와 같은지 확인한다. 해당 메서드는 DaoAuthenticationProvider 클래스에 구현되어 있다.

additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
@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"));
    }
}

 

4-4. postAuthenticationChecks.check()는 사용자의 비밀번호가 expired된 것은 아닌지 확인한다. 

this.postAuthenticationChecks.check(user);
private UserDetailsChecker postAuthenticationChecks = new DefaultPostAuthenticationChecks();
    
private class DefaultPostAuthenticationChecks implements UserDetailsChecker {

    @Override
    public void check(UserDetails user) {
        if (!user.isCredentialsNonExpired()) {
            AbstractUserDetailsAuthenticationProvider.this.logger
                .debug("Failed to authenticate since user account credentials have expired");
            throw new CredentialsExpiredException(AbstractUserDetailsAuthenticationProvider.this.messages
                .getMessage("AbstractUserDetailsAuthenticationProvider.credentialsExpired",
                        "User credentials have expired"));
        }
    }

}

 

4-5. 이 모든 과정이 끝나면 createSucessAuthentication()을 통해 새로운 Authentication 객체를 발급한다. 

return createSuccessAuthentication(principalToReturn, authentication, user);

 

@Override
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
        UserDetails user) {
    boolean upgradeEncoding = this.userDetailsPasswordService != null
            && this.passwordEncoder.upgradeEncoding(user.getPassword());
    if (upgradeEncoding) {
        String presentedPassword = authentication.getCredentials().toString();
        String newPassword = this.passwordEncoder.encode(presentedPassword);
        user = this.userDetailsPasswordService.updatePassword(user, newPassword);
    }
    return super.createSuccessAuthentication(principal, authentication, user);
}
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
        UserDetails user) {
    // Ensure we return the original credentials the user supplied,
    // so subsequent attempts are successful even with encoded passwords.
    // Also ensure we return the original getDetails(), so that future
    // authentication events after cache expiry contain the details
    UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken.authenticated(principal,
            authentication.getCredentials(), this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
    result.setDetails(authentication.getDetails());
    this.logger.debug("Authenticated user");
    return result;
}

 

4-6. 이렇게 만들어진 객체는 setAuthentication(true) 딱지가 붙은 인증이 완료된 Authentication 객체다. UsernamePasswordAuthenticationToken 클래스에 해당 내용이 들어 있다. 

 

[ 결론 ]

테스트 코드에서는 authenticate()가 완료된 후 authResult를 반환하려 하는데, 이때의 authentication 객체는 setAuthenticated가 true인, 인증이 완료된 객체이기 때문에 아래처럼 만들었어야 하는 것! 테스트가 잘 통과된 것을 확인할 수 있다.