본문 바로가기

프로젝트

[토이프로젝트] - 6. 인증

오늘은 로그인 관련 작업을 진행했습니다.

로그인은 가장 기본적이면서도 중요한 기능입니다. 로그인을 구현하기 위해서는 단순히 이메일과 비밀번호를 비교해서 맞는지 확인하는 로직만 추가해서 하는 방법도 있지만, 이번에는 Spring Security라는 보안 프레임워크를 사용해서 구현해 볼 예정입니다.

 

Spring Security는 인증, 인가등 보호 기능을 제공하는 프레임워크로, 애플리케이션 보호에 대한 기능을 자체적으로 구현할 필요없이 보안 관련 기능을 효율적이고 신속하게 구현할 수 있도록 도와줍니다.

 

단순 로그인 뿐만 아니라 로그인 프로세스를 타 사이트(네이버, 카카오 ...)에게 위임하는 OAuth도 쉽게 제공해 줄 수 있다는 장점이 있기 때문에 Spring Security를 선택하였습니다.

 

Spring Security의 동작 방식만 간단하게 설명 후 구현 설명 드리곘습니다.

 

spring security 6.3.4 버전을 사용했습니다.


1. Spring Security 구조

 

1.1 Filter 기반

Filter의 구조적 위치 - https://hello-judy-world.tistory.com/216

Spring Security는 필터 구조로써 Spring MVC와 분리되어 Dispatcher Servlet보다 먼저 처리됩니다.

그렇기 때문에 Filter에서 인증이 실패하면 Controller가 호출되지 않습니다.

 

1.2 Spring Security Architecture

Spring Security Architecture - https://dev-coco.tistory.com/174

 

Spring Security의 기본 처리 과정은 아래와 같습니다.

  1. Security Filter에서 Http 요청을 가로챕니다.
  2. UsernamePasswordAuthenticationFilter를 통해 UsernamePasswordAuthentication Token이라는 인증용 토큰을 생성합니다.
  3. AuthenticationManger의 구현체인 ProviderManger에서 인증용 토큰을 처리할 수 있는 Provider인 AuthenticationProvider를 찾습니다.
  4. AuthenticationProvider는 UserDetailsService를 통해 인증용 토큰에 존재하는 요청자 정보와 저장되어 있는 사용자 정보를 비교하여 인증 여부를 판단합니다.
  5. UserDetailsService에서는 DB에서 UserDetails를 가져와서 인증용 토큰과 비교합니다.
  6. 존재하는 사용자인 경우 인증 정보를 담은 Authentication을 반환합니다.
  7. Authentication은 UsernamePasswordAuthenticationFilter로 전달되어 SecurityContext에 저장되어 인증은 완료가 됩니다.

2. 의존성 주입

spring security는 filter 기반으로 동작하기 때문에 http 요청, 응답 중심으로 관심이 집중되어 있기 때문에 의존성은 scm_api 모듈에 등록했습니다.

 

//build.gradle(:scm_api)
dependencies {
implementation project(":scm_domain")

implementation 'org.springframework.boot:spring-boot-starter-web'

implementation("org.springframework.boot:spring-boot-starter-security")
}

jar {
enabled = false
}

 

3. Security Config

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity()
public class SecurityConfig {

    private final AccountDetailService accountDetailService;

//1
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .addFilterBefore(buildAuthCustomFilter(), UsernamePasswordAuthenticationFilter.class)
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }

//2
    @Bean
    public AuthCustomFilter buildAuthCustomFilter() {
        RequestMatcher matcher = new LoginRequestMatcher();

        AuthCustomFilter customFilter = new AuthCustomFilter(matcher);
        customFilter.setAuthenticationManager(buildAuthenticationProviderManager());
        customFilter.setAuthenticationSuccessHandler(buildSuccessHandler());
        customFilter.setAuthenticationFailureHandler(buildFailureHandler());

        return customFilter;
    }

