스프링 부트 - 기본 UserDetailsService 설정

Spring

Posted by kwon on 2020-05-26

기본 UserDetailsService 설정

기본 UserDetailsService를 설정하는 방법을 보기 전에 내가 겪은 문제를 잠시 보자.

로그인과 로그인 유지 기능에서의 문제

앞선 포스팅에서 로그인 기능로그인 유지 기능을 구현하는 방법에 대해 살펴보았다.
로그인 구현에 대한 포스팅에서는 닉네임과 이메일로 모두 로그인이 가능하도록 했었다.
현재 진행중인 프로젝트에는 이메일로만 로그인이 가능하도록 구현을 하였는데, 자동 로그인 기능까지 구현을 하고 보니 문제가 발생했다.
문제는 다음과 같다.

  • Remember-me쿠키가 정상적으로 생성이 되지만 JSESSIONID를 지우고 리디렉션했을 때, 정상적인 경우라면 새로운 JSESSIONID가 생성되며 세션이 유지되어야 하지만 JSESSIONID와 함께 Remember-me쿠키도 같이 사라지고 세션이 사라져버린다.

  • UserDetailsService의 구현체인 AccountServiceloadUserByUsername메서드이다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Transactional(readOnly = true)
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    Account account = accountRepository.findByEmail(email);//login.html form 에서 받은 email 로 회원 검색
    if(account == null){
    throw new UsernameNotFoundException("not found "+email);//해당 이메일이 없을 때 throw
    }
    return new UserAccount(account); // User 를 확장한 UserAccount 클래스에 유저 정보와 권한을 삽입하여 반환
    }
  • 스프링 시큐리티의 User를 상속받은 UserAccount클래스

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Getter
    public class UserAccount extends User { // 스프링 시큐리티의 User를 상속

    private Account account;
    // 스프링 시큐리티가 다루는 유저 정보를 우리가 가지고 있는 도메인의 유저 정보와 연동
    public UserAccount(Account account) {
    super(account.getNickname(), account.getPassword(), List.of(new SimpleGrantedAuthority("ROLE_USER")));
    this.account = account;
    }
    }
  • 위와 같이 구현되어 있었다.

  • 이 때, username에 로그인 시 사용된 스프링 시큐리티의 User클래스를 상속한 UserAccount라는 클래스의 인스턴스로 로그인 처리를 하고 인증 정보를 저장한다.

    • 여기서 생성된 인스턴스의 username자리에 getNickname()을 넣어주었다.
  • JSESSIONID를 지우게 되면 세션이 없어지므로 Remember-me 쿠키에 존재하는 인증 정보로 인증을 시도한다.

  • 문제는, 여기서 다시 인증을 시도할 때, username에 nickname를 넣었으므로 nickname를 가지고 loadUserByUsername메소드로 가 인증을 시도한다.

    • 이 때, 위의 메소드를 보면 파라미터로 String email을 받는데, 이 email이라는 변수에 nickname이 들어와서 인증을 시도하게 된다.
    • 하지만, 이메일만으로 accountRepository를 검색해보기 때문에 account 객체를 찾지 못하게 되어 재인증이 실패하게 되는 것이다.
  • 따라서, 이 문제를 해결하기 위해서는 두 가지 방법이 있다.

첫 번째 방법

  • UserAccount에서 아래 코드와 같이 username부분에 이메일을 넣어주면 된다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 로그인 시 유저 정보를 반환하기 위해 User 클래스를 확장하여 생성
    @Getter
    public class UserAccount extends User {

    private Account account;
    // 스프링 시큐리티가 다루는 유저 정보를 우리가 가지고 있는 도메인의 유저 정보와 연동
    public UserAccount(Account account) {
    super(account.getEmail(), account.getPassword(), List.of(new SimpleGrantedAuthority("ROLE_USER")));
    this.account = account;
    }
    }
  • 위와 같이 하면 Remember-me 쿠키의 username에 이메일이 저장되고, 그 이메일로 사용자를 찾게 되므로 로그인 유지가 정상적으로 동작하게 된다.
  • 하지만 이렇게 하면 authentication 객체의 username에 이메일이 저장되기 때문에 타임리프에서 #authentication.name로 접근했을 때 사용자의 닉네임이 안이라 이메일을 얻게 된다.
  • 나는 닉네임을 얻고 싶기 때문에 다른 방법을 선택했다.

