# 개요

이전 글에서 Spring Security를 추가하고 관련 설정들을 SecurityFilterChain으로 작성해주었다.

이번 글에서는 Controller 계층에서 구현했던 Login API를 SecurityFilter로 옮겨보겠다.

선요약

  1. formLogin의 흐름
  2. Login API 기존 Controller 계층에서 구현 → JsonLoginFilter를 추가하여 SecurityFilterChain에 등록
    • JsonLoginFilter 추가
    • Success/Failure Handler 추가
    • 리팩토링
  3. CustomUserDetailsService와 CustomUserDetails 추가
    • AuthService 추가
    • AuthenticatedUser 추가
    • 패키지 구조 정리
  4. Success/Failure Handler 수정

 

# SecurityFilter로 옮긴 이유

소위 무지성으로 블로그들을 따라 구현했기 때문에 SecurityFilter에 구현한 이유를 명확하게 알지 못했다. 그리고 기존에 알던 방식이 아니었기 때문에 따라하면서도 이해하기가 많이 어려웠다.

블로그 글을 작성하기위해 코드를 다시 작성하면서 장점과 단점에 대해 내가 느낀것과 블로그, GPT 등을 참고한 내용을 정리해 보겠다.

장점

단일 책임 원칙: 인증/인가에 대한 책임을 SecurityFilter로 분리하여 단일 책임 원칙(SRP)을 준수할 수 있었다. 일반적인 사용자 요청은 Controller에서 처리하고, 인증/인가와 관련된 로직은 SecurityFilter에서 처리하여 역할을 명확하게 분리할 수 있었다. 그래서 각 코드의 가독성과 유지보수를 높힐 수 있었다.

보안 강화와 표준화된 인증 프로세스: 인증/인가에 관한 로직을 SecurityFilter에 작성함으로써 인증 요청이 DispatcherServlet에 도달하기 전에 SecurityFilter가 가로채어 처리하게 되었다. Spring Security의 표준 인증 프로세스를 따를 수 있고 보안 측면에서도 더 안전하게 구현할 수 있었다.

개방 폐쇄 원칙: 인증/인가에 관련된 로직을 SecurityFilter로 분리함으로써 OCP를 잘 준수할 수 있었다. Session 방식에서 JWT 방식으로 변경하는 등 새로운 기능을 추가하거나 기존의 기능을 수정하는데 유연하게 대처할 수 있었다.

단점

학습 곡선: Spring Security 프레임워크를 제대로 사용하기 위한 학습 곡선이 상당히 크다고 느꼈다. 여러 블로그에서 잘 설명해주었고, 공식 문서에 아키텍처를 잘 표현해주었기 때문에 익힐 수 있었지만 꽤 시간이 걸렸다. 새로운 프레임워크를 배우고 적용해볼 수 있어서 좋았지만, 더 잘 다루기 위해서는 더 공부가 필요하다고 느꼈다.


 

# Security formLogin 동작 흐름 (formLogin)

먼저 formLogin의 흐름을 이해하고나서 Custom Login Filter를 만들어보겠다. 디버깅을 통해 formLogin의 흐름을 살펴보자.

꽤 길다..... 디버깅 과정이 보기 싫으면 시퀀스 다이어그램만 봐도 될것 같다. 그것도 싫다면 바로 Custom Login Filter를 구현하는 부분부터 봐도 된다.

더보기

form Login 화면에서 username과 password를 입력하고 로그인 요청을 디버깅 해보면 다음과 같은 순서의 필터를 확인할 수 있다.

여기서 인증부분을 담당하는 필터는 UsernamePasswordAuthenticationFilter이다.

 

이 클래스를 살펴보면

request로부터 username과 password를 가져와서 UsernamePasswordAuthenticationToken을 만들어 이를AuthenticationManagerauthenticate() 메서드에 전달한다.

 

AuthenticationManager를 살펴보면

ProviderManager인 것을 확인할 수 있고

ProviderManagerauthenticate() 메서드를 살펴보면

ProviderManager 내부에 가지고 있는 AuthenticationProvider들을 순회하며 전달받은 Authentication (UsernamePasswordAuthenticationToken)을 지원한다면 해당 AuthenticationProviderauthenticate() 메서드를 호출하고, 지원하는 AuthenticationProvider가 없다면 ProviderManager의 parent의 authenticate()를 호출한다.

 

디버깅을 계속 해보자