//3
    @Bean
    public AuthenticationManager buildAuthenticationProviderManager() {
        return new ProviderManager(buildAuthCustomProvider());
    }

    public AuthenticationProvider buildAuthCustomProvider() {
        return new AuthCustomProvider(accountDetailService, bCryptPasswordEncoder());
    }

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

//5
    public AuthenticationSuccessHandler buildSuccessHandler() {
        return new LoginSuccessHandler();
    }

//6
    public AuthenticationFailureHandler buildFailureHandler() {
        return new LoginFailureHandler();
    }

}

 

Spring Security를 사용하기 위해서는 먼저 SecurityConfig 설정파일을 생성해주어야 합니다.

@Configuration과 @EnableWebSecurity 어노테이션을 적용해 줍니다.

 

@EnableWebSecurity를 등록해 줘야만 SecurityFilterChain을 활성화하여 Spring Security에서 사용하기 위해 Custom 된 filter들이 FilterChain에 등록되어 인증, 인가 과정에 사용될 수 있습니다.

 

3.1 SecuirtyFilterChain

예전에 Spring Security를 공부했을 때는WebSecurityConfigurerAdapter를 적용했던 것 같은데 지금은 권장하지 않는 방법이라고 해서 새로운 방법으로 config를 설정하게 되었네요.

예전에는 and()를 사용했던 것 같은데 stream()으로 모두 변경되었습니다.

 

인증, 인가에 사용될 filter 및 보안 설정을 filter에 등록하기 위한 빈을 생성해 줍니다.

 

1. CSRF(Cross-Site Request Forgery)

 

정의 : 사용자가 인증된 상태를 악용해서 사용자가 의도하지 않은 공격자의 요청을 대신 전송하도록 만드는 보안 취약점.

예: 사용자가 은행에 로그인된 상태에서, 공격자가 준비한 악성 링크를 클릭하면 사용자의 계정으로 금액 이체 요청이 전송

 

.csrf(AbstractHttpConfigurer::disable)

 

간단한 프로젝트이기 때문에 사용 안 함.

 

2. CORS(Cross-Origin Resource Sharing)

 

정의 : 웹 애플리케이션이 다른 출처의 리소스에 접근할 때, 브라우저가 이를 허용할지 결정하는 보안 메커니즘.

예 : 웹 앱(도메인 example.com)에서 API 서버(도메인 api.example.com)의 데이터를 가져오려 할 때, 브라우저가 이를 제한.

 

 .cors(AbstractHttpConfigurer::disable)

 

간단한 프로젝트이기 때문에 사용 안 함.

 

3. FormLogin

 

Spring Security에서 기본적으로 제공하는 로그인 폼 설정.

 

.formLogin(AbstractHttpConfigurer::disable)

 

로그인 창을 직접 구현할 예정이기 때문에 사용 안 함.

 

4. addFilterBefore

 

인증, 인가 처리를 하기 위한 custom 필터를 SpringSecurityFilter에 등록하기 위한 함수.

 

.addFilterBefore(buildAuthCustomFilter(), UsernamePasswordAuthenticationFilter.class)

 

사용자 인증 처리를 담당하는 UsernamePasswordAuthenticationFilter 이전에 인증 처리를 할 AuthCustomFilter가 먼저 동작하게 하게 합니다.

 

5. sessionManagement

 

Spring Security의 세션 사용 방식 정의.

.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

 

RestApi 서버이고 추후에 JWT를 사용할 예정이기 때문에 세션은 사용하지 않도록 설정합니다.

 

3.2 AuthCustomFilter

인증 처리를 담당할 사용자가 지정한 Filter입니다.

 

@Bean
    public AuthCustomFilter buildAuthCustomFilter() {
        RequestMatcher matcher = new LoginRequestMatcher();

        AuthCustomFilter customFilter = new AuthCustomFilter(matcher);
        customFilter.setAuthenticationManager(buildAuthenticationProviderManager());
        customFilter.setAuthenticationSuccessHandler(buildSuccessHandler());
        customFilter.setAuthenticationFailureHandler(buildFailureHandler());

        return customFilter;
    }

 

