반응형

지난 시간에는, 인증 및 인가를 구현하기 위해 간단한 API를 만들어두고, 설계 방향에 대해 논의하였습니다.

이번 시간에는, Spring Security를 이용해서 기능을 구현하기 위한 코드 작성 시간을 가져보도록 하겠습니다.

 

 

전체적인 인증 및 인가 로직을 간략히 소개해보겠습니다.
1. 클라이언트가 API를 요청한다. 이 때, 로그인해서 발급받은 액세스 토큰을 HTTP Authorization 헤더에 담아서 보내준다.
2. 필터를 거친다. 우리가 작성한 JwtAuthenticationFilter에 도착한다. 필터에서는 Authorization 헤더에서 토큰을 검증하고, 토큰으로 요청한 사용자 정보를 데이터베이스에서 조회해서 SecurityContext에 저장한다.
3. 요청한 URL에 따라서 접근 허용 여부를 검사한다. 
4-1. 인증되지 않은 사용자라면, 401 응답을 보내준다. (실제로는 401 응답을 내려주는 곳으로 리다이렉트)
4-2. 요청한 자원에 접근 권한이 없다면, 403 응답을 보내준다. (실제로는 403 응답을 내려주는 곳으로 리다이렉트)

 

 

이제 Spring Security 설정 빈으로 사용하는, config.security 패키지에 SecurityConfig를 살펴보도록 하겠습니다.