UesrnamePasswordAuthenticationFilterProviderManager(13113)AnonymousAuthenticationProvider를 가지고 있고 이 ProviderManager(13113)의 parent인 ProviderManager(13117)는 DaoAuthenticationProvider를 가지고있다.

AnonymousAuthenticationProviderUsernamePasswordAuthenticationToken을 지원한다면 AnonymousAuthenticationProviderauthenticate() 메서드를 호출할 것이고, 지원하지 않는다면 parent ProviderManager(13117)authenticate()를 호출할 것이다.

 

그럼 어느 AuthenticationProviderauthenticate()를 호출하는지 확인해보자. AnonymousAuthenticationProvider일까? DaoAuthenticationProvider일까? 아니면 둘 다 아니라서 Exception이 발생할까?

DaoAuthenticationProviderauthenticate() 메서드가 실행되었다. 즉 UsernamePasswordAuthenticationTokenAnonymousAuthenticationProvider는 지원하지 않고, DaoAuthenticationProvider는 지원한다는 뜻!

 

계속해서 살펴보자. AbstractUserDetailsAuthenticationProvider authenticate() 메서드를 살펴보면( DaoAuthenticationProviderAbstractuUserDetailsAuthenticationProvider를 상속)

retrieveUser() 메서드를 통해 user를 반환받고 그 user를 additionalAuthenticationChecks() 메서드에 전달하여 추가적인 확인을 하는것 같다.

 

차례대로 확인해보자. retrieveUser() 메서드를 살펴보면

DaoAuthenticationProvider가 가지고있는 UserDetailsServiceloadUserByUsername() 메서드를 통해 UserDetails를 반환한다.

 

계속해서 디버깅 해보면

UserDetailsServiceInMemoryUserDetailsManager이고 loadUserByUsername() 메서드를 살펴보면

여느 getUser() 메서드처럼 User를 반환한다.

 

이번엔 addtionalAuthenticationChecks() 메서드를 살펴보자.

비밀번호를 확인하여 틀리면 Exception을 발생한다.

 

로그인을 성공하면 SuccessHandler에서 실패하면 FailureHandler에서 응답처리한다.

SimpleUrlAuthenticationSuccessHandler를 상속받은 SavedRequestAwareAuthenticationSuccessHandler와 SimpleUrlAuthenticationFailureHandler

위의 복잡한 디버깅으로 알아본 formLogin의 흐름을 간단한 시퀀스 다이어그램으로 표현하면 다음과 같다.

이 흐름을 토대로 Custom Login Filter를 만들어 볼 것이다. 


 

# Custom Login Filter 구현 (JsonLoginFilter)

formLogin의 인증 부분을 담당하는 필터는 UsernamePasswordAuthenticationFilter였다. 이를 대체할 CustomAuthenticationFilter를 만들것인데 이후 Header에서 JWT를 확인하는 JwtAuthenticationFilter를 구현할 것인데 괜히 JsonAuthenticationFilter라고 이름지어서 혼란을 줄 수 있으니 편의상 JsonLoginFilter라고 이름짓겠다.

JsonLoginFilter 추가

JsonLoginFilterFilter 클래스들 중 하나를 상속하여 구현할 수 있다.

Filter 계층구조(이 외에도 Filter를 구현한 클래스가 많다.)

블로그와 책 등을 살펴보니 가장 많이 구현한 형태가 OncePerRequestFilter, AbstractAuthenticationProcessingFilter, UsernamePasswordAuthenticationFilter를 상속한 클래스였다. 각 차이점이 뭔지 AI에게 물어보았다. (답변이 틀릴수도 있다...)

  • CustomLoginFilter를 구현함에 있어서 AbstractAuthenticationProcessingFilter, UsernamePasswordAuthenticationFilter, OncePerRequestFilter를 상속받아 구현하는것 중 어느것이 가장 선호 돼?

답변

  • OncePerRequestFilter: 요청 당 한번만 실행되는 필터, 범용성이 높으나 인증 관련 메서드가 제공되지 않기 때문에 직접 구현해야함
  • AbstractAuthenticationProcessingFilter: Spring Security에서 기본적으로 제공하는 필터, 주로 로그인 인증 프로세스에서 사용됨
  • UsernamePasswordAuthenticationFilter: AbstractAuthenticationProcessingFilter를 상속받아 구현, 주로 폼 기반 로그인에서 사용됨

나는 AbstractAuthenticationProcessingFilter를 상속받아 구현해보겠다.

더보기

JsonLoginFilter.java 추가

package com.example.securityexample.global.filter;

// 생략...

