개요
이번 교내 팀 프로젝트에서 Spring Security를 확실하게 짚고 가고자 회원관리를 담당했습니다. Spring Sedurity를 활용해 기본 제공 FORM 로그인이 아닌 REST API로 로그인 인증을 구현했습니다. 이 포스팅을 통해 Security의 인증 핵심 인터페이스를 어떻게 구현했는지 복기하려 합니다. Spring Security 인증 절차 & 핵심 인터페이스 포스팅을 함께 보시는 것을 추천 드립니다.
Spring Security 인증 핵심 인터페이스
인증 처리의 핵심 인터페이스 다섯 가지를 소개합니다.
- AbstractAuthenticationProcessingFilter: 인증 처리의 시작점
- AuthenticationProvider: 실질적인 인증 처리를 담당
- Authentication: 인증 절차를 거치게 될 대상이자(이 시점에서는 사용자가 입력한 username과 password를 가짐), 인증이 완료된 대상(이 시점에서는 실제 회원 데이터를 가짐)
- AuthenticationSuccessHandler: 인증 처리 성공 후처리를 담당
- AuthenticationFailureHandler: 인증 처리 실패 후처리를 담당
1. MemberAuthenticationToken
- 애플리케이션 내에서 인증 객체로 사용될 AbstractAuthenticationToken을 구현했습니다.
@Getter
public class MemberAuthenticationToken extends AbstractAuthenticationToken {
private Object principal;
private String credential;
private MemberAuthenticationToken(String username, String password) {
super(null);
this.principal = username;
this.credential = password;
this.setAuthenticated(false);
}
private MemberAuthenticationToken(Object principal, String credential) {
super(null);
this.principal = principal;
this.credential = null;
this.setAuthenticated(true);
}
// 인증처리 전
public static MemberAuthenticationToken unauthenticated(UsernamePasswordLoginDto loginDto) {
return new MemberAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
}
// 인증처리 후
public static MemberAuthenticationToken authenticated(AuthenticatedMember principal) {
return new MemberAuthenticationToken(principal, null);
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return principal;
}
}
2. LoginProcessingFilter
- AbstractAuthenticationProcessingFilter의 attemptAuthentication 메서드와 successfulAuthentication, unsuccessfulAuthentication 메서드를 구현했습니다.
@Slf4j
public class LoginProcessingFilter extends AbstractAuthenticationProcessingFilter {
private SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();
public LoginProcessingFilter() {
super(new AntPathRequestMatcher("/members/login")); // "/members/login" 요청에 Filter를 적용
}
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// 1. HTTP 요청 바디 json을 읽어 DTO로 변환
UsernamePasswordLoginDto usernamePasswordLoginDto = jsonToUsernamePasswordLoginDto(request);
// 2. 인증처리 전의 Authentication 객체를 생성
MemberAuthenticationToken authRequest =
MemberAuthenticationToken.unauthenticated(usernamePasswordLoginDto);
// 3. AuthenticationManager에게 인증처리를 위임
return super.getAuthenticationManager().authenticate(authRequest);
}
private UsernamePasswordLoginDto jsonToUsernamePasswordLoginDto(HttpServletRequest request) throws IOException {
ServletInputStream inputStream = request.getInputStream();
String usernamePasswordJson = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.readValue(usernamePasswordJson, UsernamePasswordLoginDto.class);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws ServletException, IOException {
// 1. 비어있는 SecurityContext를 생성
SecurityContext context = SecurityContextHolder.createEmptyContext();
// 2. 인증처리 완료된 Authentication 객체를 SecurityContext에 등록
context.setAuthentication(authResult);
// 3. Session 등록 및 성공 핸들러 호출
this.securityContextRepository.saveContext(context, request, response);
this.getSuccessHandler().onAuthenticationSuccess(request,response,chain,authResult);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
// 1. SecurityContextHolder 비우기
SecurityContextHolder.clearContext();
// 2. 실패 핸들러 호출
this.getFailureHandler().onAuthenticationFailure(request, response, failed);
}
}
3. UsernamePasswordAuthenticationProvider
- ProviderManager는 자신이 갖고 있는 Provider 객체들의 목록을 뒤지며 해당 Provider에게 Authentication 토큰을 넘겨줄 수 있는지 없는지 여부를 supports 메서드로 확인합니다.
- supports 메서드 호출 결과 true를 반환하면 authetnicate 메서드가 호출되며, 넘겨 받은 Authentication에 대해 인증 절차를 수행합니다.
@Component
@RequiredArgsConstructor
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
private final PasswordEncoder passwordEncoder;
private final MemberRepository memberRepository;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 1. DB에서 username으로 Member를 찾음
MemberAuthenticationToken authenticationToken = (MemberAuthenticationToken) authentication;
String username = String.valueOf(authenticationToken.getPrincipal());
Member findMember = memberRepository.findByUsername(username).orElseThrow(
()-> new UsernameNotFoundException("아이디를 찾을 수 없습니다."));
// 2. DB와 password가 일치하는지 검증
String password = authenticationToken.getCredential();
if (!passwordEncoder.matches(password, findMember.getPassword())) {
throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
}
// 3. 인증 성공 시 Authentication 토큰 생성
AuthenticatedMember authenticatedMember = new AuthenticatedMember(findMember.getId(), findMember.getNickname(), findMember.getRole());
return MemberAuthenticationToken.authenticated(authenticatedMember);
}
@Override
public boolean supports(Class<?> authentication) {
return MemberAuthenticationToken.class.isAssignableFrom(authentication);
}
}
4. CustomAuthenticationSuccessHandler
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 1. 인증된 AuthenticatedMember 객체를 꺼낸다.
AuthenticatedMember authenticatedMember = (AuthenticatedMember) authentication.getPrincipal();
LoginSuccessResponseDto loginSuccessResponseDto = LoginSuccessResponseDto.builder()
.memberId(authenticatedMember.getMemberId())
.nickname(authenticatedMember.getNickName())
.role(authenticatedMember.getRole().name())
.build();
BaseResponse<LoginSuccessResponseDto> responseDto = BaseResponse.of(HttpStatus.OK, loginSuccessResponseDto);
// 2. 응답한다.
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(responseDto));
response.flushBuffer();
}
}
5. CustomAuthenticationFailureHandler
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
BaseResponse<Boolean> loginFailureResponseDto = BaseResponse.of(HttpStatus.OK, false);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(loginFailureResponseDto));
response.flushBuffer();
}
}
6. SecurityConfig
커스텀하게 구현한 Filter와 Handler들을 SecurityFilterChain에, Provider는 AuthenticationManager으로 등록하기 위해 Security Configuration 설정파일을 작성합니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
@Order(0)
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.httpBasic(Customizer.withDefaults())
.formLogin(login -> login.disable()) // 폼로그인 비허용
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/members/login", "/members/register", "/members/verify-username").permitAll()
.anyRequest().authenticated())
.securityContext(securityContext -> new HttpSessionSecurityContextRepository())
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.accessDeniedHandler(new CustomAccessDeniedHandler()));
return http.build();
}
@Bean
@Order(1)
public SecurityFilterChain addCustomFilters(HttpSecurity http,
LoginProcessingFilter loginProcessingFilter) throws Exception {
http
.addFilterAt(loginProcessingFilter, UsernamePasswordAuthenticationFilter.class); // 로그인 필터 등록
return http.build();
}
@Bean
public LoginProcessingFilter loginProcessingFilter(AuthenticationManager authenticationManager) {
LoginProcessingFilter loginProcessingFilter = new LoginProcessingFilter();
loginProcessingFilter.setAuthenticationManager(authenticationManager);
loginProcessingFilter.setAuthenticationSuccessHandler(new CustomAuthenticationSuccessHandler());
loginProcessingFilter.setAuthenticationFailureHandler(new CustomAuthenticationFailureHandler());
return loginProcessingFilter;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationProvider authenticationProvider) {
return new ProviderManager(authenticationProvider);
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
Github 소스 코드
- 로그인 인증필터 구현: https://github.com/CatDesignProject/CUKZ-BE/pull/14
- 로그인 성공, 실패 핸들러 구현: https://github.com/CatDesignProject/CUKZ-BE/pull/25
'개발 > Spring' 카테고리의 다른 글
[Spring] Spring Data JPA OSIV와 지연 로딩 (3) | 2024.06.11 |
---|---|
스프링 싱글톤 컨테이너의 의존관계 주입과 빈 생명주기 (0) | 2024.05.16 |
Springboot 프로젝트에서 Jasypt 암호화로 yml 설정 파일 프로퍼티 관리하기 (0) | 2024.05.07 |
서블릿과 서블릿 컨테이너란 무엇이고 어떻게 동작할까? - 전통적인 웹 앱 부터 Spring MVC까지 (1) (1) | 2024.04.16 |
Spring Security 인증 절차 & 핵심 인터페이스 (1) | 2024.03.23 |