AuthCustomFilter는 AbstractAuthenticationProcessingFilter를 상속받은 Filter로 구현했습니다.

 

많은 분들의 spring security 구현 방법을 찾아봤습니다. 인증 Filter를 구현하는데 OncePerRequestFilter를 많이 사용하셔서 OncePerRequestFilter와 AbstractAuthenticationProcessingFilter 중 고민을 했습니다.

 

OncePerRequestFilter는 클라이언트의 정보를 기억해 두었다가 동일한 클라이언트의 요청을 판단하여 동일 인증을 막아주는 Filter이기 때문에 불필요한 중복 필터를 실행하지 않을 수 있다는 장점이 있습니다.

그리고 AbstractAuthenticationProcessingFilter는 특정 URL 패턴에 매핑되고 성공, 실패 시 지정된 로직을 실행합니다. 또한 AuthenticationManger를 등록해야 하기 때문에 학습한 Spring Security 구조를 보다 확실하게 이해한 상태로 구현할 수 있다는 장점이 있었습니다.

 

위의 코드를 보면 AuthCustomFilter를 생성할 때 URL 매핑을 위한 LoginRequestMatcher(RequestMatcher), 인증 프로세스를 진행할 AuthenticationProviderManger(AuthenticationManager), 성공 시 동작할 LoginSuccessHandler(AuthenticationSuccessHandler), 실패 시 동작할 LoginFailureHandler(AuthenticationFailureHandler)를 등록해서 초기화하기 때문에 인증 로직에 필요한 것들을 보다 명확하게 알 수 있습니다.

 

먼저 URL 매핑을 위한 LoginRequestMacther를 보겠습니다.

public class LoginRequestMatcher implements RequestMatcher {

    private final String DEFAULT_LOGIN_HTTP_METHOD = "POST";
    private final String[] LOGIN_PATH = new String[]{"/api/auth/login"};
    private List<AntPathRequestMatcher> matcherList = new ArrayList<>();

    public LoginRequestMatcher() {
        for(String pattern : LOGIN_PATH) {
            matcherList.add(new AntPathRequestMatcher(pattern, DEFAULT_LOGIN_HTTP_METHOD));
        }
    }

    @Override
    public boolean matches(HttpServletRequest request) {
        return matcherList.stream().anyMatch(matcher -> matcher.matches(request));
    }
}

로그인을 위한 요청은 httpMethod의 'POST'와 URL은 '/api/auth/login'일 때 동작하도록 등록하였습니다.

해당 method와 url의 경우에만 AuthCustomFilter에서 인증 프로세스가 동작하게 됩니다. 

 

public class AuthCustomFilter extends AbstractAuthenticationProcessingFilter {

