상품 공급 시스템 - Spring Security를 사용하여 인증/인가 구현

로그인(인증/인가) 기능 구현

인증(Authentication)이란 사용자가 누구인지 확인하는 것이며 허용된 사용자에게만 서비스를 제공하는 것이 목적이다.
인가(Authorizatino)란 인증을 통해 검증된 사용자가 요청을 보낼 때 해당 요청(resource)에 대한 자격(권리)이 있는지를 확인하는 것이다. 사용자의 권한에 따라 리소스에 대한 접근을 차별하는 것이 목적이다.

스프링(부트) 애플리케이션의 경우 Spring Security를 사용하면 편리하게 원하는 기능을 구현할 수 있다. 스프링의 하위 프로젝트인 스프링 시큐리티는 서블릿 필터(Servlet Filter) 기반으로 동작한다.

Spring Security는 기본적으로 UsernamePasswordAuthenticationFilter를 통해 인증을 수행한다. 이 필터에서는 인증이 실패하면 로그인 폼이 포함된 화면을 전달한다. 지금 제작 중인 서버 애플리케이션은 REST API 서버이기 때문에 JWT를 사용하는 인증 필터를 구현하고 `UsernamePasswordAuthenticationFilter` 필터 앞에 인증 필터를 배치하여 인증 주체를 변경해야 한다.

Servlet Filter

Servlet Filter는 서블릿에 붙어(attach) 그 내용을 수정하거나 사용하여 특정 기능을 수행하는 필터이다. 서블릿이 생성되기 전 또는 후에 기능을 수행한다.

Filter

Filter는 Java Servlet specification version 2.3에서 도입된 새로운 component type이다. 필터는 동적으로 요청과 응답을 가로채(intercepts)고 그 속의 정보(header 또는 content)를 변형하거나 사용한다. Filter는 일반적으로 스스로 응답을 생성할 수는 없지만 서블릿이나 JSP page에 붙어(attach) 기능을 수행한다.

Filter는 반복되는 작업을 재사용 가능한 유닛(코드)로 묶는다(encapsulate). 이렇게 모듈화된 코드는 관리하기 편하다. Filter는 다음과 같은 다양한 종류의 기능(데이터 전처리, 후처리)을 수행한다.

  • 인증/인가 - 사용자가 전송한 정보를 바탕으로 요청을 차단한다.
  • Logging and auditing - 웹 애플리케이션의 사용자를 추적(tracking)한다.
  • Data compression - 여러 데이터에 대해 공통적으로 압축을 한 뒤 사용자에게 전송한다.
  • Localization - 응답과 요청을 특정 지역으로 targeting한다.
  • Trnsformations of XML content - 하나 이상의 클라이언트 종류에 대해 적절한 응답 형식으로 변환한다.

Filter API는 javax.servlet 패키지 내부의 Filter FilterChain FilterConfig 인터페이스에 선언되어 있다. Filter 인터페이스를 구현함으로써 filter를 정의할 수 있다. 컨테이너에 의해 필터에 전달된 filter chain은 여러 개의 필터들을 순차적으로 실행시키는 매커니즘을 제공한다.

Servlet

Servlet이란 request-response 모델로 구현된 애플리케이션을 호스팅하는 서버의 능력(capability)을 확장하기 위한 Java 클래스이다. Servlet을 사용하면 애플리케이션이 정적 웹 서버에서 실행되더라도 동적 컨텐츠를 생성하여 응답으로 보낼 수 있다. Java Servlet 기술은 HTTP에 특화된(specified) 클래스를 제공한다.

즉, Servlet은 Java 애플리케이션와 서버 사이에서 데이터를 전달하는데 사용되는 객체이다.

javax.servletjavax.servlet.http 패키지는 servlet을 작성할 수 있는 클래스들과 인터페이스들을 제공한다. 모든 서블릿은 Servlet 인터페이스를 구현하여 서블릿의 생명주기 메서드를 정의해야 한다.

Static vs Dynamic Web server

정적 웹 서버는 기본적으로 정적 컨텐츠(내용이 고정된 HTML, CSS, JavaScript, 이미지 등)를 제공하는 것이 주된 목적이지만 적절한 확장(servlet), 모듈 또는 설정을 통해 동적 컨텐츠도 처리하거나 전달할 수 있다. - ChatGPT -

정적 웹 서버와 동적 웹 서버를 분리하는 기준은 서버의 주된 기능, 즉 논리적인 요소일 뿐이다. 오직 정적 컨텐츠 제공만을 할 수 있는 서버같은 것은 없다. 다만 주된 기능이 다르기 때문에 하드웨어의 성능에 차이가 있을 수 있다.

  정적 서버 동적 서버