public class JsonLoginFilter extends AbstractAuthenticationProcessingFilter {

  public JsonLoginFilter() {
    super(new AntPathRequestMatcher("/api/v1/login", "POST"));
  }

  @Override
  public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
      throws AuthenticationException, IOException, ServletException {
    if (!isApplicationJson(request.getContentType())) {
      throw new AuthenticationServiceException("Not Supported Content-Type: " + request.getContentType());
    }

    LoginDto loginDto = new ObjectMapper().readValue(request.getInputStream(), LoginDto.class);
    return getAuthentication(loginDto);
  }

  private Authentication getAuthentication(LoginDto loginDto) {
    UsernamePasswordAuthenticationToken authentication =
        UsernamePasswordAuthenticationToken.unauthenticated(loginDto.getEmail(), loginDto.getPassword());
    return this.getAuthenticationManager().authenticate(authentication);
  }

  private boolean isApplicationJson(String contentType) {
    return contentType != null && contentType.equals("application/json");
  }
}

큰 흐름은 Request로 LoginDto를 받아 Authentication을 생성하여 AuthenticationManagerauthenticate() 메서드로 전달하는 것이다.

그리고 SecurityFilterChain에 JsonLoginFilter를 등록한다.

 

SecurityConfig.java의 filterChain() 수정

package com.example.securityexample.global.config;

// 생략...
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
        .formLogin(AbstractHttpConfigurer::disable)
        .httpBasic(AbstractHttpConfigurer::disable)
        .csrf(AbstractHttpConfigurer::disable)
        .headers(frame -> frame.frameOptions(FrameOptionsConfig::sameOrigin));
        
    // 생략...

    httpSecurity.addFilterBefore(jsonLoginFilter(), UsernamePasswordAuthenticationFilter.class);

    return httpSecurity.build();
  }

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

  @Bean
  public JsonLoginFilter jsonLoginFilter() {
    return new JsonLoginFilter();
  }
}

 이러고 실행 했는데 오류가 났다.

내용은 JsonLoginFilterAuthenticationManager를 등록해야한다는 것, formLogin의 실행 흐름을 생각해보면 UsernamePasswordAuthenticationFilterProviderManager가 있고 ProcierManagerDaoAuthenticationProvider가 있던 것처럼 JsonLoginFilter에도 ProviderManager를 추가하고, ProviderManagerDaoAuthenticationProvider를 추가해주어야 한다.

 

JsonLoginFilter.java Constructor 수정

package com.example.securityexample.global.filter;
// 생략...
public class JsonLoginFilter extends AbstractAuthenticationProcessingFilter {

  public JsonLoginFilter() {
    super(new AntPathRequestMatcher("/api/v1/login", "POST"));
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    ProviderManager authenticationManager = new ProviderManager(authenticationProvider);

    setAuthenticationManager(authenticationManager);
  }
  
  // 생략...
}

이제 실행이 된다! Postman을 이용해서 Login API 테스트를 했는데 Exception이 발생했다.

내용은 DaoAuthenticationProviderUserDetailsService가 null이라는 것, 이것도 formLogin의 실행 흐름을 생각해보면 DaoAuthenticationProviderUserDetailsServiceInMemoryUserDetailsManager였다. 등록해주자

 

JsonLoginFilter.java Constructor 수정

package com.example.securityexample.global.filter;
// 생략...
public class JsonLoginFilter extends AbstractAuthenticationProcessingFilter {

  public JsonLoginFilter() {
    super(new AntPathRequestMatcher("/api/v1/login", "POST"));
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    authenticationProvider.setUserDetailsService(new InMemoryUserDetailsManager());
    ProviderManager authenticationManager = new ProviderManager(authenticationProvider);

    setAuthenticationManager(authenticationManager);
  }
  // 생략...
}

다시 Postman을 이용해서 Login API 테스트를 해보았는데 계속 403 Forbidden 응답이왔다;; 디버깅을 다시 해보니 InMemoryUserDetailsManagerloadUserByUsername() 메서드에서 User가 null이 나와서 그런것이었다. 원래라면 CustomUserDetailsService를 구현해야 하지만 우선 테스트를 위해서 InMemoryUserDetailsManager에 User를 추가해보겠다.

 

JsonLoginFilter.java의 Constructor 수정

package com.example.securityexample.global.filter;
//생략...
public class JsonLoginFilter extends AbstractAuthenticationProcessingFilter {

