Spring/Spring Security

스프링 시큐리티 2. 인증(AuthenticationProvider 작동 방식과 커스텀 AuthenticationProvider 작성)

Recfli 2024. 3. 16. 19:46

[ 스프링 시큐리티 기본 제공 인증 ]

 스프링에서는 기본적으로 로그인 기능을 제공한다. 1에서 따로 계정을 만들 수 있는 로직을 만든 이유는 이 기본 설정을 바꿔 원하는 객체로 회원가입을 하고 로그인하는 서비스를 만들기 위함이다. 이를 달성하기 전에 스프링 시큐리티에서 기본 제공하는 로그인 기능을 확인해보고 무엇을 바꾸면 되는지를 알아보자.

 

 기본 로그인을 확인하기 위해선 1편 프로젝트를 그대로 사용하고 SecurityCheckController라는 파일을 만들고 하단의 코드를 작성해주자.

 

SecurityCheckController.java

@RestController
public class SecurityCheckController {

    @GetMapping("/security")
    public String checkSecurityDefault(){
        return "Hello Security Default!";
    }
}

 

 그리고 추가적으로 API를 만들었으니 SecurityConfig에 가서 해당 코드는 인증된 사용자에게만 허용한다는 의미로 SucurityFilterChain을 다음처럼 바꿔주자.

 

SecurityConfig.java

@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http.csrf((csrf) -> csrf.disable())
            .authorizeHttpRequests((requests) -> requests
                    .requestMatchers("/security").authenticated()
                    .requestMatchers("/register").permitAll())
            .formLogin(Customizer.withDefaults())
            .httpBasic(Customizer.withDefaults());
    return http.build();
}

 

 이제 서버를 띄우고 localhost:8080/security로 접속을 해보면 이상하게 localhost:8080/login으로 이동하고 아래의 이미지와 같은 화면을 보게 된다. 그 이유는 스프링 시큐리티에서 기본 제공하는 로그인 옵션을 사용하도록 설정을 해놓았기 때문이다.

 

 그럼 스프링 시큐리티에선 기본 제공하는 로그인 계정은 무엇이고 어떻게 회원을 만들 수 있을까라는 의문이 들 것이다. 그것은 InMemoryUserDetailsManger라는 클래스를 확인해보면 알 수 있다. 해당 클래스 내부에는 UserDetails라는 객체 컬렉션을 받아서 DB가 아닌 메모리 상에 username과 password 쌍으로 된 인증객체와 역할을 저장하는 메서드가 있다.

public class InMemoryUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {
	public InMemoryUserDetailsManager(Collection<UserDetails> users) {
		for (UserDetails user : users) {
			createUser(user);
		}
	}
    	@Override
	public void createUser(UserDetails user) {
		Assert.isTrue(!userExists(user.getUsername()), "user should not exist");
		this.users.put(user.getUsername().toLowerCase(), new MutableUser(user));
	}
 }

 

 따라서 사용자 이름/비밀번호 인증을 기본으로 사용할 때에는  UserDetailsService라는 객체를 반환하는 메서드에 InMemoryUserDatailsManager 생성자에 적절한 userDetails를 채우고 이를 빈으로 등록해주면 된다.

	@Bean
	public UserDetailsService userDetailsService() {
		UserDetails userDetails = User.withDefaultPasswordEncoder()
			.username("user")
			.password("password")
			.roles("USER")
			.build();

		return new InMemoryUserDetailsManager(userDetails);
	}

 