CPU 😌EASY 🔥사용자의 요청에 따라 실시간으로 서비스 로직을 수행한다. SSR 애플리케이션을 호스팅할 경우 렌더링 연산이 필요하다.
RAM 🔥자주 접근되는 파일을 메모리에 캐싱하여 사용량이 증가될수 있다. 🔥애플리케이션, 프레임워크, 커넥션 등이 메모리에 로드되어야 한다.
DISK I/O 🔥파일 시스템에서 파일을 읽어 클라이언트에게 전달한다. 🤔로그 작성 외에는 별로 없을것 같다…

XML namespace

XML에서 element(요소)의 이름은 개발자에 의해 정의된다. 그렇기 때문에 서로 다른 xml 파일이 섞이는 경우 동일한 이름 간의 충돌이 발생할 수 있다. 이를 namepsace를 통해 해결한다. C++의 namespace와 유사하다. namespace는 요소의 이름 사이 충돌을 방지하고 요소에 대해 고유한 컨텍스트를 제공한다.

<!-- 출처 https://www.w3schools.com/xml/xml_namespaces.asp -->
<root xmlns:h="http://www.w3.org/TR/html4/"
xmlns:f="https://www.w3schools.com/furniture">

<h:table>
  <h:tr>
    <h:td>Apples</h:td>
    <h:td>Bananas</h:td>
  </h:tr>
</h:table>

<f:table>
  <f:name>African Coffee Table</f:name>
  <f:width>80</f:width>
  <f:length>120</f:length>
</f:table>

</root>

<namespace 이름:태그 이름> 형식으로 사용하며 namespace는 위와 같이 root 태그에서 정의될 수도 있고 아래와 같이 사용되는 태그에서 정의될 수도 있다.


<root>

<h:table xmlns:h="http://www.w3.org/TR/html4/">
  <h:tr>
    <h:td>Apples</h:td>
    <h:td>Bananas</h:td>
  </h:tr>
</h:table>

<f:table xmlns:f="https://www.w3schools.com/furniture">
  <f:name>African Coffee Table</f:name>
  <f:width>80</f:width>
  <f:length>120</f:length>
</f:table>

</root>

Spring Security 구조

`DelegatingFilterProxy`는 스프링 컨테이너가 관리하는 Filter 객체(springSecurityFilterChain이라는 bean)를 WAS의 FilterChain에 프록싱을 통해 삽입한다.

보안 필터체인

스프링 시큐리티는 인증/인가에 사용하고자 하는 filter chain을 서블릿 컨테이너의 필터 사이에서 동작시키기 위해 DelegatingFilterProxy를 사용한다. DelegatingFilterProxy는 표준 서블릿 필터를 구현하고 있으며 실제 보안 기능을 수행할 FilterChain을 내부에 가지고 있다. 필터체인 프록시는 스프링 부트의 자동 설정에 의해 자동 생성된다.

Servlet Container

서블릿 컨테이너는 서블릿을 관리한다. 쉽게 말하면 그냥 웹앱 서버(WAS 예를 들면 apache tomcat)이다. WAS는 호스팅하는 웹앱들의 설정파일(web.xml)을 읽어서 요청을 받으면 해당 요청을 적절한 웹앱으로 라우팅한다. 그리고 해당 애플리케이션의 설정파일(web.xml)에 정의된 필터체인이 호출된다.
서블릿 컨테이너 내에서 각 애플리케이션은 독립적인 컨텍스트에서 실행된다. 이 컨텍스트는 각 애플리케이션의 설정, 서블릿, 필터 등을 캡슐화 한 것이다.

WAS

그림에서 DispatcherServlet은 각 애플리케이션으로의 진입점이다. 서블릿 컨테이너가 생성한 HttpServletRequest 객체를 인자로 받는다. 반대로 응답은 HttpServletResponse 형태로 전달된다.
DispatcherServlet에 HTTP 요청이 도달하면 HandlerMapping이라는 bean 객체에게 해당 요청을 처리할 컨트롤러 검색을 요청한다. HandlerMapping bean은 요청을 처리할 컨트롤러 bean을 찾아서 DispatcherServlet에게 반환한다. DispatcherServlet은 HandlerAdapter bean을 사용하여 찾은 컨트롤러 bean을 실행한다.

보안 설정 작성

Spring Security 2.0은 xml namespace를 사용하여 Acegi에 비해 설정이 간단하다. 그리고 스프링 3.2는 Java 설정 방식을 도입하여 xml 보안 설정의 필요성을 없앴다.

EnableWebSecurity vs EnableWebMvcSecurity

WebSecurityAdapter 대체

대부분의 책에는 WebSecurityAdapter를 상속받아 Configuration 클래스를 작성하였는데 WebSecurityAdapter는 deprecated 상태이다.