  public JsonLoginFilter() {
    super(new AntPathRequestMatcher("/api/v1/login", "POST"));
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    authenticationProvider.setUserDetailsService(new InMemoryUserDetailsManager(
        new User("jinho4744@naver.com", new BCryptPasswordEncoder().encode("12345678"),
            Collections.singleton(new SimpleGrantedAuthority("read")))));
    // 임의로 InMemoryUserDetailsMananger에 User 추가, Password Encode
    
    authenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder()); // PasswordEncoder도 추가
    ProviderManager authenticationManager = new ProviderManager(authenticationProvider);

    setAuthenticationManager(authenticationManager);
  }
  
  // 생략...
  
  // 테스트를 위해 success/failure handler 메서드 추가
  @Override
  protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
      Authentication authResult) throws IOException, ServletException {
    response.setContentType("text/html;charset=utf-8");
    response.setStatus(HttpServletResponse.SC_OK);
    response.getWriter().write("로그인 성공");
  }

  @Override
  protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException failed) throws IOException, ServletException {
    response.setContentType("text/html;charset=utf-8");
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    response.getWriter().write("로그인 실패");
  }
  // 생략...
}

다시 Postman을 이용하여 Login API 테스트를 해보니 성공했다.

 

리팩토링

이제 JsonLoginFilterSecurityConfig를 리팩토링 해보겠다. JsonLoginFilter의 Constructor에서 등록한 AuthenticationManagerAuthenticationProviderSecurityConfig에서 Bean으로 등록하고, SuccessHandler와 FailureHandler 클래스를 추가하여 Bean으로 등록했다. 그리고 LoginDto Validation Check와 매직넘버들을 상수로 추출했다.

더보기

JsonLoginFilter.java 리팩토링

package com.example.securityexample.global.filter;
// 생략...
public class JsonLoginFilter extends AbstractAuthenticationProcessingFilter {

  // 매직넘버 상수로 추출
  private static final String LOGIN_REQUEST_URL = "/api/v1/login";
  private static final String LOGIN_REQUEST_HTTP_METHOD = "POST";
  private static final String LOGIN_REQUEST_CONTENT_TYPE = "application/json";
  private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
      new AntPathRequestMatcher(LOGIN_REQUEST_URL, LOGIN_REQUEST_HTTP_METHOD);

  // For LoginDto Validation Check
  private final Validator validator;

  // JsonLoginFilter를 위한 AuthenticationManager, AuthenticationProvider SecurityConfig로 이동
  // Success/Failure Handler SecurityConfig로 이동
  public JsonLoginFilter(Validator validator) {
    super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER);
    this.validator = validator;
  }

  @Override
  public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
      throws AuthenticationException, IOException {
    if (!isApplicationJson(request.getContentType())) {
      throw new AuthenticationServiceException("Not Supported Content-Type: " + request.getContentType());
    }

    LoginDto loginDto = parseDto(request);
    return getAuthentication(loginDto);
  }

  private LoginDto parseDto(HttpServletRequest request) throws IOException {
    ObjectMapper objectMapper = new ObjectMapper();
    LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);
    Set<ConstraintViolation<LoginDto>> violations = validator.validate(loginDto);
    if (!violations.isEmpty()) {
      Map<String, String> errorMap = violations
          .stream()
          .collect(Collectors.toMap(k -> k.getPropertyPath().toString(), ConstraintViolation::getMessage));
      throw new AuthenticationServiceException(objectMapper.writeValueAsString(errorMap));
    }
    return loginDto;
  }

  private Authentication getAuthentication(LoginDto loginDto) {
    UsernamePasswordAuthenticationToken authentication =
        UsernamePasswordAuthenticationToken.unauthenticated(loginDto.getEmail(), loginDto.getPassword());
    return this.getAuthenticationManager().authenticate(authentication);
  }

  private boolean isApplicationJson(String contentType) {
    return contentType != null && contentType.equals(LOGIN_REQUEST_CONTENT_TYPE);
  }
}

 

SecurityConfig.java 리팩토링

package com.example.securityexample.global.config;
// 생략...
public class SecurityConfig {

  private final Validator validator;

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
        .formLogin(AbstractHttpConfigurer::disable)
        .httpBasic(AbstractHttpConfigurer::disable)
        .csrf(AbstractHttpConfigurer::disable)
        .headers(frame -> frame.frameOptions(FrameOptionsConfig::sameOrigin));

    httpSecurity.authorizeHttpRequests(requests -> requests
        .requestMatchers(HttpMethod.POST, "/api/v1/login", "/api/v1/signup").permitAll()
        .requestMatchers("/h2-console/**").permitAll()
        .requestMatchers("/").permitAll()
    );

