본문 바로가기
Spring

스프링 시큐리티 인증 프로세스와 데이터베이스

by hseong 2023. 8. 12.

인증 처리 복습

AbstractAuthenticationProcessingFilter는 사용자의 인증을 위한 정보를 모아서 미완성의 Authentication 객체를 만듭니다.

구현체를 기준으로 다시 정리해봅시다.

UsernamePasswordAuthenticationFilter는 HTTP 요청으로부터 인증을 위한 파라미터를 뽑아냅니다. 이를 토대로 인증되지 않았고 권한도 없는 UsernamePasswordAuthenticationToken 객체를 만듭니다.

생성된 Authentication 객체는 AuthenticationManager로 전달되어 인증 프로세스를 수행합니다.

다시 한 번 구현체를 기준으로 정리해봅시다.

생성된 UsernamePasswordAuthenticationToken 객체는 ProviderManger로 전달되어 인증 프로세스를 수행합니다.

ProviderManagerAuthenticationProvider 목록을 순회하며 Authentication 객체의 인증 처리 작업을 수행가능한 *Provider 객체를 찾습니다. UsernamePasswordAuthenticationToken 객체의 경우 DaoAuthenticationProvider가 처리를 담당하게 됩니다.

다음은 해당 Provider의 추상 클래스인 AbstractUserDetailsAuthenticationProvider 클래스의 일부입니다.

AbstractUserDetailsAuthenticationProvider.java

@Override
public boolean supports(Class<?> authentication) {
    return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}

데이터베이스 조회

공식문서에서 제공해주는 다음 그림에 따라 ProviderManagerDaoAuthenticationProviderauthenticate 메서드를 호출하였을 때, 어떠한 흐름으로 사용자 인증 처리가 이루어지는지 코드를 살펴보며 따라가보겠습니다.

ProviderManagerDaoAuthenicationProviderauthenticate 메서드를 호출하면 다음 코드가 실행됩니다.

우선 Authentication 객체로부터 사용자 이름을 뽑아내고 데이터베이스에서 사용자 인증 정보를 조회하는 작업을 UserDetailsService 객체에 요청합니다.

AbstractUserDetailsAuthenticationProvider.java

@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");
    }
    ...
}

DaoAuthenticationProvider.java

@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);
    }
}
public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

사용자 이름을 통해 성공적으로 회원을 조회하였다면 사용자 이름, 패스워드, 권한 목록 등이 담긴 UserDetails 객체가 반환됩니다.

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    String getPassword();
    String getUsername();
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();
    boolean isCredentialsNonExpired();
    boolean isEnabled();
}

DaoAuthenticationProvider는 반환된 UserDetails 객체를 대상으로 각종 check를 수행합니다.

우선 계정이 잠겼는지, 사용 가능한지, 만료되었는지를 검사합니다.(this.preAuthenticationChecks.check(user))

다음 사용자의 비밀번호가 입력된 비밀번호와 일치하는지를 검사합니다.(additionalAuthenticationChecks) 이 과정에서 사용자가 입력한 비밀번호가 UserDetails 객체의 비밀번호와 일치하는지의 여부를 검사하는 작업을 PasswordEncoder 객체에게 요청합니다.

마지막으로 비밀번호가 만료되지는 않았는지 여부를 검사합니다.(this.postAuthenticationChecks.check(user))

AbstractUserDetailsAuthenticationProvider.java

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    ...
    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);
    ...
}

DaoAuthenticationProvider.java

@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"));
    }
}

사용자에 대한 인증 작업이 완료되면 DaoAuthenticationProvider는 인증이 완료된 Authentication 객체(UsernamePasswordAuthenticationToken)를 반환하는 것으로 인증 프로세스가 종료됩니다.

AbstractUserDetailsAuthenticationProvider.java

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException{
    ...
    if (!cacheWasUsed) {
        this.userCache.putUserInCache(user);
    }
    Object principalToReturn = user;
    if (this.forcePrincipalAsString) {
        principalToReturn = user.getUsername();
    }
    return createSuccessAuthentication(principalToReturn, 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;
}

구현

데이터베이스에서 회원 정보를 조회하여 인증 프로세스를 진행할 수 있도록 구현을 해보겠습니다.

스프링 시큐리티는 UserDetailsService의 구현체로 InMemoryUserDetailsManager, JdbcUserDetailsManager와 같은 인메모리, JDBC 구현체 등을 제공합니다.

만일 JPA를 사용하고자 한다면 UserDetailsServiceloadUserByUsername 메서드를 구현하면 됩니다.

우선 회원 엔티티를 추가해주고, 권한 목록을 나타내기 위한 엔티티를 추가해 줄 것입니다. 이 때, 회원과 권한 사이에 그룹이라는 간접 계층을 둡니다. 이로써 회원이 특정 그룹에 속하면 그룹에 속한 권한이 회원에게 일괄적으로 적용됩니다. 이를 Group-based Access Control이라 합니다.

User는 특정한 Group에 속할 것입니다. 그리고 Group은 특정한 Permission 목록을 가지게 됩니다. 이 때, GroupPermission의 다대다 연관관계를 GroupPermission을 추가하여 일대다, 다대일 연관관계로 풀어줍니다.

@Getter
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    private String password;

    @ManyToOne
    @JoinColumn(name = "group_id")
    private Group group;

    public List<GrantedAuthority> getAuthorities() {
        return group.getAuthorities();
    }
}
@Getter
@Entity
@Table(name = "groups")
public class Group {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "group", fetch = FetchType.LAZY)
    private List<GroupPermission> permissions = new ArrayList<>();

    public List<GrantedAuthority> getAuthorities() {
        return permissions.stream()
                .map(gp -> new SimpleGrantedAuthority(gp.getPermission().getName()))
                .collect(Collectors.toList());
    }
}
@Getter
@Entity
@Table(name = "permissions")
public class Permission {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
}
@Getter
@Entity
@Table(name = "group_permission")
public class GroupPermission {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "group_id")
    private Group group;

    @ManyToOne
    @JoinColumn(name = "permission_id")
    private Permission permission;
}

UserDetailsService를 구현한 UserSerivce 클래스를 추가합니다. UserRepository에서 회원을 조회한 뒤 org.springframework.security.core.userdetails.User.UserBuilder를 이용하여 UserDetails 인터페이스의 구현체인 User 객체를 만들어 반환합니다.

@Service
public class UserService implements UserDetailsService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByUsername(username)
                .map(user ->
                    User.builder()
                            .username(user.getUsername())
                            .password(user.getPassword())
                            .authorities(user.getAuthorities())
                            .build()
                )
                .orElseThrow(() -> new UsernameNotFoundException("Could not found user for " + username));
    }
}

User 엔티티의 권한을 조회하는 과정에서 N+1이 발생하지 않도록 findByUsername은 페치 조인을 사용하도록 JPQL을 작성해줍니다.

이제 잘 작동하는지 확인해보겠습니다.

DaoAuthenticationProvider가 사용자 검색을 UserDetailsService에게 요청하는 부분에 브레이크 포인트를 걸고 디버그 모드로 실행해줍니다.

직접 추가한 UserService 객체에 사용자에 대한 검색을 요청하고 있는 것을 확인할 수 있습니다.

참조

프로그래머스 데브코스 백엔드

Spring Security Docs