@Configuration
// TODO 설정 방식 정리
public class FilterChainConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                 // 인증이 필요 없는 경로
                .antMatchers("/api/signup/**", "/api/auth/signin")
                .permitAll()
                .and()
                .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
                .and()
                .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider()),
                        UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public JwtProvider jwtProvider() {
        return new JwtProvider();
    }
}

  • SecurityFilterChain 인터페이스 - 이 애플리케이션의 DispatcherServlet으로 전달되는 모든 요청(HttpServletRequest 객체)에 대해 실행될 filter chain을 정의한다.
  • HttpSecurity 클래스 - 이 클래스의 인스턴스는 애플리케이션이 전달받는 요청에 대한 web based security를 설정한다. 모든 요청에 대해 적용되는 것이 기본값이고, requestMatcher(requestMatcher) 또는 비슷한 메서드들로 적용할 요청을 제한할 수 있다.
  • httpBasic().disable() - HTTP 기본 인증(Authorization 헤더에 사용자 이름과 비밀번호를 암호화 없이 전송하는 방식)을 비활성화 한다. 이 애플리케이션에서는 JWT를 통해 인증을 수행하기 때문에 비활성화 하였다.
  • sessionManagement().sessionCreationPolicy(...) - 세션 생성 전략 정의. JWT를 통해 인증을 수행하기 때문에 세션이 필요없다.
  • authorizeRequests() - HttpServletRequest를 통한 접근(access)를 URL 패턴을 사용하여 제한하는데 사용한다.
  • antMatchers("/api/signup/**", "/api/auth/signin").permitAll() - 인자로 받은 패턴(ant pattern)에 매칭되는 url은 모든 사용자에 대해 접근을 허용한다.
  • exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler) - 인가에 실패(예외 발생)하였을 때의 동작을 정의한다.
  • exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint) - 인증에 실패(예외 발생)하였을 때의 동작을 정의한다.

JwtAuthenticationFilter

요청의 헤더에 있는 JWT를 확인하여 인증/인가를 수행하는 필터이다. Spring Security의 필터체인에는 기본적으로 폼 기반의 UsernamePasswordAuthenticationFilter를 통해 인증을 수행한다. 둘 중 하나만 통과해도 인증에 성공한 것으로 간주된다. JWT 방식으로 인증을 수행하기 때문에 굳이 UsernamePasswordAuthenticationFilter까지 실행할 필요가 없기 때문에 JWT 검증 필터는 그 앞에 배치한다.

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    @Autowired
    public JwtAuthenticationFilter(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        Optional<String> accessToken = extractTokenFromRequest(request);
        accessToken.ifPresent( (token) -> {
            jwtProvider.checkExpiration(token);
            SecurityContextHolder.getContext()
                    .setAuthentication(jwtProvider.getAuthentication(token));
        });
        filterChain.doFilter(request, response);
    }

    private Optional<String> extractTokenFromRequest(HttpServletRequest request) {
        /**
         * 인증이 필요없는 요청의 경우 헤더에 X-AUTH-TOKEN 값이 없다.
         */
        String token = request.getHeader("X-AUTH-TOKEN");
        return Optional.ofNullable(token);
    }


}

  • SecurityContextHolder.getContext().setAuthentication(authentication) - 성공한 인증에 대한 정보(사용자 이름, 권한 리스트 등)를 SecurityContext에 등록한다. SecurityContextHolder는 ThreadLocal을 사용하여 인증 정보(Authentication 인스턴스)를 저장한다. 즉 각 스레드(http 요청)마다 SecurityContext 인스턴스가 생성되고 하나의 스레드가 실행되는 동안 등록된 Authentication 인스턴스에 접근하여 인증 정보를 획득 할 수 있다.

Controller

@RestController
@RequestMapping("/api/auth/signin")
public class SigninController {

    private final AuthenticationService authService;

    @Autowired
    public SigninController(AuthenticationService authService) {
        this.authService = authService;
    }

    @PostMapping("")
    public BaseResponse signin(@RequestBody  SigninRequestDto request) {
        try {
            SuccessfulSigninResultDto resultDto = authService.signin(request);
            return new BaseResponse(resultDto);
        } catch (EmailNotFoundException wrongEmail) {
            return new BaseResponse(ResponseStatus.EMAIL_NOT_FOUND);
        } catch (PasswordMismatchException wrongPassword) {
            return new BaseResponse(ResponseStatus.WRONG_PASSWORD);
        }
    }
}

Service 객체가 반환하는 값으로 응답 객체를 만들어 반환한다.

Service

@Service
public class AuthenticationService {

    private SigninDao signinDao;
    private JwtProvider jwtProvider;
    private PasswordEncoder passwordEncoder;

    @Autowired
    public AuthenticationService(SigninDao signinDao,
                                 JwtProvider jwtProvider,
                                 PasswordEncoder passwordEncoder) {
        this.signinDao = signinDao;
        this.jwtProvider = jwtProvider;
        this.passwordEncoder = passwordEncoder;
    }