    httpSecurity.addFilterBefore(jsonLoginFilter(), UsernamePasswordAuthenticationFilter.class);

    return httpSecurity.build();
  }

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

  @Bean
  public AuthenticationManager authenticationManager() {
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    User tempUser = new User(
        "jinho4744@naver.com",
        new BCryptPasswordEncoder().encode("12345678"),
        Collections.singleton(new SimpleGrantedAuthority("read"))
    );
    authenticationProvider.setUserDetailsService(new InMemoryUserDetailsManager(tempUser));
    authenticationProvider.setPasswordEncoder(bCryptPasswordEncoder());

    return new ProviderManager(authenticationProvider);
  }

  @Bean
  public JsonLoginSuccessHandler jsonLoginSuccessHandler() {
    return new JsonLoginSuccessHandler();
  }

  @Bean
  public JsonLoginFailureHandler jsonLoginFailureHandler() {
    return new JsonLoginFailureHandler();
  }

  @Bean
  public AbstractAuthenticationProcessingFilter jsonLoginFilter() {
    AbstractAuthenticationProcessingFilter loginFilter = new JsonLoginFilter(validator);
    loginFilter.setAuthenticationManager(authenticationManager());
    loginFilter.setAuthenticationSuccessHandler(jsonLoginSuccessHandler());
    loginFilter.setAuthenticationFailureHandler(jsonLoginFailureHandler());
    return loginFilter;
  }
}

 

JsonLoginSuccessHandler.java 추가

package com.example.securityexample.global.handler;
// 생략...
public class JsonLoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {

  private static final String RESPONSE_CONTENT_TYPE = "application/json;charset=utf-8";

  @Override
  public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException exception) throws IOException {
    response.setContentType(RESPONSE_CONTENT_TYPE);
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    response.getWriter().write(exception.getLocalizedMessage());
  }
}

 

JsonLoginFailureHandler.java 추가

package com.example.securityexample.global.handler;
// 생략...
public class JsonLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

  private static final String RESPONSE_CONTENT_TYPE = "application/json;charset=utf-8";
  private static final String SUCCESS_MESSAGE = "Login Success: ";

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication authentication) throws IOException {
    response.setStatus(HttpServletResponse.SC_OK);
    response.setContentType(RESPONSE_CONTENT_TYPE);

    response.getWriter().write(SUCCESS_MESSAGE + authentication.getName());
  }
}

 

 

JsonLoginFilter 추가, SecurityFilterChain에 등록 · JinHoooooou/SecurityExample@d83508d

JinHoooooou committed Jul 15, 2024

github.com

 

 

CustomUserDetailsService 추가

실제로는 위처럼 InMemoryUserDetailsManagerUser를 등록하여 조회하지 않고, 데이터베이스를 조회하여 등록된 User가 있는지 찾는다. 따라서 CustomUserDetailsService를 추가하고 이를 DaoAuthenticationProvider에 등록해야한다.

  • 물론 Spring Security에서 제공하는 구현체중에 JdbcUserDetailsManager가 있긴한데, 여기 구현되어있는 findBy~~ 메서드를 오버라이딩하려면 직접 쿼리를 입력해야한다. 결국 그러한 DB I/O 메서드들을 재정의해서 JdbcUserDetailsManager를 Bean으로 등록해야하기 때문에 그럴 바에는 구현체를 직접 작성하는게 낫다고 생각했다.

UserDetailsService인터페이스를 구현하는 클래스인 AuthService를 작성하고, loadUserByUsername() 메서드의 반환 타입인 UserDetails 인터페이스를 구현하는 클래스 AuthenticatedUser를 작성했다.

  • UserDetails 인터페이스를 구현하는 AuthenticatedUser 클래스를 작성하는 것 대신 기존의 User Entity 클래스가 UserDetails 인터페이스를 구현하는것으로 수정해도 됐는데, User Entity의 결합도가 높아질 것 같다고 생각하여 별도의 클래스로 작성했다.
더보기

AuthService.java 추가

package com.example.securityexample.auth.service;
// 생략...
@Service
@RequiredArgsConstructor
public class AuthService implements UserDetailsService {

  private final UserRepository userRepository;

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    User user = userRepository.findByEmail(username).orElseThrow(
        () -> new BadCredentialsException(Message.NOT_MATCH_LOGIN_DTO)
    );
    return new AuthenticatedUser(user);
  }
}

 