두 번째 방법

  • UserAccount의 username에 닉네임을 넣은 경우 Remember-me 쿠키의 username에 닉네임이 들어가게 되므로 JSESSIONID가 사라진 경우 재인증을 시도하기 위해 loadUserByUsername가 다시 호출된다.
  • 이 때, loadUserByUsername의 인자에 닉네임이 전달되는데 현재 코드에서는 이메일로만 사용자 정보를 찾으므로 이메일로 찾지 못한 경우 닉네임으로 사용자 정보를 찾을 수 있도록 다음과 같이 작성한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Override
    public UserDetails loadUserByUsername(String emailOrNickname) throws UsernameNotFoundException {
    Account account = accountRepository.findByEmail(emailOrNickname);
    if(account == null){ // 이메일로 찾지 못한 경우 닉네임으로 찾는다.
    account = accountRepository.findByNickname(emailOrNickname);
    }
    if(account == null){ // 닉네임으로도 찾지 못한다면 에러를 던짐
    throw new UsernameNotFoundException(emailOrNickname);
    }
    // Principal 에 해당하는 객체를 리턴한다.
    return new UserAccount(account);
    }
  • 하지만 위와 같이 구현하면 로그인 폼에서 하는 일반 로그인 또한 이메일과 닉네임으로 모두 가능하게 된다.
  • 나는 이메일로만 로그인을 하게 하고 싶기 때문에 이 방법도 사용하지 않았다.

다른 방법

  • 이메일로만 로그인이 가능하고, 로그인을 했을 때 타임리프에서 #authentication.name으로 닉네임을 가져올 수 있는 방법이다.

    • 즉, UserAccount의 username에는 닉네임을 넣고 loadUserByUsername에서는 이메일로만 사용자를 찾는 것을 유지하고 로그인 유지 기능 또한 정상적으로 동작할 수 있는 방법이다.
  • 방법은 RememberMe 필터가 사용할 UserDetailsService를 하나 더 만들면 된다.

  • 다음과 같이 RememberMeUserDetailsService라는 이름으로 UserDetailsService구현체를 하나 더 만들었다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @Service
    @RequiredArgsConstructor
    public class RememberMeUserDetailsService implements UserDetailsService {
    // 로그인 유지 기능 사용 시 닉네임을 통해 데이터베이스를 조회하여 사용자 정보를 가져오기 위한 UserDetailsService 의 구현체
    private final AccountRepository accountRepository;
    @Transactional(readOnly = true)
    @Override
    public UserDetails loadUserByUsername(String nickname) throws UsernameNotFoundException {
    Account byNickname = accountRepository.findByNickname(nickname);

    if (byNickname == null) {
    throw new UsernameNotFoundException(nickname);
    }
    return new UserAccount(byNickname);
    }
    }
  • SecurityConfig

    1
    2
    3
    4
    5
    6
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.rememberMe()
    .userDetailsService(rememberMeUserDetailsService)
    .tokenRepository(tokenRepository());
    }
  • 기존에 .userDetailsService(accountService)를 위와 같이 변경했다.

  • 이렇게 되면 RememberMe에서는 사용자의 닉네임으로 db를 조회하므로 정상적으로 로그인 유지가 가능하다.

  • 하지만 기존 로그인 폼에서의 로그인에는 문제가 발생한다.

  • UserDetailsService의 구현체가 두 개이기 때문에 어떤 것을 기본으로 로그인 시 사용할지 모르게 된다.

  • 이 경우 기본으로 사용할 UserDetailsService를 아래와 같이 설정할 수 있다.

  • SecurityConfig

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final AccountService accountService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    // 스프링 시큐리티가 사용할 기본 AuthManager 에 이메일로만 로그인하는 UserDetailsService 를 설정하는 코드
    auth.userDetailsService(accountService);
    }
    }
  • 위와 같이 설정하여 내가 원하는 기능을 구현할 수 있었다.

참조
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-JPA-%EC%9B%B9%EC%95%B1/dashboard