    public SuccessfulSigninResultDto signin(SigninRequestDto claim)
            throws EmailNotFoundException, PasswordMismatchException, BaseSQLException {
        ExpectedMemberInfo expectedMember = signinDao.retrieveMemberInfoByEmail(claim.getEmail());
        checkPasswordMatch(expectedMember.getPassword(), claim.getPassword());
        updateLastSiginDate(expectedMember.getMemberId());
        String accessToken = jwtProvider.createToken(expectedMember);

        return new SuccessfulSigninResultDto(expectedMember, accessToken);
    }

    private void checkPasswordMatch(String expected, String actual)
            throws PasswordMismatchException {
        if ( !passwordEncoder.matches(actual, expected) )
            throw new PasswordMismatchException();
    }

    private void updateLastSiginDate(Long memberId)
            throws BaseSQLException {
        signinDao.updateLastSigninDate(memberId,
                LocalDate.now(ZoneId.of(TIMEZONE))
        );
    }
}

로그인 기능을 signin 메서드로 추상화 하였다. 이 메서드의 로직은 아래와 같다. * 전달받은 이메일로 활성화된 회원 조회 - retrieveMemberInfoByEmail * 비밀번호 확인 - checkPasswordMatch * 마지막 로그인 날짜 업데이트 - updateLastSiginDate * 토큰 생성 - createToken

Test

서비스 객체의 로직이 의도한대로 작동하는지 테스트하는 코드. DAO 객체는 mock을 사용했다.

public class SigninTest {

    private final SigninDao signinDao;
    private final JwtProvider jwtProvider;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationService authenticationService;

    public SigninTest() {
        signinDao = Mockito.mock(SigninDaoJDBC.class);
        this.jwtProvider = new JwtProvider();
        this.passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
        this.authenticationService = new AuthenticationService(signinDao, jwtProvider, passwordEncoder);
    }

    @Test
    void 로그인_성공() {
        // given
        SigninRequestDto signinRequest =
                new SigninRequestDto("hello@world.com", "qwer1234");
        ExpectedMemberInfo expectedMemberInfo = createMemberInfo();
        String expectedToken = jwtProvider.createToken(expectedMemberInfo);
        // when
        Mockito.when(signinDao.retrieveMemberInfoByEmail(any(String.class)))
                .thenReturn(expectedMemberInfo);
        String actualToken = authenticationService.signin(signinRequest).getAccessToken();
        // then
        Assertions.assertThat(checkTokenEquality(expectedToken, actualToken)).isTrue();
    }

    @Test
    void 로그인_실패_비밀번호_오류() {
        // given
        SigninRequestDto signinRequestWithWrongPassword =
                new SigninRequestDto("hello@world.com", "qwer1234!");
        ExpectedMemberInfo expectedMemberInfo = createMemberInfo();
        // when
        Mockito.when(signinDao.retrieveMemberInfoByEmail(any(String.class)))
                .thenReturn(expectedMemberInfo);
        // then
        Assertions.assertThatThrownBy(
                () -> authenticationService.signin(signinRequestWithWrongPassword))
                .isInstanceOf(PasswordMismatchException.class);
    }

    @Test
    void 로그인_실패_이메일_오류() {
        // given
        SigninRequestDto signinRequestWithWrongEmail =
                new SigninRequestDto("hEllo@world.com", "qwer1234");
        ExpectedMemberInfo expectedMemberInfo = createMemberInfo();
        // when
        Mockito.when(signinDao.retrieveMemberInfoByEmail(any(String.class)))
                        .thenThrow(EmailNotFoundException.class);
        // then
        Assertions.assertThatThrownBy(
                        () -> authenticationService.signin(signinRequestWithWrongEmail))
                .isInstanceOf(EmailNotFoundException.class);
    }

    /**
     * 두 토큰의 정보(회원 이메일, 권한) 동치(equality) 여부 검사
     */
    private boolean checkTokenEquality(String expected, String actual) {
        Authentication expectedPrincipal = jwtProvider.getAuthentication(expected);
        Authentication actualPrincipal = jwtProvider.getAuthentication(actual);
        return expectedPrincipal.getName().equals(actualPrincipal.getName())
                && expectedPrincipal.getAuthorities().equals(actualPrincipal.getAuthorities());
    }

    private ExpectedMemberInfo createMemberInfo() {
        List<Authority> authorities = Arrays.asList(READ, WRITE, NORMAL);
        ExpectedMemberInfo registeredMember = ExpectedMemberInfo.builder()
                .memberId(0L)
                .email("hello@world.com")
                .nickname("Tom")
                .password(passwordEncoder.encode("qwer1234"))
                .authorities(authorities)
                .isDormant(false)
                .isEnabled(true)
                .build();
        return registeredMember;
    }

}

reference

Comments