AuthenticatedUser.java 추가

package com.example.securityexample.auth.dto;
//생략...
public class AuthenticatedUser implements UserDetails {

  private final User user;

  public AuthenticatedUser(User user) {
    this.user = user;
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
  }

  @Override
  public String getPassword() {
    return user.getPassword();
  }

  @Override
  public String getUsername() {
    return user.getEmail();
  }

  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return true;
  }
}

 

Provider에 AuthService 등록

package com.example.securityexample.global.config;
// 생략...
public class SecurityConfig {
  // 생략...
  private final AuthService

  @Bean
  public AuthenticationManager authenticationManager() {
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    authenticationProvider.setUserDetailsService(authService); // InMemoryUserDetailsManager 대신 AuthService 추가
    authenticationProvider.setPasswordEncoder(bCryptPasswordEncoder());

    return new ProviderManager(authenticationProvider);
  }

}

 

그리고 auth관련 도메인 패키지를 만들어서 Login API와 관련된 클래스들을 옮겨주었고, 기존 Controller계층에 작성했던 LoginController와 Service계층의 login() 메서드를 삭제했다.

 

CustomUserDetailsService(AuthService), CustomUserDetails(Authenticate… · JinHoooooou/SecurityExample@02cdde1

…dUser) 추가 및 패키지 구조 변경

github.com

 

 

Success/Failure Handler 수정

지금의 Login API의 성공과 실패는 단순히 text형식으로 메시지만 반환하고 있는데 이를 Json 형태로 보내고, 로그인 성공시 세션에 추가하도록 변경해보겠다.

더보기

JsonLoginSuccessHandler.java 수정

package com.example.securityexample.auth.handler;
// 생략...
public class JsonLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

  private static final String RESPONSE_CONTENT_TYPE = "application/json;charset=utf-8";
  private static final String SUCCESS_MESSAGE = "Login Success: ";

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication authentication) throws IOException {
    response.setStatus(HttpServletResponse.SC_OK);
    response.setContentType(RESPONSE_CONTENT_TYPE);

    HttpSession session = request.getSession();
    session.setAttribute("authenticatedUser", authentication.getPrincipal());

    new ObjectMapper().writeValue(response.getWriter(),
        LoginResponseDto.builder().message(SUCCESS_MESSAGE + authentication.getName()).build());
  }
}

 

JsonLoginFailureHandler.java 수정

package com.example.securityexample.auth.handler;
// 생략...
public class JsonLoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {

  private static final String RESPONSE_CONTENT_TYPE = "application/json;charset=utf-8";

  @Override
  public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException exception) throws IOException {
    response.setContentType(RESPONSE_CONTENT_TYPE);
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

    new ObjectMapper().writeValue(response.getWriter(),
        LoginResponseDto.builder().message(Message.NOT_MATCH_LOGIN_DTO).build());
  }
}

 

LoginResponseDto.java 추가

package com.example.securityexample.auth.dto;
// 생략..
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginResponseDto {

  private String message;
}
 

Login Success/Failure Handler 수정 · JinHoooooou/SecurityExample@845ca8c

JinHoooooou committed Jul 19, 2024

github.com


 

# Postman Login API 테스트

성공

쿠키로 세션ID도 잘 전송하는 모씁

 

실패


 

# 결론

이번 글에서 추가 한 내용은 다음과 같다.

  1. formLogin의 흐름
  2. Login API 기존 Controller 계층에서 구현 → JsonLoginFilter를 추가하여 SecurityFilterChain에 등록
    • JsonLoginFilter 추가
    • Success/Failure Handler 추가
    • 리팩토링
  3. CustomUserDetailsService와 CustomUserDetails 추가
    • AuthService 추가
    • AuthenticatedUser 추가
    • 패키지 구조 정리
  4. Success/Failure Handler 수정

다음 글에서는 Session 방식을 Token 방식으로 바꿔 보겠다.


 

# 전체 코드

 

GitHub - JinHoooooou/SecurityExample: Spring Security 적용기

Spring Security 적용기. Contribute to JinHoooooou/SecurityExample development by creating an account on GitHub.

github.com

 

 

# 개요

인증/인가를 구현하는 과정에서 세션 방식과 토큰 방식이 있다는 것을 알게되어 정리한다.

내용만 정리하고 별도 코드는 작성하지 않는다.

지적, 오타 등 환영합니다..


 

# 인증? 인가?

영어로 하면

  • 인증: Authentication
  • 인가: Authorization 

사실 더 헷갈린다..

