이전부터 아주 천천히 진행하고 있던 토이 프로젝트의 로그인 기능을 드디어 구현했다.
이번 달 ~ 다음 달 안에는 종료되어야 마음이 편할텐데 가능하..겠지..?😂
본 포스팅에서는 복잡한 로직 다 빼고 Spring Security를 적용만 해보는 과정을 담았다.
1. 준비
🔻 이전 글 참고: https://yeonyeon.tistory.com/185
2. 동작 방식
먼저 Spring Security의 동작 방식부터 눈에 익혀두자.
처음 보면 감이 잘 안잡힐 수 있는데 코드를 작성한 뒤 또 보는걸 추천한다.
- 사용자가 로그인 정보(id, password) 전송
- AuthenticationFilter가 1의 정보 인터셉트
- AuthenticationManager 인터페이스를 거쳐 AuthenticationProvider에게 2의 정보 전달 (Authentication 형태)
- AuthenticationProvider 실행
- supports() 메소드를 통해 실행 가능한지 체크
- authenticate() 메소드를 통해 DB에 저장된 이용자 정보와 입력한 로그인 정보 비교
- DB 이용자 정보: UserDetailsService의 loadUserByUsername() 메소드를 통해 불러옴
- 입력 로그인 정보: 3에서 받았던 Authentication 객체
- 👉 일치하는 경우 Authentication 반환
- Authentication 객체를 SecurityContextHolder에 담음
- 성공 시 AuthenticationSuccessHandle,
실패 시 AuthenticationFailureHandle 실행
3. 적용
1. SecurityConfig 설정
- 설정에 대한 설명은 이전 글에 있으니 생략한다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/users", "/login**").permitAll()
.anyRequest().authenticated();
http.formLogin()
.disable();
http.csrf().disable();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
본 프로젝트에서는 백엔드를 API 셔틀로만 사용할 예정이다.
formLogin과 csrf 설정이 불필요해 disable() 설정 해두었다.
2. AuthenticationFilter 생성
위 동작 흐름에서 살펴봤듯이 사용자가 로그인 정보를 전송하면 AuthenticationFilter가 이를 가로챈다.
요 AuthenticationFilter를 생성할건데 먼저 SecurityConfig 파일에 filter를 등록해주자.
configure(HttpSecurity http)를 보면 addFilterBefore()를 통해 우리가 커스텀한 필터를 등록할 예정이다.
LoginSuccessHandler, LoginFailureHandler는 내가 커스텀한 핸들러로 아직 컴파일 에러가 뜨는 것이 당연하다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// ...
}
@Bean
public CustomAuthenticationFilter customAuthenticationFilter() throws Exception {
CustomAuthenticationFilter filter = new CustomAuthenticationFilter(
new AntPathRequestMatcher("/login", HttpMethod.POST.name())
);
filter.setAuthenticationManager(authenticationManager());
filter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
filter.setAuthenticationFailureHandler(new LoginFailureHandler());
return filter;
}
// ...
}
- addFilterBefore(filter A, filter B.class): filter A 추가 (filter B보다 우선)
CustomAuthenticationFilter
public class CustomAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private final ObjectMapper objectMapper = new ObjectMapper();
public CustomAuthenticationFilter(RequestMatcher requiresAuthenticationRequestMatcher) {
super(requiresAuthenticationRequestMatcher);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException {
// objectMapper.readValue(request.getReader(), dto클래스.class);를 통해 값 꺼내오기 가능
// 인증 객체를 생성하는데 필요한 데이터들 가져오기
return getAuthenticationManager().authenticate(new CustomAuthenticationToken(account, password));
}
}
반환한 Authentication은 기본을 사용해도 되지만 나는 더 많은 정보를 넣고 싶어서 새로 생성했다.
🔻 CustomAuthenticationToken
@Getter
public class CustomAuthenticationToken extends AbstractAuthenticationToken {
private long id;
private String account;
private String email;
private String credentials;
public CustomAuthenticationToken(String account, String credentials) {
super(null);
this.account = account;
this.credentials = credentials;
}
public CustomAuthenticationToken(String account, String email, String credentials, long id,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.account = account;
this.email = email;
this.credentials = credentials;
this.id = id;
super.setAuthenticated(true);
}
public CustomAuthenticationToken(Collection<? extends GrantedAuthority> authorities) {
super(authorities);
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.account;
}
}
3. AuthenticationProvider 생성
이제 AuthenticationManager가 실행시킬 CustomAuthenticationProvider를 생성해보겠다.
AuthenticationManager도 커스텀할 수는 있지만 당장 필요하지 않아 생략했다.
SecurityConfig
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserService customUserService;
@Override
protected void configure(HttpSecurity http) throws Exception { ... }
@Override
protected void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(customAuthenticationProvider());
}
@Bean
public AuthenticationProvider customAuthenticationProvider() {
return new CustomAuthenticationProvider(customUserService, passwordEncoder());
}
@Bean
public BCryptPasswordEncoder passwordEncoder() { ... }
@Bean
public CustomAuthenticationFilter customAuthenticationFilter() throws Exception { ... }
}
- authenticationProvider(): provider 등록
- customAuthenticationProvider(): 직접 커스텀한 provider 생성
CustomAuthenticationProvider
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Autowired
private CustomUserService userDetailsService;
private BCryptPasswordEncoder passwordEncoder;
public CustomAuthenticationProvider(CustomUserService customUserService, BCryptPasswordEncoder passwordEncoder) {
this.userDetailsService = customUserService;
this.passwordEncoder = passwordEncoder;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String account = authentication.getName();
String password = (String) authentication.getCredentials();
CustomUserDetails user = userDetailsService.loadUserByUsername(account);
// 로그인 로직 추가
if (!this.passwordEncoder.matches(password, user.getPassword())) {
throw new BadCredentialsException("password is not matched");
}
return new CustomAuthenticationToken(account, user.getEmail(), null, user.getId(), user.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(CustomAuthenticationToken.class);
}
}
- CustomUserService: UserDetailsService을 구현한 클래스
- supports(): 해당 Provider이 사용이 적합한지 체크
- authenticate(): DB에 저장된 정보와 파라미터로 받은 정보가 일치하는지 체크
CustomUserService
@Service
@RequiredArgsConstructor
public class CustomUserService implements UserDetailsService {
private final static String ERROR_NO_USER = "[ERROR] 해당 사용자가 없습니다.";
private final static String ERROR_NO_ACCOUNT = "[ERROR] 계정 정보를 불러올 수 없습니다.";
private final UserRepository userRepository;
@Override
public CustomUserDetails loadUserByUsername(String account) throws UsernameNotFoundException {
if(account == null) {
throw new IllegalArgumentException(ERROR_NO_ACCOUNT);
}
User user = userRepository.findByAccount(account)
.orElseThrow(() -> new IllegalArgumentException(ERROR_NO_USER));
return new CustomUserDetails(user);
}
}
- UserDetailsService 구현
- loadUserByUsername(): DB의 사용자 정보 불러오기
4. Success/FailureHandler 생성
2에서 필터를 추가할 때 handler를 생성하지 않아 컴파일 에러가 떴었을 것이다.
SuccessHandler와 FailureHandler는 아래처럼 간단하게 만들 것이다.
다시 말하지만 API 셔틀로 쓰는 레포기 때문에... redirect같은 로직 없이 JSON만 반환하도록 했다.
LoginSuccesHandler
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
String account = authentication.getName();
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
LoginResponseDto responseDto = new LoginResponseDto();
responseDto.setResult(true);
responseDto.setAccount(account);
objectMapper.writeValue(response.getWriter(), responseDto);
}
}
LoginFailureHandler
public class LoginFailureHandler implements AuthenticationFailureHandler {
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
String errorMessage = "로그인 실패";
if (exception instanceof BadCredentialsException) {
errorMessage = "Invalid Username / Password";
} else if (exception instanceof DisabledException) {
errorMessage = "Locked";
} else if (exception instanceof CredentialsExpiredException) {
errorMessage = "Expired password";
}
LoginResponseDto responseDto = new LoginResponseDto();
responseDto.setResult(false);
responseDto.setErrorMessage(errorMessage);
objectMapper.writeValue(response.getWriter(), responseDto);
}
}
🔻 2번과 코드 흐름 비교해보기
'2. 동작 방식'과 연관지어서 코드를 하나하나 분석해보자.
먼저 사용자는 /login으로 접근해 로그인 정보인 id, password를 보낸다.
SecurityConfig에 저장된 configure(HttpSecurity) 메소드에 의해 다음 설정을 따를 것이다.
addFilterBefore를 통해 추가했던 CustomAuthenticationFilter가 사용자가 보낸 정보를 낚아챈다.
// SecurityConfig.class - Filter 등록
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/users", "/login**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(customAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class);
// ...
}
Filter에서 설정했던 AuthenticationManager 호출한다.
// SecurityConfig.class - filter() 생성 및 manager 등록
@Bean
public CustomAuthenticationFilter customAuthenticationFilter() throws Exception {
CustomAuthenticationFilter filter = new CustomAuthenticationFilter(
new AntPathRequestMatcher("/login", HttpMethod.POST.name())
);
filter.setAuthenticationManager(authenticationManager());
filter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
filter.setAuthenticationFailureHandler(new LoginFailureHandler());
return filter;
}
AuthenticationManager는 여러 AuthenticationProvider 중 적합한 AuthenticationProvider를 찾는다.
// SecurityConfig.class - Provider 등록
@Override
protected void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(customAuthenticationProvider());
}
// CustomAuthenticationProvider - supports() 적합한지 확인
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(CustomAuthenticationToken.class);
}
CustomAuthenticationProvider가 적합하다고 판단되면 authenticate() 메소드가 호출된다.
UserDetailsService를 상속받은 CustomUserDetails를 통해 DB의 사용자 정보를 가져온다.
파라미터로 받아온 Authentication의 데이터와 비교해 로그인 로직을 진행된다.
// AuthenticationProvider - authenticate()
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String account = authentication.getName();
String password = (String) authentication.getCredentials();
CustomUserDetails user = userDetailsService.loadUserByUsername(account);
if (!this.passwordEncoder.matches(password, user.getPassword())) {
throw new BadCredentialsException("password is not matched");
}
return new CustomAuthenticationToken(account, user.getEmail(), null, user.getId(), user.getAuthorities());
}
위 메소드를 통해 반환된 Authentication 객체는 SecurityContextHolder에 담긴다.
로직 성공 시 AuthenticationSuccessHandle이 실행되고,
실패 시 AuthenticationFailureHandle 실행된다.
// AuthenticationFilter에서 설정했었던 Success/Failure Handler
@Bean
public CustomAuthenticationFilter customAuthenticationFilter() throws Exception {
CustomAuthenticationFilter filter = new CustomAuthenticationFilter(
new AntPathRequestMatcher("/login", HttpMethod.POST.name())
);
filter.setAuthenticationManager(authenticationManager());
filter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
filter.setAuthenticationFailureHandler(new LoginFailureHandler());
return filter;
}
참고
- inflearn - Spring Boot 기반으로 개발하는 Spring Security
'Develop > Spring+JPA' 카테고리의 다른 글
[Spring] 생성자 주입 vs 필드 주입 vs 수정자 주입 (0) | 2022.04.28 |
---|---|
[Spring] @ExceptionHandler로 API 예외 한번에 처리하기 (7) | 2022.04.21 |
[Spring Security] 스프링 시큐리티 간단 적용기 (1) | 2022.02.07 |
[Spring] NestedServletException - Name for argument type [java.lang.Long] not available 에러 (0) | 2022.01.28 |
PATCH 메소드는 언제 사용하는가? (3) | 2022.01.25 |