인증 처리 복습
AbstractAuthenticationProcessingFilter
는 사용자의 인증을 위한 정보를 모아서 미완성의 Authentication
객체를 만듭니다.
구현체를 기준으로 다시 정리해봅시다.
UsernamePasswordAuthenticationFilter
는 HTTP 요청으로부터 인증을 위한 파라미터를 뽑아냅니다. 이를 토대로 인증되지 않았고 권한도 없는 UsernamePasswordAuthenticationToken
객체를 만듭니다.
생성된 Authentication
객체는 AuthenticationManager
로 전달되어 인증 프로세스를 수행합니다.
다시 한 번 구현체를 기준으로 정리해봅시다.
생성된 UsernamePasswordAuthenticationToken
객체는 ProviderManger
로 전달되어 인증 프로세스를 수행합니다.
ProviderManager
는 AuthenticationProvider
목록을 순회하며 Authentication
객체의 인증 처리 작업을 수행가능한 *Provider
객체를 찾습니다. UsernamePasswordAuthenticationToken
객체의 경우 DaoAuthenticationProvider
가 처리를 담당하게 됩니다.
다음은 해당 Provider
의 추상 클래스인 AbstractUserDetailsAuthenticationProvider
클래스의 일부입니다.
AbstractUserDetailsAuthenticationProvider.java
@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
데이터베이스 조회
공식문서에서 제공해주는 다음 그림에 따라 ProviderManager
가 DaoAuthenticationProvider
의 authenticate
메서드를 호출하였을 때, 어떠한 흐름으로 사용자 인증 처리가 이루어지는지 코드를 살펴보며 따라가보겠습니다.
ProviderManager
가 DaoAuthenicationProvider
의 authenticate
메서드를 호출하면 다음 코드가 실행됩니다.
우선 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를 사용하고자 한다면 UserDetailsService
의 loadUserByUsername
메서드를 구현하면 됩니다.
우선 회원 엔티티를 추가해주고, 권한 목록을 나타내기 위한 엔티티를 추가해 줄 것입니다. 이 때, 회원과 권한 사이에 그룹이라는 간접 계층을 둡니다. 이로써 회원이 특정 그룹에 속하면 그룹에 속한 권한이 회원에게 일괄적으로 적용됩니다. 이를 Group-based Access Control이라 합니다.
User
는 특정한 Group
에 속할 것입니다. 그리고 Group
은 특정한 Permission
목록을 가지게 됩니다. 이 때, Group
과 Permission
의 다대다 연관관계를 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' 카테고리의 다른 글
낙관적 락과 동시성 테스트하기 (0) | 2023.09.16 |
---|---|
분산 환경에서의 스프링 스케줄러 사용 (0) | 2023.09.10 |
스프링 빈에 관하여 (0) | 2023.06.30 |
스프링 부트가 제공하는 프로덕션 준비 기능 (0) | 2023.05.29 |
스프링 부트에서 외부 설정에 접근하는 방법 (0) | 2023.05.29 |