이게 가능한 이유는 InMemoryUserDetailsManager가 다음과 같은 UML 구조를 가지고 있기 때문이다. 

 그러면 이제 스프링 시큐리티는 어떻게 지정된 사용자를 인증하고 그 구조는 어떻게 되어있는지만 알면 기본 제공 로그인을 이해할 수 있다. 스프링 시큐리티는 AuthenticationProvider라는 클래스가 존재한다. 이 클래스의 역할은 사용자가 로그인 때 입력한 username을 통해 userDetails 객체를 찾아오고 스프링 빈에 등록된 암호화 방식에 따라 사용자 입력을 암호화하고 찾아온 userDetails와 비밀번호가 동일한 지 확인하고 동일하다면 인증을 성공시키는 역할을 한다.

 

 이제 기본 설정 구성을 통해 설명을 해보도록 하겠다. 스프링 부트는 우리가 수동으로 등록한 UserDetailsService와 PasswordEncoder를 주입받은 AuthenticationProvider 인터페이스의 구체 클래스인 DaoAuthenticationProvider를 등록한다. 만약 유저가 로그인을 시도하면 DaoAuthent icationProvider 내부에는 사용자가 입력한 username을 이용해서 주입 받은 UserDetailsService에 loadUserByUsername을 호출한다. 이 때 userDetails를 찾으면 passwordEncoder로 사용자가 시도한 패스워드를 암호화한 뒤 찾아온 userDetails의 객체 내부의 패스워드와 비교를 하고 이게 동일하면 인증을 해주는 것이다. 위에서 설명한 흐름이 스프링 공식문서에는 다음과 같은 플로우 차트로 나와있다.

 

 결과적으로 user를 찾기 위해 UserDetailsService를 주입받고 password 인증을 위해 Password Encoder를 등록해야 하는 것이다. 그래서 스프링 공식문서에는 사용자 이름/비밀번호 방식으로 로그인을 하고 싶다면 다음과 같은 코드를 작성해달라고 설명하고 있다.

	@Bean
	public AuthenticationManager authenticationManager(
			UserDetailsService userDetailsService,
			PasswordEncoder passwordEncoder) {
		DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
		authenticationProvider.setUserDetailsService(userDetailsService);
		authenticationProvider.setPasswordEncoder(passwordEncoder);

		return new ProviderManager(authenticationProvider);
	}

	@Bean
	public UserDetailsService userDetailsService() {
		UserDetails userDetails = User.withDefaultPasswordEncoder()
			.username("user")
			.password("password")
			.roles("USER")
			.build();

		return new InMemoryUserDetailsManager(userDetails);
	}

	@Bean
	public PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}

[ DaoAuthenticationProvider를 사용한 로그인 구현 ]

 앞에선 기본적으로 제공되는 DaoAuthenticationProvider에 대해 알아보았다. 내부에서 어떻게 작동하는지 알았으니 이를 활용해 임의로 만든 Member로 로그인을 구현해보자. 이 과정을 위해선 UserDetailsSerivce와 passwordEncoder를 따로 작성해 빈으로 등록을 해주어야 한다.

 

 AuthenticationProvider의 로직에서 UserDetailsService의 역할은 저장된 user객체를 찾아오는 것이다. 1편에서 저장한 데이터는 MySQL에 저장이 되어있다. 따라서 기존 InMemoryUser DetailsService가 아닌 직접UserService를 구현하고 내부에 loadUserByUsername메서드를 작성해주면 user 객체를 DB에서 꺼내서 새로 만들 수 있다. 

 

StudyUserDetails.java

@Service
@RequiredArgsConstructor
public class StudyUserDetails implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String userName, password = null;
        List<GrantedAuthority> authorities = null;
        Member member = memberRepository.findByUserEmail(username);
        if(member == null){
            throw new UsernameNotFoundException("User is not exists in DataBase");
        } else{
            userName = member.getUserEmail();
            password = member.getPassword();
            authorities = new ArrayList<>();
            for(Authority authority : member.getAuthorities()){
                authorities.add(new SimpleGrantedAuthority(authority.getRole()));
            }
        }
        return new User(username, password, authorities);
    }
}

 

PasswordEncoder는 1번에서 작성했던 그대로 SecurityConfig 내부에 아래처럼 작성되어있으면 된다.

 

SecurityConfig.java

@Bean
public PasswordEncoder passwordEncoder(){
    return new BCryptPasswordEncoder();
}

 

 위의 코드를 보면 StudyUserDetails에서 loadUserByUsername이 작성이 되어있지만 기존 Member 객체는 단순 정보를 꺼내고 그 데이터를 User로 만들어서 다시 DaoAuth enticationProvider의 인증로직에 의존하게 된다. 이렇게 해도 인증과정에서의 문제가 없지만 인증 과정에서 요구사항이 추가되면 인증 로직을 바꿀 수가 없다. 따라서 AuthenticationProvider를 DaoAuthenticationProvider가 아닌 커스텀으로 작성하여 이 User와 DaoAuthenticationProvider에 관한 의존성을 끊어주어야 한다.