내가 이해한 것은 다음과 같다.

  • 인증: 로그인
  • 인가: 사용자의 권한 확인 → 권한에 맞는 응답

예를 들어서 얘기해보자면

  • 어떤 서비스의 회원 리스트를 보고싶은데 일반 회원은 활성화된 회원 리스트만 볼 수 있고, 관리자는 탈퇴하거나 정지된 회원들 리스트도 볼 수 있다고 하자.
    • 인증: 로그인을 통해 내가 이 서비스의 회원이라는 것을 인증받는다.
    • 인가: 내 권한을 확인하여 내가 일반 회원이라면 활성화된 회원 리스트만, 관리자라면 모든 회원 리스트를 볼 수 있도록 인가받는다.

 

# 세션

HTTP는 Stateless 프로토콜이다. 즉, 각 요청이 독립적이며, 서버는 이전 요청의 상태를 기억하지 않는다.

  • 하지만 웹 애플리케이션에서는 클라이언트의 상태를 유지해야 하는 경우가 많다. (예를 들어, 로그인 상태나 장바구니 목록 등)

이러한 Stateless 특성을 보완하기 위해 세션이 도입되었다. 세션은 세션 ID를 사용하여 클라이언트가 서버와의 상호작용에서 상태를 유지하는 방법이다.

 

작동 방식

  1. 클라이언트가 로그인에 성공하면 세션을 생성한다.
  2. 생성된 세션은 서버의 어딘가(메모리, 디스크, 데이터베이스)에 저장되고 클라이언트의 브라우저에 쿠키로 저장한다.
  3. 이후 요청에서 브라우저는 쿠키에 세션 ID를 담아서 서버에 함께 보낸다.
  4. 서버는 이 쿠키의 세션 ID를 확인하여 해당 클라이언트의 상태를 유지하고, 그에 맞는 응답을 보내준다.

 

세션을 사용하면 클라이언트의 상태를 지속적으로 유지할 수 있기 때문에 관리에 용이하다는 장점이 있지만 다음과 같은 단점도 있다.

  • 세션을 메모리, 파일 시스템, 데이터베이스 등에 저장하고 관리하고 있어서 사용자가 많아짐에 따라 부하가 발생할 수 있다.
    • 메모리에서 관리하는 경우 파일 시스템이나 데이터베이스에 비해 빠르지만, 휘발성이라는 단점이 있다.
    • 파일 시스템이나 데이터베이스에서 관리하는 경우 I/O가 발생하므로 메모리에서 관리하는것에 비해 느리다.
  • 서버를 여러 대 두고 운영하는 대규모 서비스의 경우 세션 유지가 어렵다.
    • 사용자 요청이 특정 서버에만 할당되도록 설정하거나, 세션을 중앙에서 관리하도록 설정해야 한다.

이러한 단점을 보완하기 위해 토큰을 통한 인증/인가 방식이 도입되었다.


 

# 토큰 (JWT)

서버 입장에서 세션 방식의 부담을 해결하기 위해 도입된 방식으로 클라이언트의 상태를 따로 저장할 필요가 없다.

 

작동 방식

  1. 로그인에 성공하면 토큰을 생성한다.
  2. 토큰은 서버에 저장하지 않고 클라이언트에 전달된다.
  3. 클라이언트는 이후 요청마다 헤더에 이 토큰을 담아서 보낸다.
  4. 서버는 해당 토큰을 검증하여 해당 클라이언트를 확인하고, 그에 맞는 응답을 보내준다.

위 흐름만 보면 세션을 통한 방법과 작동 방식이 크게 다르지 않은 것 같은데, 토큰을 통한 인증/인가 방식은 어떻게 서버에 토큰을 따로 저장하지 않고 클라이언트가 보낸 토큰을 검증할 수 있을까?

 

토큰 구조

토큰은 다음과 같은 구조를 가지고 있으며 암호화 된 3가지 데이터를 이어붙인 형식이다.

