사용자에게 인증 요청이 오면 인증관리자는 인증공급자 즉, AuthenticationProvider에게 처리하도록 한다. 인증공급자에서는 UserDetailsService를 이용하여 등록되어 있는 유저인지 확인하고, PasswordEncoder를 이용하여 비밀번호가 맞는지 인코딩하여 확인하는 과정을 거친 후 Authentication 구현체를 반환한다. 이 과정은 스프링 시큐리티에서 기본으로 처리해 주지만 보통은 자신의 어플리케이션에 맞는 비즈니스 로직을 거쳐 원하는 방식대로 사용자 인증을 하는 것을 선호할 것이다. 이번에는 AuthenticationProvider를 직접 구현하여 인증을 거치는 과정을 직접 만들어보자.
준비물
UserDetailsService
PasswordEncoder
@Component
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = new User("test", passwordEncoder.encode("1234"));
if (user.getName().equals(username)) {
return new SecurityUser(user);
}
else {
throw new UsernameNotFoundException("유저를 찾을 수 없음");
}
}
}
UserDetailsService의 loadUserByUsername()을 구현한 클래스이다. 현재로써는 DB 연결이 안 되어 있으므로 임의로 test라는 이름으로 1234가 비밀번호인 계정을 만들어 파라미터로 받은 계정명과 맞는지 비교해 보았다. DB에서 어떤 목록을 가져와야 한다면 해당 부분에 목록을 가져오는 메서드라던가 원하는 코드를 대신 작성하면 된다. 유저명을 확인하는 과정을 거치면 UserDetails에 맞는 형태로 반환해 주면 된다. SecurityUser라는 것은 따로 작성해 둔 클래스로 단순히 UserDetails의 메서드들을 구현한 클래스로 멤버 변수로 User 인스턴스를 갖는다.
@Configuration
public class CustomPasswordEncoder {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
PasswordEncoder를 따로 재정의한 부분이다. 직접 재정의 해주긴 했지만 엄밀히 말하면 스프링 시큐리티에서 제공해주는 PasswordEncoder 구현체 중 인코딩 방식에 따라 다른 구현체를 선택할 수 있는 Map을 가지고 있는 DelegatingPasswordEncoder를 사용한 것이다. 아무것도 지정하지 않으면 기본 인코딩 방식이 BCryptPasswordEncoder로 설정되어 있기 때문에 해당 방식으로 작동하게 된다.
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UserDetails user = null;
try {
user = userDetailsService.loadUserByUsername(authentication.getName());
} catch (UsernameNotFoundException e) {
log.info("유저를 찾을 수 없습니다!", e);
throw new UsernameNotFoundException("해당 유저가 없음");
}
if (user != null && passwordEncoder.matches(authentication.getCredentials().toString(), user.getPassword())) {
log.info("유저를 찾음!");
return new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()
, List.of(new SimpleGrantedAuthority("user")));
}
else {
log.error("비밀번호가 맞지 않음");
throw new BadCredentialsException("비밀번호 틀림");
}
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
이제 CustomAuthenticationProvider 클래스를 살펴보자. 인증 공급자에서는 UserDetailsService와 PasswordEncoder가 필요하므로 컨테이너에 등록된 해당 객체들을 받아와야 한다. 그 후에 AuthenticationProvider의 메서드 authenticate()와 supports()를 구현하면 된다.
authenticate()에서는 인증을 어떤 방식으로 처리할 것인지를 구현하고,
supports()에서는 CustomAuthenticationProvider가 해당 Authentication을 처리할 수 있으면 true를 반환하도록 하는 용도이다.
인증 로직은 다음과 같다.
1. UserDetailsService의 loadUserByUsername()을 이용하여 유저를 찾아 일치하는 유저명이 있는지 확인함
2. 입력받은 비밀번호를 String으로 변경하여 PasswordEncoder의 matches()를 이용하여 비밀번호가 맞는지 확인함
3. 비밀번호가 일치하면 UsernamePasswordAuthenticationToken을 이용하여 Authentication 구현체를 반환함
이제 잘 작동하는지 확인해보자.
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomAuthenticationProvider authenticationProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authenticationProvider(authenticationProvider)
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin(login -> login.successForwardUrl("/test/hello"));
return http.build();
}
}
위의 설정을 통해 어떤 경로로 접근하든 인증을 받도록 했다. 그리고 폼로그인 방식으로 브라우저에서 결과를 확인하기로 했다.
@RestController
public class TestController {
@PostMapping("/test/hello")
public String test1() {
return "hello";
}
@GetMapping("/test")
public String test2() {
return "hi";
}
}
컨트롤러에서는 간단하게 위의 결과를 raw 문자열로 보여주도록 했다.

localhost:8080의 어떤 경로로 이동해도 위의 로그인 페이지가 나온다. 해당 페이지는 스프링 시큐리티에서 기본으로 제공하는 페이지이다. 일단 틀린 유저명으로 로그인해보자.

해당 유저가 없다고 한다. 위의 오류 메시지처럼 보이는 것은 UsernameNotFoundException을 처리하지 않고 밖으로 던졌기 때문이다. 그런데 만약 실제로 어플리케이션을 운영하는 입장이라면 유저명이라던가 비밀번호가 틀렸다고 각각 알려주기보다는 "입력된 정보가 잘못되었습니다."와 같이 입력된 정보가 틀렸다는 것을 사용자에게 인지하도록 하면서도 둘 중에 어떤 정보가 틀렸는지는 가르쳐주지 않는 것이 사용자에게 특정 계정의 정보를 예측할 수 없도록 만들어 보안상 좋다고 말할 수 있다.
다음은 계정명은 맞지만 비밀번호만 틀리도록 해보자.

비밀번호가 틀렸다고 표시되었다. 계정은 찾았지만 PasswordEncoder에서 비밀번호가 맞지 않아 BadCredentialsException 예외를 던진 것이다.
이번에는 맞는 계정인 test에 1234로 로그인해 보자.

정상적으로 로그인되었다! 다만 컨트롤러에서 로그인한 이후의 처리는 따로 하지 않았기 때문에 해당 화면을 보는 것이 다이다.
'공부 > Spring Security' 카테고리의 다른 글
| [Spring Security] CSRF 설정하기 (0) | 2024.01.10 |
|---|---|
| [Spring Security] 원하는 Filter 만들어서 사용하기 (0) | 2024.01.09 |
| [Spring Security] mvcMatchers, antMatchers, regexMatchers 대신 requestMatchers (0) | 2024.01.08 |
| [Spring Security] 기본 동작 과정 및 UserDetailsService (0) | 2024.01.03 |
| [Spring Security] HTTP Basic 테스트 (0) | 2024.01.02 |