설정 코드를 먼저 살펴보고, 각각에 필요한 클래스와 코드를 추가해나갈 것입니다.

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final TokenService tokenService; // 1
    private final CustomUserDetailsService userDetailsService; // 2

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().mvcMatchers("/exception/**"); // 3
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .formLogin().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                    .authorizeRequests() // 4
                        .antMatchers(HttpMethod.POST, "/api/sign-in", "/api/sign-up").permitAll()
                        .antMatchers(HttpMethod.GET, "/api/**").permitAll()
                        .antMatchers(HttpMethod.DELETE, "/api/members/{id}/**").access("@memberGuard.check(#id)")
                        .anyRequest().hasAnyRole("ADMIN")
                .and()
                    .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler()) // 5
                .and()
                    .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint()) // 6
                .and() // 7
                    .addFilterBefore(new JwtAuthenticationFilter(tokenService, userDetailsService), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

1. 토큰을 통해 사용자를 인증하기 위해 JwtAuthenticationFilter에서 필요한 의존성입니다.

2. 토큰을 통해 사용자를 인증하기 위해 JwtAuthenticationFilter에서 필요한 의존성입니다. 토큰에 저장된 subject(여기에선 사용자 id)로 사용자의 정보를 조회하는데 사용됩니다.

3. Spring Security를 무시할 URL을 지정해줍니다. 지정된 이유는 아래에서 살펴보겠습니다.

4. 각 메소드와 URL에 따른 접근 정책을 설정해줍니다. 로그인과 회원가입, GET 요청 API는 누구나 접근할 수 있도록 설정하였고, 지난 시간에 작성했던 사용자 삭제 API는 access()로 정책을 설정하였습니다.

기본적인 문법은,

"@<빈이름>.<메소드명>(<인자, #id로하면 URL에 지정한 {id}가 매핑되어서 인자로 들어감>)" 입니다.

스프링 빈으로 등록한 MemberGuard.check()를 호출하고, 반환 값이 true라면 접근을 허용합니다. 일단 그 외의 요청들은, 모두 관리자 권한이 필요한 것으로 해두겠습니다.

5. 인증된 사용자가 권한 부족 등의 사유로 인해 접근이 거부되었을 때 작동할 핸들러를 지정해줍니다.

6. 인증되지 않은 사용자의 접근이 거부되었을 때 작동할 핸들러를 지정해줍니다.

7. 토큰으로 사용자를 인증하기 위해 직접 정의한 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter의 이전 위치에 등록해줍니다. JwtAuthenticationFilter는 필요한 의존성인 TokenService와 CustomUserDetailsService를 주입받습니다.

 

 

이제 각각의 항목에 대해서 세부적으로 살펴보겠습니다.

먼저 1번 TokenService를 살펴보겠습니다. 지난 시간에 작성했었지만, 토큰을 검증하기 위한 기능도 추가되었기에 다시 살펴보겠습니다.

@Service
@RequiredArgsConstructor
public class TokenService {
    private final JwtHandler jwtHandler;

    @Value("${jwt.max-age.access}")
    private long accessTokenMaxAgeSeconds;

    @Value("${jwt.max-age.refresh}")
    private long refreshTokenMaxAgeSeconds;

    @Value("${jwt.key.access}")
    private String accessKey;

    @Value("${jwt.key.refresh}")
    private String refreshKey;

    public String createAccessToken(String subject) {
        return jwtHandler.createToken(accessKey, subject, accessTokenMaxAgeSeconds);
    }

    public String createRefreshToken(String subject) {
        return jwtHandler.createToken(refreshKey, subject, refreshTokenMaxAgeSeconds);
    }

    public boolean validateAccessToken(String token) {
        return jwtHandler.validate(accessKey, token);
    }

    public boolean validateRefreshToken(String token) {
        return jwtHandler.validate(refreshKey, token);
    }

    public String extractAccessTokenSubject(String token) {
        return jwtHandler.extractSubject(accessKey, token);
    }

    public String extractRefreshTokenSubject(String token) {
        return jwtHandler.extractSubject(refreshKey, token);
    }
}

크게 달라진 것은 없습니다.

JwtHandler를 이용하여 각각의 토큰 종류마다, 검증하고 subject를 추출하는 메소드가 추가되었습니다.

* 토큰 종류에 따라 유사한 코드가 반복되고 있지만, 더 이상 추가될 토큰 종류는 없을 것이고 그 개수가 많지 않기에,

지금 단계에서는 암묵적으로 묵인하고, 여유가 될 때 리팩토링 작업을 거쳐보도록 하겠습니다.

 

 

이제 메소드가 추가되었으므로, 그에 대한 간단한 테스트를 작성해보겠습니다.

TokenServiceTest 클래스는 지난 시간에 작성했었으므로, 다음 테스트들을 추가하겠습니다.

    @Test
    void validateAccessTokenTest() {
        // given
        given(jwtHandler.validate(anyString(), anyString())).willReturn(true);

        // when, then
        assertThat(tokenService.validateAccessToken("token")).isTrue();
    }

    @Test
    void invalidateAccessTokenTest() {
        // given
        given(jwtHandler.validate(anyString(), anyString())).willReturn(false);

        // when, then
        assertThat(tokenService.validateAccessToken("token")).isFalse();
    }

    @Test
    void validateRefreshTokenTest() {
        // given
        given(jwtHandler.validate(anyString(), anyString())).willReturn(true);

        // when, then
        assertThat(tokenService.validateRefreshToken("token")).isTrue();
    }

    @Test
    void invalidateRefreshTokenTest() {
        // given
        given(jwtHandler.validate(anyString(), anyString())).willReturn(false);

        // when, then
        assertThat(tokenService.validateRefreshToken("token")).isFalse();
    }

    @Test
    void extractAccessTokenSubjectTest() {
        // given
        String subject = "subject";
        given(jwtHandler.extractSubject(anyString(), anyString())).willReturn(subject);

        // when
        String result = tokenService.extractAccessTokenSubject("token");

        // then
        assertThat(subject).isEqualTo(result);
    }

    @Test
    void extractRefreshTokenSubjectTest() {
        // given
        String subject = "subject";
        given(jwtHandler.extractSubject(anyString(), anyString())).willReturn(subject);

        // when
        String result = tokenService.extractRefreshTokenSubject("token");

        // then
        assertThat(subject).isEqualTo(result);
    }

자세한 설명은 생략하겠습니다.

 

 

***

- 액세스 토큰과 리프레시 토큰의 Key를 구분한 이유

두 종류의 토큰에 모두 동일한 Key를 사용한다면, 검증이나 Subject 추출 작업은 하나의 메소드로 통일시킬 수 있습니다.

하지만 리프레시 토큰은, 액세스 토큰에 비해 더욱 긴 만료 시간을 가지고 있습니다.

만약 두 토큰이 동일한 Key를 사용할 경우, 액세스 토큰이 만료되더라도, 리프레시 토큰으로 계속해서 API 요청을 할 수 있게 됩니다.

리프레시 토큰의 용도는 액세트 토큰을 재발급하기위한 것입니다.

각 토큰이 본연의 목적만을 수행하여, API 요청에는 액세스 토큰만을 사용할 수 있도록 구분 짓기 위해 서로 다른 Key를 사용하였습니다.

물론, 구현에 따라서 다른 방법(토큰에 토큰 종류를 기입한다든지)도 있겠지만, 검사를 위해 토큰을 파싱하여 데이터를 추출하는 과정 없이, 한 번의 검증만으로 토큰을 구분 짓기 위해 이러한 방법을 택하게 되었습니다.

***

 

 

다음으로 SecurityConfig의 2번 항목인 config.security 패키지에 CustomUserDetailsService를 살펴보겠습니다.

명칭은 Service지만, Security에서 관리하고 사용될 것이므로 security 패키지에 @Component로 선언하였습니다.

@Component
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public CustomUserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        Member member = memberRepository.findById(Long.valueOf(userId))
                .orElseGet(() -> new Member(null, null, null, null, List.of()));
        return new CustomUserDetails(
                String.valueOf(member.getId()),
                member.getRoles().stream().map(memberRole -> memberRole.getRole())
                        .map(role -> role.getRoleType())
                        .map(roleType -> roleType.toString())
                        .map(SimpleGrantedAuthority::new).collect(Collectors.toSet())
        );
    }
}

