1. Spring Security의 구조
1.1 유저가 로그인을 시도한다.
1.2 AuthenticationFilter
에서 UserDetails
로 User DB에 접근한다.
1.3 DB에 있는 user라면 UserDetails
에서 꺼내 session을 생성한다.
1.4 spring security의 인메모리 세션저장소인 SecurityContextHolder
에 저장한다.
1.5 user에게 session ID와 함께 응답한다.
1.6 이후 사용자의 요청에서는 요청 쿠키에서 JSESSIONID를 검증해 유효하면 Authentication
을 준다.
2. Spring Security - 인증 절차 인터페이스
2-1. UserDetails
각기 다른 어플리케이션의 User에 해당하는 Model에 UserDetails
를 implements하여
spring security가 이애할 수 있는 형태의 User로 만들어줘야 한다.
로그인 로직에서 사용자가 입력한 정보와 DB에 저장된 사용자의 정보를 비교해야 하는데
UserDetails
인터페이스는 사용자의 정보를 담아두는 VO 역할을 한다고 할 수 있다.
UserDetails
인터페이스를 구현하려면 오러바이드해야 하는 메소드들이 있다.
사용자 정보에 관한 다른 정보를 추가해도 된다.
(다른 멤버변수들은 getter, setter를 만들어서 사용하면 된다.)
-
Collection<? extends GrantedAuthority> getAuthorities()
계정이 갖고있는 권한 목록을 리턴한다. -
String getPassword()
계정의 비밀번호를 리턴한다. -
String getUsername()
계정의 이름을 리턴한다. -
boolean isAccountNonExpired()
계정이 만료되지 않았는지 리턴한다. (true : 만료되지 않음) -
boolean isAccountNonLocked()
계정이 잠겨있지 않았는지 리턴한다. (true : 잠기지 않음) -
boolean isCredentialNonExpired()
비밀번호가 만료되지 않았는지 리턴한다. (true : 만료되지 않음) -
boolean isEnabled()
계정이 활성화(사용가능)인지 리턴한다. (true: 활성화)
UserDetails
인터페이스는 User Entity 클래스에 구현하지 않고,
CustomUserDetails
클래스에 implements한다.
CustomUserDetails.java
public class CustomUserDetails implements UserDetails {
private Long id;
private String email;
private String password;
@Enumerated(EnumType.STRING)
private Role userType;
private boolean enabled;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() { return null; }
@Override
public boolean isAccountNonExpired() { return false; }
@Override
public boolean isAccountNonLocked() { return false; }
@Override
public boolean isCredentialsNonExpired() { return false; }
}
위 코드는 이번 프로젝트에서 사용한 UserDetails
인터페이스의 일부이다.
2-2. UserDetailsService
UserDetailsService
인터페이스에는
DB에서 사용자 정보를 불러오는 메소드를 오버라이드 해야 한다.
loadUserByUsername()
메소드이다.
이 메소드에서 사용자 정보를 불러오는 작업을 하면 된다.
CustomUserDetails
타입으로 사용자의 정보를 가져오면 된다.
사용자 정보 유/무에 따라 예외, 사용자 정보를 리턴하면 된다.
@RequiredArgsConstructor
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return (UserDetails) userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
}
}
위 코드는 이번 프로젝트에서 사용한 UserDetailsService
인터페이스의 일부이다.
2-3. AuthenticationProvider
AuthenticationProvider
인터페이스는
사용자가 입력한 로그인 정보와 DB에서 가져온 사용자의 정보를 비교해주는 인터페이스이다.
해당 인터페이스에 오버라이드되는 authenticate()
메소드는
화면에 사용자가 입력한 로그인 정보를 담고있는 Authentication
객체를 갖고 있다.
authenticate()
메소드에서 로그인 인증을
성공하면 Authentication
객체를 리턴하고
실패하면 그에 맞는 Exception을 던진다.
2-4. AuthenticationFailureHandler
spring security에서 로그인 실패를 담당하는 인터페이스아다.
SecurityConfig
에서 formLogin()
에 설정하면 로그인이 실패했을 떄 호출되며 이에 대한 처리를 수행한다.
사용자가 왜 로그인할 수 없는지 자세한 에러 메시지를 출력해주고 싶어서
AuthenticationFailureHandler
를 구현했다.
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
AuthenticationException e) throws IOException, ServletException {
if(e instanceof ValidationFailedException){
httpServletResponse.sendRedirect("/users/signin?error=0");
}
else if(e instanceof UsernameNotFoundException){
httpServletResponse.sendRedirect("/users/signin?error=1");
}
else if(e instanceof DisabledException) {
httpServletResponse.sendRedirect("/users/signin?error=2");
}
else {
httpServletResponse.sendRedirect("/users/signin?error=3");
}
}
위 코드는 이번 프로젝트에서 사용한 AuthenticationFailureHandler
인터페이스의 일부이다.
Validation
으로 에러를 출력해주고 싶은데
spring security로 로그인 로직을 처리하려고 하니까
spring boot가 접근을 못해서 에러 메시지가 출력되지 않았다.
그래도 사용자에게 자세히 에러 메시지를 출력해주고 싶어서
ValidationFailedException
을 생성해 sendRedirect
로 파라미터에 에러 코드를 전달했다.
public Authentication authenticate(String email, String password) throws AuthenticationException {
UserSigninDto userSigninDto = UserSigninDto.builder()
.email(email)
.password(password)
.build();
// 사용자가 입력한 form data 형식이 맞는지 검사
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
validator.validate(userSigninDto).stream().forEach(x -> {
throw new ValidationFailedException(x.getMessage());
});
CustomUserDetails user = (CustomUserDetails) userDetailsService.loadUserByUsername(email);
위 코드는 Validation
을 위한 authenticate
메소드의 일부이다.
userSigninDto).stream().forEach(x -> {
이 부분에서 어떤 Validation
에러 인지 전달하고 싶었는데
여러 에러가 발생하는 경우, 여러 메시지를 전달하는건
이 메소드 안에서 해결할 수 없어서 일단 사용자에게 입력 형식이 잘못되었다는
메시지만 전달하려고 한다.
3. Spring Security 예외
-
BadCredentialException
- 비밀번호가 일치하지 않을 때 던지는 예외 -
InternalAuthenticationServiceException
- 존재하지 않는 아이디일 때 던지는 예외 -
AuthenticationCredentialNotFoundException
- 인증 요구가 거부됐을 때 던지는 예외 -
LockedException
- 인증 거부 (잠긴 계정) -
DisabledException
- 인증 거부 (계정 비활성화) -
AccountExpiredException
- 인증 거부 (계정 유효기간 만료) -
CredentialExpiredException
- 인증 거부 (비밀번호 유효기간 만료)