[ 커스텀 AuthenticationProvider 작성 ]

 커스텀 AuthenticationProvider를 작성하기 전에 스프링 시큐리티에서 지원하는 AuthenticationProvider가 어떻게 호출이 되는지를 알아야 한다. 스프링 시큐리티에서는 아래의 이미지처럼 AuthenticatoinProvider를 제공한다. 이 부분은 따로 공식문서나 파일 내부를 읽어보지 않았다.  아마도 구현 방식은 Filter중 하나는 ProviderManager를 호출하여서 Authentication을 확인하는 필터가 있을 것이다. 그리고 그 ProviderManager에는 Map 같은 형태로 AuthenticationProvider 인터페이스에 의존된 구조가 있을 것이다. 이런 구조를 통해서 Authentication 요청이 있을 때 ProviderManager 내부에서 Map을 돌고 인증 방식 조건에 맞는 Authentication Providers를 찾아와 인증을 진행하는 것 같다.

 

 그래서 AuthenticationProvider를 커스텀으로 작성할 때에는 이를 상속 받아 내부 메서드를 구현해주어야 한다. 우선 구현해주어야 하는 메서드는 supports이다. 아마도 이 메서드를 통해 적절한 AuthenticationProvider를 ProvderManger에서 찾아오는 것 같다.

 

 현재 커스텀 유저로 인증을 진행하는 과정이기에  Username과 password를 통한 사용은 동일하므로 supports는 DaoAuthenticationProvider에서 사용하는 것과 동일하게 작성하였다.

 

 또한 Authentication 로직은 DB 내에서 직접 이메일에 맞는 데이터를 Member와 함께 Authority를 페치조인으로 함께 가져온다. 그리고 그 DB 내에서 찾아온 객체가  있다면 password가 매칭되는지 확인하고 맞다면 Authentication을 생성하고 아니면 에러를 내뱉는다. 

 

StudyAuthenticationProvider.java

@Component
@RequiredArgsConstructor
public class StudyAuthenticationProvider implements AuthenticationProvider {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = authentication.getCredentials().toString();
        Optional<Member> member = memberRepository.findByUserEmailWithAuthorities(username);
        if(member.isPresent()){
            Member findMember = member.get();
            if(passwordEncoder.matches(password, findMember.getPassword())){
                return new UsernamePasswordAuthenticationToken(username, password, getGrantedAuthorities(findMember.getAuthorities()));
            } else{
                throw new BadCredentialsException("Invalid password!");
            }
        }
        else{
            throw new BadCredentialsException("No user registered with this details!");
        }
    }

    private List<GrantedAuthority> getGrantedAuthorities(List<Authority> authorities){
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        for(Authority authority: authorities){
            grantedAuthorities.add(new SimpleGrantedAuthority(authority.getRole()));
        }
        return grantedAuthorities;
    }

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

[ 테스트 ]

테스트를 해보고 싶다면 미리 데이터를 메인 어플리케이션에서 작성해서 DB에 놓자. 

 

SpringSecurityApplication.java

@PostConstruct
@Transactional
public void init(){

    String password = passwordEncoder.encode("12345");
    Member member = new Member("test@example.com", password, "happy");
    memberRepository.save(member);

    Authority admin = new Authority("ROLE_ADMIN");
    Authority user = new Authority("ROLE_USER");

    member.addAuthorities(List.of(admin, user));
    authorityRepository.save(admin);
    authorityRepository.save(user);
}

 

 그리고 맨 위에서 처음 만들었던 security 페이지에 접속해서 로그인을 하고 한번 저장하지 않은 계정도 넣어보고 저장한 계정도 넣어보며 어떤 차이가 있는지 F12로 개발자 콘솔창을 열어보고 확인해보면 된다.

[ 참고 자료 ]

https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/dao-authentication-provider.html

Spring Security 6 초보에서 마스터되기 최신 강의! - Eazy Bytes