Spring Security에서 제공해주는 UserDetailsService를 구현하는 CustomUserDetailsService는 인증된 사용자의 정보를 CustomUserDetails로 반환해줍니다.

CustomUserDetailsService는 스프링 컨테이너에 등록되기 때문에, 다른 의존성들을 주입받을 수 있습니다.

토큰에서 추출한 사용자의 id를 이용하여, MemberRepository로 Member를 조회합니다.

조회된 Member로 CustomUserDetails를 반환해줍니다.

CustomUserDetails는 권한 등급을 GrantedAuthority 인터페이스 타입으로 받게 되는데, 이의 간단한 구현체인 SimpleGrantedAuthority를 이용하도록 하겠습니다. 권한 등급은 String 타입으로 인식하기 때문에, Enum 타입인 RoleType을 String으로 변환해주었습니다.

만약 사용자를 찾지 못했다면, 권한이 없고 비어있는 CustomUserDetails를 생성하여 반환해줍니다.

 

***
- 토큰 인증 방식에서 각 요청마다 데이터베이스에 접근?
요청을 받을 때마다 사용자의 정보를 데이터베이스에서 조회하는 것은 비효율적인 작업입니다.
이를 방지하기 위해 토큰에다가 인증에 필요한 모든 정보를 미리 넣어둘 수도 있겠습니다.
인증에 필요한 정보가 토큰에 담겨있다면, 데이터베이스에 접근할 필요는 없어지게 됩니다.

하지만 사용자의 정보 또는 권한은 언제 변경될지 모릅니다.
액세스 토큰의 만료 기간이 짧다고 하더라도, 변경된 정보가 발급된 토큰으로 인해 사용자에게 즉시 반영되지 않는다면, 문제의 소지가 있습니다.
이를 방지하기 위해 우리의 코드에서는 데이터베이스에서 다시 조회하고 있는 것입니다.
이메일이나 닉네임처럼 unique 제약 조건에 의해 생성된 secondary index를 이용하여 조회하는 것이 아닌, 사용자의 primary key에 의해 생성된 clustering index(primary index)를 이용하여 데이터베이스에 접근하는 것이므로, 비교적 저렴한 비용으로 트랜잭션을 수행할 수 있을 것입니다.

물론, 데이터베이스에 접근하는 것은 여전히 큰 비용이기 때문에, 더 나은 해결책은 분명 필요합니다.
가장 좋은 방법은, 아예 데이터베이스에 접근하지 않는 것입니다.
토큰에 저장된 낡은 정보가 사용자에게 끼치는 영향이 없다면, 데이터베이스에 접근할 필요는 없을 것입니다.
영향이 있더라도 토큰에 담긴 정보가 업데이트되는 상황은 그렇게 많지 않을 것이기에, 차라리 토큰을 새로 발급해주는게 더 나을 것입니다.
혹여나 데이터베이스 접근이 필요하더라도, Redis와 같은 메모리 DB에 사용자 정보를 짧은 시간 캐시하는 등의 방법도 있겠습니다.

다음과 같은 상황도 살펴보겠습니다.
사용자 인증에 많은 정보가 필요하고, 이를 모두 토큰에 담아야한다고 가정해보겠습니다.
HTTP에서는 HTTP 헤더 크기에 제한을 두고 있지 않지만, 특정 웹 서버에서는 HTTP 헤더 크기에 제한을 두고 있습니다.
토큰이 과도하게 커진다면, HTTP 헤더를 이용한 토큰 전송이 아예 불가능할 수도 있는 것입니다.
이 뿐만 아니라, 거대한 토큰은 전송 과정에서 네트워크 대역폭을 낭비하게 됩니다.
이를 해결하기 위해 토큰을 압축하거나, 인증에 필요한 정보를 최소화하거나, HTTP Body로 토큰을 전송할 수도 있겠지만, 가장 손쉬운 해결책으로는 서버가 데이터베이스에서 인증 정보를 직접 꺼내오는 방식도 있을 것입니다.
네트워크는 복잡하고 위험하기 때문에, 오히려 데이터베이스 접근이 더욱 안전하고 효율적일지도 모릅니다.