    private final String EmailKey = "email";
    private final String PasswordKey = "password";
    private final String CONTENT_TYPE = "application/json";
    private final ObjectMapper objectMapper;
    public AuthCustomFilter(RequestMatcher matcher) {
        super(matcher);
        objectMapper = new ObjectMapper();
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {

        if(request.getContentType() == null || !StringUtils.pathEquals(request.getContentType(), CONTENT_TYPE)) {
            throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
        }

        ServletInputStream inputStream = request.getInputStream();
        Map<String, String> usernamePasswordMap = objectMapper.readValue(inputStream, Map.class);

        String email = usernamePasswordMap.get(EmailKey);
        String password = usernamePasswordMap.get(PasswordKey);

        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(email, password);

        return this.getAuthenticationManager().authenticate(authentication);
    }
}

 

AbstractAuthenticationProcessingFilter를 상속받은 AuthCustomFilter입니다.

AbstractAuthenticationProcessingFilter가 호출되면 attemptAuthentication 함수가 호출됩니다.

POST형식의 body에 email, password라는 필드값으로 로그인 정보가 들어오기 때문에 HttpServletRequest에서 body의 값을 받기 위해 ObjectMapper를 사용해서 각 값을 가져올 수 있습니다.

 

HttpServletRequest로 필드값을 ObjectMapper로 가져올 때 반드시 요청 HEADER의 CONTENT_TYPE을 application/json으로 요청해줘야 합니다.

 

인증 과정을 처리하기 위해 등록한 AuthenticationManger의 authenticate에 인증 전 토큰인 UsernamePasswordAuthenticationToken을 만들어서 전달합니다.

 

3.3 AuthenticationManager

@Bean
    public AuthenticationManager buildAuthenticationProviderManager() {
        return new ProviderManager(buildAuthCustomProvider());
    }

 

AuthenticationManager

Filter에서 인증을 처리하기 위한 AuthenticationManger로써 authenticate함수에 전달된 인증 개체(Authentication)를 인증하려고 시도합니다.

AuthCustomFilter에서 전달한 인증 전 객체인 UsernamePasswordAuthenticationToken이 전달되게 됩니다.

 

AuthenticationManger는 AuthenticationProvider가 전달된 Authentication을 인증할 수 있을 때까지 AuthenticationProvider목록을 호출하며 인증을 시도하게 됩니다. 

 

ProviderManger에서 provider 리스트

위에서도 AuthCustomFilter에서 인증 개체(Authentication)을 전달 했을때 provider리스트에 등록한 AuthCustomProvider가 존재하는 것을 확인할 수 있습니다.

 

3.4 AuthenticationProvider

public AuthenticationProvider buildAuthCustomProvider() {
        return new AuthCustomProvider(accountDetailService, bCryptPasswordEncoder());
    }

Authentication을 인증하는 개체인 AuthenticationProvider입니다.

AuthenticationProvider

AuthenticationProvider에는 authenticated와 supports 함수가 존재합니다.

  • authenticate
    인증 전 개체인 Authentication을 받아서 인증 프로세스를 진행합니다.
    반환되는 값은 인증이 완료된 Authentication을 전달하고 인증할 수 없는 경우에는 null을 반환합니다.
  • supports
    인증을 처리할 것인지 여부 판단을 하는 함수입니다.
    true를 반환하는 경우 AuthenticationManger에서 해당 AuthenticationProvider에 인증 처리(authenticate함수)를 요청합니다.

    설명에 따르면 supports가 true라고 인증 개체(Authentication)를 인증할 수 있다는 보장을 할 수 없다고 합니다.(authenticate 함수에서 null을 반환할 수 있다는 얘기인 것 같습니다.)
public class AuthCustomProvider implements AuthenticationProvider {

    private final AccountDetailService accountDetailService;
    private final BCryptPasswordEncoder passwordEncoder;

    public AuthCustomProvider(AccountDetailService accountDetailService, BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.accountDetailService = accountDetailService;
        this.passwordEncoder = bCryptPasswordEncoder;
    }

//2
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        log.info(authentication.getName());

        UserDetails principalDetail = accountDetailService.loadUserByUsername(authentication.getName());

        try{
            this.checkPassword(principalDetail, authentication);
        }
        catch (LoginAuthException ex) {
            throw new LoginAuthException(ex);
        }

        Authentication principal = new PrincipalDetails((AccountDetails) principalDetail);

        return principal;
    }

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

private void checkPassword(UserDetails userDetails, Authentication authentication) throws LoginAuthException {
        if(authentication.getCredentials() == null) {
            log.info("password is null");
            throw new BadCredentialsException("INVALID Password !");
        }

        String inputPassword = authentication.getCredentials().toString();

        if(!this.passwordEncoder.matches(inputPassword, userDetails.getPassword())) {
            log.info("fail match password");
            throw new LoginAuthException("INVALID Password");
        }
    }

위는 AuthenticationProvider을 상속받은 AuthCustomProvider입니다.