{header}.{payload}.{signature}

  1. Header
    • typ: "JWT" 고정
    • alg: 해싱 알고리즘으로 signature를 만드는데 사용된 알고리즘이 지정된다 (HS256 등..)
  2. Payload
    • 토큰에 담을 정보들이 존재하고, 이를 클레임(claim)이라고 한다.
    • 클레임은 세 가지 유형으로 나뉜다.
      • Registered Claims: JWT 표준에서 정의한 클레임으로 iss(발급자), exp(만료 시간), sub(주제), aud(대상자) 등이 포함된다.
      • Public Claims: 충돌 방지를 위해 IANA(Internet Assigned Numbers Authority) JSON 웹 토큰에 등록되거나 URI로 정의된 클레임이다.
      • Private Claims: 클라이언트와 서버 간에 임의로 정의된 클레임이다.
  3. Signature
    • 토큰의 무결성을 검증하는 데 사용된다.
    • 서명은 다음과 같은 과정으로 생성된다.
      • Header와 Payload를 각각 Base64로 인코딩한다.
      • 인코딩된 Header와 Payload를 '.'으로 연결한다.
      • 이 연결된 문자열을 서버 내의 '비밀 키'를 이용하여 Header에서 정의한 알고리즘으로 해싱한다.
      • 해싱한 결과를 Base64 URL로 인코딩하여 서명을 만든다.

토큰이 위와 같은 구조를 가지고 있기 때문에 서버는 클라이언트에게 발급한 토큰을 따로 기억할 필요 없다. 서버는 가지고 있는 '비밀 키'와 JWT 헤더의 알고리즘을 이용해서 해당 토큰을 검증한다. 만약 Payload가 조작되더라도 Signature가 달라지기 때문에 유효하지 않은 것으로 판별할 수 있다.

Signature 값이 일치하고, 만료 기간이 지나지 않았다면 해당 사용자에게 인가를 해준다.

그럼 토큰 방식이 서버에 부담이 줄어드니까 세션 방식 보다 항상 우월하고 항상 좋을까?


 

# 세션 vs 토큰

 

세션

세션은 서버 내에서 관리되기 때문에 서버가 사용자의 상태를 제어할 수 있다. (Stateful)

  • 안정성: 서버측에서 사용자 상태를 관리하기 때문에 보안상 토큰에 비해 안전하다.
    • 한 기기에서만 로그인이 가능한 서비스를 만드는 경우, PC에서 로그인 한 상태의 사용자가 모바일로 다시 로그인을 하면 기존 세션을 종료함으로써 PC에서 로그아웃할 수 있다.
    • 세션 ID가 탈취되더라도 서버측에서 세션을 제거하거는 등 무효화 처리하면 된다.
  • 서버 부담: 서버가 모든 세션을 관리해야하기 때문에 세션 데이터가 증가하면 서버의 부담이 커진다.
    • 세션 저장소를 효율적으로 관리하기 위해 Redis와 같은 In-Memory 데이터베이스를 사용하여 보완할 수 있다.
  • 확장성: 서버가 확장되면 세션 관리가 어려워 진다.
    • 로드 밸런싱을 통한 스티키 세션이나 중앙 세션 저장소를 사용하여 모든 서버가 동일한 세션 정보를 공유하도록 하여 보완할 수 있다.

 

토큰

토큰은 서버에 저장되지 않기 때문에 사용자의 상태를 제어할 수 없다. (Statelsss)

  • 안정성: 서버에서 토큰 정보를 별도로 관리하지 않기 때문에 보안상 안전하지 않을 수 있다.
    • 토큰이 탈취되더라도 서버 측에서 토큰을 무효화할 방법이 없다.
    • 짧은 만료 시간의 Access Token과 별도로 저장하는 Refresh Token을 도입하여 보완할 수 있다.
  • 서버 부담: 서버가 클라이언트의 정보를 보관하지 않기 때문에 세션에 비해 서버의 부담이 줄어든다.
  • 확장성: 서버 간 상태를 공유할 필요가 없기 때문에 세션에 비해 서버 확장에 용이하다.

# 결론

세션과 토큰 방식은 각각 장단점이 있다. 세션 방식은 서버가 사용자의 상태를 제어할 수 있고 보안상 안전하지만 확장성이 떨어진다. 반면 토큰 방식은 서버 부담이 적고 확장성이 뛰어나지만 상태 제어가 어렵고 보안상 위험이 존재할 수 있다.

따라서 운영하려는 서비스의 특성에 따라 적절한 방식을 선택하는 것이 중요하다고 할 수 있다. 또한 각 방식의 단점을 보완하기 위한 전략을 도입하여 보다 안전하고 효율적인 인증/인가를 구현할 필요가 있다. 

추후 Sticky Session, Refresh Token에 대한 포스팅 할 수 있으면 하기..

'Etc' 카테고리의 다른 글

IntelliJ와 git bash 연동하기  (0) 2022.01.25
내가 배운 팀 프로젝트 협업 1 - 코딩 컨벤션  (0) 2021.10.17

+ Recent posts