정리하면, 토큰 인증 방식에서도 데이터베이스 접근이 필요한 상황과 해결책은 다음과 같을 것입니다.
1. 토큰에 저장된 낡은 정보가 사용자에게 즉시 반영되어야 하는 경우 : 앞서 언급했듯이, 토큰을 다시 내려주거나 짧은 캐시 또는 만료 시간을 가짐으로써 해결할 수 있을 것입니다.
2. 토큰에 너무 많은 정보가 담기는 경우 : HTTP에서는 헤더 크기에 대해 제한을 두지 않지만, 특정 웹 서버에서는 이를 제한하고 있고, 너무 커다란 토큰은 네트워크 대역폭을 낭비할 수도 있습니다. 이를 해결하려면 압축을 수행하거나, 인증에 필요한 정보를 최소화하거나, HTTP Body를 이용하거나, 데이터베이스를 이용하는 등의 방법이 있겠습니다.
여기서 언급된 데이터베이스라는 해결책은, 반드시 RDB 등에서의 디스크 접근이 아니라 메모리 DB를 이용할 수도 있을 것입니다.

우리의 코드는 데이터베이스에 접근하고 있습니다.
하지만 지금은 단순히 공부하는 입장이기에, 아이디어만 논의하고 넘어가도록 하겠습니다.
현재 구현된 방식은 반드시 개선되어야한다는 것을 언급하고 싶었습니다.
불필요한 데이터베이스 접근은 토큰 인증 방식의 장점을 깨트리게 될 것입니다.

* 작성된 내용은 개인적인 의견입니다.

* 결국, 35편에서 언급된 내용을 반영하였습니다.

12편까지 로그인 기능을 마무리하시고, 선택적으로 적용하시면 됩니다.

2022.01.04 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (35) - 토큰 인증 방식 수정

해당 과정은 필수 사항은 아니며, 이후의 과정에는 전혀 지장이 없습니다.

***

 

* 파라미터로 전달받은 userId로 사용자를 찾지 못하는 상황은, 정상적인 사용자가 로그인하여 토큰을 발급 받은 뒤, 자신의 계정을 삭제하는 상황이 있겠습니다. 토큰 만료 기간이 끝나지 않은 시점에 토큰은 아직 유효하다고 판단되겠지만, 삭제된 사용자이기에 데이터베이스에서 조회되지 않는 것입니다. 이러한 상황이 흔할 것이라고 생각되진 않지만, 하나의 예외 사항으로 보고, 비교적 간단하고 일관화된 처리를 위해 Optional.orElseGet으로 비어있는 Member를 반환하도록 하였습니다. 이를 통해 사용자 정보와 권한이 없는 CustomUserDetails가 생성되는 것입니다.

 

* 곳곳에서 인터페이스를 사용하지않고 그 구현체를 반환한 까닭은, 추가로 정의된 또는 정의할 구현체가 없다고 판단된 경우에 불필요한 캐스팅을 방지하기 위함입니다.

 

* 오버라이드한 loadUserByUsername이라는 메소드 명과는 달리, 실제로는 사용자의 id 값으로 사용자 정보를 조회하고 있습니다. 실질적으로 수행하는 작업과 일치하는 메소드 명은 아니지만 Spring Security와 맞물려 동작하기 위함이니 묵인하고 넘어가도록 하겠습니다.

 

 

CustomUserDetails는 인증된 사용자의 정보와 권한을 담고 있습니다.

config.security 패키지에서 확인해보도록 하겠습니다.

@Getter
@AllArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final String userId;
    private final Set<GrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getUsername() {
        return userId;
    }

    @Override
    public String getPassword() {
        throw new UnsupportedOperationException();
    }

    @Override
    public boolean isAccountNonExpired() {
        throw new UnsupportedOperationException();
    }

    @Override
    public boolean isAccountNonLocked() {
        throw new UnsupportedOperationException();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        throw new UnsupportedOperationException();
    }

    @Override
    public boolean isEnabled() {
        throw new UnsupportedOperationException();
    }
}