  1. supports
    인증 개체(Authentication)가 UsernamePasswordAuthenticationToken 클래스인 경우 허용하도록 하였습니다.
  2. authentcate
    인증 개체(Authentication)의 요청자 정보에 대한 인증 처리를 의존성 주입한 AccountDetailService를 통해 사용자 정보를 가져온 후 비밀번호를 비교하여 인증 처리를 수행합니다.

    인증이 완료되었다면 인증 완료 개체인 PrincipalDetails를 생성해서 전달합니다.

 

3.5 BCryptPasswordEncode

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

Spring Security에서 제공하는 암호화 클래스입니다.

실질적 인증을 처리할 AuthenticationProvider에서 DB에서 조회한 사용자 정보의 password와 비교하기 위한 목적으로 사용합니다.

 

AuthenticationProvider에서 사용하지만, 회원가입 시 암호화한 비밀번호를 저장하는 데 사용할 의존성 주입을 위해 Bean으로 등록합니다.

 

3.6 AuthenticationSuccessHandler

public AuthenticationSuccessHandler buildSuccessHandler() {
        return new LoginSuccessHandler();
    }

AbstractAuthenticationProcessingFilter를 통한 인증 과정을 성공했을 경우 호출되는 SuccessHandler입니다.

public class LoginSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        AccountDetails userDetails = (AccountDetails) authentication.getPrincipal();
        log.info( "로그인 성공. JWT 발급. username: {}" ,userDetails.getUsername());


        response.getWriter().write("success");
    }
}

 

 

 

3.7 AuthenticationFailureHandler

public AuthenticationFailureHandler buildFailureHandler() {
        return new LoginFailureHandler();
    }

AbstractAuthenticationProcessingFilter를 통한 인증 과정을 실패했을 경우 호출되는 FailedHandler입니다.

public class LoginFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //401 인증 실패
        response.getWriter().write("fail");
        log.info("로그인 실패");
    }
}

 

 

SpringSecurity를 통해 이메일, 비밀번호로 로그인 인증을 처리하는 프로세스를 구현해 보았습니다.

 

감사합니다.

 


참고

https://dev-coco.tistory.com/174

 

Spring Security의 구조(Architecture) 및 처리 과정 알아보기

시작하기 앞서 스프링 시큐리티에서 어플리케이션 보안을 구성하는 두 가지 영역에 대해 간단히 알아보자.인증(Authentication)과 인가(Authorization)대부분의 시스템에서는 회원을 관리하고 있고, 그

dev-coco.tistory.com

https://velog.io/@on5949/SpringSecurity-AbstractAuthenticationProcessingFilter-%EC%99%84%EC%A0%84-%EC%A0%95%EB%B3%B5

 

[SpringSecurity] AbstractAuthenticationProcessingFilter 완전 정복

Abstract processor of browser-based HTTP-based authentication requests.브라우저 기반의 Http 기반 인증에 대한 추상 프로세서이다.GenericFilterBean의 상속을 받고 있고, subclass로는 OAut

velog.io

https://hello-judy-world.tistory.com/216

 

[Spring] Spring Security 개념과 처리 과정 👮‍♀️ (+근데 상황극을 곁들인)

오늘도 노드 마을에서 온.. 토끼는 낯선 기술에 울고 있다..(?) 그렇다.. 유저가 있는 서비스라면 인증과 인가 처리는 필수이다. Spring에서는 Spring Security라는 프레임워크로 관련 기능을 제공하고

hello-judy-world.tistory.com

https://devhooney.tistory.com/318

 

[Spring] AbstractAuthenticationProcessingFilter, OncePerRequestFilter 차이

AbstractAuthenticationProcessingFilter, OncePerRequestFilter 차이를 알아보자!!   Spring Security에서 AbstractAuthenticationProcessingFilter와 OncePerRequestFilter는 두 가지 주요한 필터 유형이다. 이들은 인증 및 요청 처리

devhooney.tistory.com