Spring Security : Signin


1. Spring Security의 구조

스크린샷 2020-04-18 오전 12 28 42



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를 만들어서 사용하면 된다.)

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 예외