Spring Security에서 제공해주는 UserDetails 인터페이스를 구현한 클래스입니다.

사용자의 접근을 제어하기 위해 최소한으로 필요한 userId와 권한 등급 정도만 필드로 선언하겠습니다.

나머지 오버라이드된 메소드들은, 실제로 사용하거나 사용할 수 있는, 또는 유효한 메소드가 아니므로 호출 시 예외를 발생시키도록 하겠습니다.

UserDetails 인터페이스는, 기존에 작성했던 엔티티에 구현하는 예시도 많습니다.

그렇지만 저는, Entity는 본연의 데이터와 업무에만 집중할 수 있도록, 별도의 클래스로 구현하도록 하겠습니다.

 

 

잠깐 학습 테스트를 진행해보겠습니다.

CustomUserDetailsService에서 Enum 타입을 스트링으로 변환하기 위해 roleType.toString() 을 호출했지만,

이렇게 하면 스트링으로 변환되는 것인지 정확히 알지 못하였습니다.

test 디렉토리의 learning 패키지에 간단한 테스트를 작성하여 검증해보도록 하겠습니다.

public class EnumToStringTest {

    public enum TestEnum{
        TEST1, TEST2
    }

    @Test
    void enumToStringTest() {
        Assertions.assertThat(TestEnum.TEST1.toString()).isEqualTo("TEST1");
        Assertions.assertThat(TestEnum.TEST2.toString()).isEqualTo("TEST2");
    }
}

테스트가 성공하였습니다.

안심하고 다음 내용을 살펴보도록 하겠습니다.

 

 

SecurityConfig의 3번 항목은, "/exception" 으로 요청이 들어왔을 때, Spring Security를 거치지않고 바로 컨트롤러로 요청이 도달하게 됩니다.

이렇게 한 이유는, 5번과 6번 항목과 함께 살펴보도록 하겠습니다.

각 항목에 설정된 CustomAuthenticationEntryPoint 클래스와 CustomAccessDeniedHandler 클래스입니다.

public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendRedirect("/exception/entry-point");
    }
}
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendRedirect("/exception/access-denied");
    }
}

AuthenticationEntryPoint는 인증되지 않은 사용자가 요청했을 때에 작동되는 핸들러입니다.

AccessDeniedHandler는 인증은 되었더라도, 사용자가 요청에 대한 접근 권한이 없을 때에 작동되는 핸들러입니다.

이러한 핸들러의 작동은 컨트롤러 계층에 도달하기 전에 수행되기 때문에, 여기에서 예외가 발생한다해도,

우리가 예외를 편리하게 다루기 위해 등록했던 ExceptionAdvice에서는 이 예외를 잡아낼 수 없습니다.

따라서 여기에서는 스프링에서 제공해주는 응답 방식을 이용할 수 없습니다.

그렇기에 그냥 여기에서 각각의 상황에 맞게 직접 응답을 작성해도 무관합니다.

AuthenticationEntryPoint는 상태 코드 401(Unauthorized), AccessDeniedHandler는 상태코드 403(Forbidden) 을 직접 응답해줘도 됩니다.

하지만, 우리는 Response 클래스를 이용해서 일관화된 응답 방식을 취하고 있고,

한 곳에서 예외 사항들을 편리하게 다루기 위해 @RestControllerAdvice를 선언한 ExceptionAdvice를 사용하고 있습니다.

그래서 우리는, 예외 사항을 다루는 방식의 일관성을 위해 "/exception/{예외}"로 리다이렉트를 시키고,

거기에서 이에 대한 예외를 발생시켜서, ExceptionAdvice 클래스에서 이 예외를 다룰 수 있도록 하겠습니다.

이미 사용자 인증 및 인가에 대한 검사가 끝나고 예외가 발생하여 리다이렉트되는 것이기 때문에,

3번 항목에서 Spring Security를 "/exception" 으로 시작하는 URL에 대해 다시 검사하지 않도록 한 것이었습니다.

 

이제 controller.exception 패키지에, 리다이렉트를 위한 ExceptionController를 작성해줍시다.

@RestController
public class ExceptionController {
    @GetMapping("/exception/entry-point")
    public void entryPoint() {
        throw new AuthenticationEntryPointException();
    }

    @GetMapping("/exception/access-denied")
    public void accessDenied() {
        throw new AccessDeniedException();
    }
}

 

요청을 전달받으면, 단순히 예외를 발생시켜줍니다.

각 상황에 발생하는 예외도 exception 패키지에 정의해주도록 합시다.

public class AuthenticationEntryPointException extends RuntimeException {
}
public class AccessDeniedException extends RuntimeException {
}

 

 

이제 ExceptionAdvice에 지금 생성한 두 가지 예외를 등록해주도록 하겠습니다.

// ExceptionAdvice.java

    @ExceptionHandler(AuthenticationEntryPointException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public Response authenticationEntryPoint() {
        return Response.failure(-1001, "인증되지 않은 사용자입니다.");
    }

    @ExceptionHandler(AccessDeniedException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public Response accessDeniedException() {
        return Response.failure(-1002, "접근이 거부되었습니다.");
    }

사실 이전에 -1001과 -1002 코드를 비워둔 이유는, 이에 대한 예외를 등록하기 위함이었습니다.

AuthenticationEntryPointException은 상태코드 401을,

AccessDeniedException은 상태코드 403을 응답해주겠습니다.

 

 

이제 이렇게 작성한 ExceptionController를 테스트해보겠습니다.

test 디렉토리에서 동일한 패키지 경로에 ExceptionControllerAdviceTest를 작성해주겠습니다.

이전에는, API 요청 URL 검증에 대한 Controller와 예외 사항에 작동하는 Advice 테스트를 분할하여 작성하였는데,

이번에는 API 요청 즉시 예외가 발생하도록 설정하였으므로, API 검증과 Advice에 대한 테스트를 동시에 진행하겠습니다.

@ExtendWith(MockitoExtension.class)
class ExceptionControllerAdviceTest {
    @InjectMocks ExceptionController exceptionController;
    MockMvc mockMvc;

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.standaloneSetup(exceptionController).setControllerAdvice(new ExceptionAdvice()).build();
    }

    @Test
    void entryPointTest() throws Exception{
        // given, when, then
        mockMvc.perform(
                get("/exception/entry-point"))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.code").value(-1001));
    }

    @Test
    void accessDeniedTest() throws Exception {
        // given, when, then
        mockMvc.perform(
                get("/exception/access-denied"))
                .andExpect(status().isForbidden())
                .andExpect(jsonPath("$.code").value(-1002));
    }

}

이미 자주 작성해보았던 테스트이므로, 설명은 생략하도록 하겠습니다.

mockMvc를 이용하여, Advice가 정의한 응답을 내려주는지 검증하였습니다.

 

 

 

이제 SecurityConfig에서 4번과 7번 항목에 대해서 남았습니다.

4번 항목은, DELETE /api/members/{id} 요청에 대한 처리 방법만 살펴보도록 하겠습니다.

.antMatchers(HttpMethod.DELETE, "/api/members/{id}/**").access("@memberGuard.check(#id)")

우리는 이전 시간에, 모든 사용자 인증과 인가에 관한 검증을 Spring Security에서 수행하기로 결정하였습니다.

사용자를 삭제하는 요청은, 삭제하고자 하는 사용자가 요청자 본인인 경우 또는 관리자인 경우에만 수행할 수 있습니다.

이러한 검증 로직을 수행하기 위해, MemberGuard.check 의 반환 결과가 true라면 요청을 수행할 수 있도록 한 것입니다. (access의 작성 방식은 위에서 간단히 살펴보았습니다.)

 

스프링 컨테이너에 등록한 MemberGuard 클래스를 살펴보겠습니다.

config.security.guard 패키지에 작성하였습니다.

package kukekyakya.kukemarket.config.security.guard;

import kukekyakya.kukemarket.entity.member.RoleType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Set;

@Component
@RequiredArgsConstructor
@Slf4j
public class MemberGuard {

    private final AuthHelper authHelper;

    public boolean check(Long id) {
        return authHelper.isAuthenticated() && authHelper.isAccessTokenType() && hasAuthority(id);
    }

    private boolean hasAuthority(Long id) {
        Long memberId = authHelper.extractMemberId();
        Set<RoleType> memberRoles = authHelper.extractMemberRoles();
        return id.equals(memberId) || memberRoles.contains(RoleType.ROLE_ADMIN);
    }
}

지금 요청한 사용자가 인증되었는지, 액세스 토큰을 통한 요청인지, 자원 접근 권한(관리자 또는 자원의 소유주)을 가지고 있는지를 검사하게 됩니다.

 

이러한 검사 작업을 도와주기 위해 동일한 패키지 경로 내에 작성된 AuthHelper 클래스는 다음과 같습니다.

지금 작성된 MemberGuard뿐만 아니라 앞으로 작성될 다양한 Guard에서 사용되며,

사용자 인증 정보를 추출하기 위해 도움을 줄 클래스입니다.

package kukekyakya.kukemarket.config.security.guard;

import kukekyakya.kukemarket.config.security.CustomAuthenticationToken;
import kukekyakya.kukemarket.config.security.CustomUserDetails;
import kukekyakya.kukemarket.entity.member.RoleType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

import java.util.Set;
import java.util.stream.Collectors;

@Component
@Slf4j
public class AuthHelper {

    public boolean isAuthenticated() {
        return getAuthentication() instanceof CustomAuthenticationToken &&
                getAuthentication().isAuthenticated();
    }

    public Long extractMemberId() {
        return Long.valueOf(getUserDetails().getUserId());
    }

    public Set<RoleType> extractMemberRoles() {
        return getUserDetails().getAuthorities()
                .stream()
                .map(authority -> authority.getAuthority())
                .map(strAuth -> RoleType.valueOf(strAuth))
                .collect(Collectors.toSet());
    }

    public boolean isAccessTokenType() {
        return "access".equals(((CustomAuthenticationToken) getAuthentication()).getType());
    }

    public boolean isRefreshTokenType() {
        return "refresh".equals(((CustomAuthenticationToken) getAuthentication()).getType());
    }

    private CustomUserDetails getUserDetails() {
        return (CustomUserDetails) getAuthentication().getPrincipal();
    }

    private Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }
}

이전에 사용자의 인증 정보는 Spring Security에서 관리해주는 컨텍스트에 저장된다고 했었습니다.

해당 정보는, ThreadLocal을 이용하여 관리됩니다.

즉, 같은 스레드를 공유하고 있다면, 어떤 위치에서 사용하든지 저장해둔 데이터를 공유할 수 있습니다.

아직 언급은 안했지만, 우리가 직접 작성할 JwtAuthenticationFilter에서는 ThreadLocal에 사용자 정보를 저장하게 됩니다.

AuthHelper는 그렇게 저장된 사용자 정보를 통해, 우리가 필요한 요청자의 id나 인증 여부, 권한 등급, 요청 토큰의 타입 등을 추출하는데 도움을 주게 됩니다.

* 인증되지 않은 사용자여도 Spring Security에서 등록해준 필터에 의해 AnonymousAuthenticationToken을 발급받게 되기 때문에, getAuthentication()의 반환 값이 우리가 직접 정의한 CustomAuthenticationToken일 때에만 인증된 것으로 판별해주었습니다. 이에 대한 내용도 아래에서 살펴보겠습니다.

 

 

 

이제 마지막으로 SecurityConfig에서 7번 항목인 JwtAuthenticationFilter를 살펴보도록 하겠습니다.

해당 필터는 UsernamePasswordAuthenticationFilter 이전에 등록되었습니다.

UsernamePasswordAuthenticationFilter는 자신이 처리할 요청이 들어오면 다음 필터를 거치지 않기 때문에,

그 이전에 필터를 등록해야 정상적으로 인증을 수행할 수 있습니다.

 

이에 대한 자세한 내용은 저도 부족한 까닭에,

https://tech.junhabaek.net/spring-security-usernamepasswordauthenticationfilter%EC%9D%98-%EB%8D%94-%EA%B9%8A%EC%9D%80-%EC%9D%B4%ED%95%B4-8b5927dbc037

위 링크에서 자세히 살펴볼 수 있습니다.

 

 

다음 코드는 config.security 패키지에 작성된 JwtAuthenticationFilter 클래스입니다.

package kukekyakya.kukemarket.config.security;

import kukekyakya.kukemarket.service.sign.TokenService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final TokenService tokenService;
    private final CustomUserDetailsService userDetailsService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = extractToken(request);
        if(validateAccessToken(token)) {
            setAccessAuthentication("access", token);
        } else if(validateRefreshToken(token)) {
            setRefreshAuthentication("refresh", token);
        }
        chain.doFilter(request, response);
    }

    private String extractToken(ServletRequest request) {
        return ((HttpServletRequest)request).getHeader("Authorization");
    }

    private boolean validateAccessToken(String token) {
        return token != null && tokenService.validateAccessToken(token);
    }

    private boolean validateRefreshToken(String token) {
        return token != null && tokenService.validateRefreshToken(token);
    }

    private void setAccessAuthentication(String type, String token) {
        String userId = tokenService.extractAccessTokenSubject(token);
        CustomUserDetails userDetails = userDetailsService.loadUserByUsername(userId);
        SecurityContextHolder.getContext().setAuthentication(new CustomAuthenticationToken(type, userDetails, userDetails.getAuthorities()));
    }

    private void setRefreshAuthentication(String type, String token) {
        String userId = tokenService.extractRefreshTokenSubject(token);
        CustomUserDetails userDetails = userDetailsService.loadUserByUsername(userId);
        SecurityContextHolder.getContext().setAuthentication(new CustomAuthenticationToken(type, userDetails, userDetails.getAuthorities()));
    }
}

GenericFilterBean을 상속받아서 필터를 구현해주었고, 직접 @Component를 선언하면 자동으로 필터 체인에 등록되기 때문에, 중복 등록을 방지하기 위해 @Component는 생략해주었습니다.

이미 순서 제어를 위해, SecurityConfig에서 직접 생성하여 필터 체인에 등록해주었기 때문입니다.

이러한 까닭에, 필요한 의존성들은 SecurityConfig에서 받아다가 주입시켜준 것입니다.

 

전반적인 로직은 간단합니다.

요청으로 전달 받은 Authorization 헤더에서 토큰 값을 꺼내오고, 토큰이 유효하다면 SpringSecurity가 관리해주는 컨텍스트에 사용자 정보(CustomAuthenticationToken)를 등록해줍니다.

엄밀히 따지면, SecurityContextHolder에 있는 ContextHolder에다가 Authentication 인터페이스의 구현체 CustomAuthenticationToken를 등록해주는 작업입니다.

CustomAuthenticationToken은 CustomUserDetailsService를 이용하여 조회된 사용자의 정보 CustomUserDetails와 요청 토큰의 타입을 저장해줍니다.

액세스 토큰과 리프레시 토큰을 구분 짓기 위해 별도의 검증 작업을 수행하였습니다.

 

 

config.security 패키지에 작성된 CustomAuthenticationToken은 다음과 같습니다.

package kukekyakya.kukemarket.config.security;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

import java.util.Collection;

public class CustomAuthenticationToken extends AbstractAuthenticationToken {

    private String type;
    private CustomUserDetails principal;

    public CustomAuthenticationToken(String type, CustomUserDetails principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.type = type;
        this.principal = principal;
        setAuthenticated(true);
    }

    @Override
    public CustomUserDetails getPrincipal() {
        return principal;
    }

    @Override
    public Object getCredentials() {
        throw new UnsupportedOperationException();
    }

    public String getType() {
        return type;
    }
}

Spring Security에서 제공해주는 추상 클래스 AbstractAuthenticationToken을 상속받아서,

우리가 사용자를 인증하는데 필요한 최소한의 정보를 기억하도록 하였습니다.

단순히, 토큰의 타입과 CustomUserDetails, 권한 등급 정보를 가지게 됩니다.

허용되지 않는 동작은 예외를 발생시켜주었습니다.

 

 

이렇게 해서 SecurityConfig에 작성된 내용으로, 인증 및 인가를 다루기 위한 모든 코드들을 살펴보았습니다.

각각의 코드가 분리되어있기 때문에, 이해하기 어려울 수 있는데, 전반적인 절차는 다음과 같습니다.

인증 및 인가 절차

작성된 코드의 절차로 간단하게 표현해봤습니다.

 

 

인증 및 인가에 관한 부분을 작성하면서 한 가지 이점이 있었습니다.

우리는 지난 시간에 논의했던대로, 접근 제어에 관한 내용을 모두 Security에서 검증하도록 하였습니다.

관리자 권한 등급을 가지고 있든, 요청자가 요청한 자원의 소유주이든 모든 검증이 Security를 거치면서 이루어졌습니다.

이로 인해 얻은 이점은, 기존에 작성했던 MemberService.delete는 여전히,

요청자가 요청한 자원의 소유주인지, 관리자 권한을 가지고 있는지에 상관없이 자신의 책임만 수행하고 있습니다.

기존의 코드를 전혀 수정하지않고 모든 인증 및 인가 관련 로직을 작성할 수 있었다는 것입니다.

 

 

다음 시간에는 이렇게 작성된 인증 및 인가 로직을 테스트해보는 시간을 가져보도록 하겠습니다.

이를 위해 사용자 조회 및 삭제에 관한 API를 이전 시간에 미리 구현해놨으므로, 이를 이용하여 테스트 코드를 작성해보도록 하겠습니다.

 

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

 

 

 

반응형

+ Recent posts