본문으로 바로가기
반응형

1. 개요

Spring boot을 사용하여 개발을 하다보면 인증에 관한 처리도 필수적으로 따라오게 된다. 관련하여 어떠한 방식을 채택할 지 많은 고민이 있겠지만 대다수가 Spring Security 프레임워크를 활용할 것이다.

이미 인터넷에 수많은 예제들에 대한 설명이 나와있는데, 나의 요구사항에 정확하게 부합하는 튜토리얼은 찾기 힘들어서 직접 삽질한 내용을 정리해본다. 참고로 본 포스팅에서의 최종 목적은 다음과 같다.

- JWT를 활용하여 인증 처리

- Controller에 특정한 어노테이션을 사용자가 직접 추가하여 세분화된 권한 관리

구현된 코드에 대해 자세한 설명보다는 흐름에 중점을 맞춰서 설명한다.

2. 구현

2-1. SecurityConfig

@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().and().csrf().disable()
                .httpBasic().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .anyRequest().permitAll()
                .and()
                .addFilterBefore(
                        jwtAuthenticationFilter,
                        UsernamePasswordAuthenticationFilter.class
                );
    }
}

먼저 WebSecurityConfigurerAdapter를 상속받은 클래스를 하나 생성하고 configure()를 구현해준다. 아래쪽에 있는 addFilterBefore()를 통해 특정하게 사용할 필터를 하나 추가해준다.

2-2. JwtAuthenticationFilter

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = ((HttpServletRequest) request).getHeader("Authorization");

        if (token != null && !token.equals("null")) {
            token = token.split(" ")[1];
        }

        if (isValidToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }
    private boolean isValidToken(String token) {
        return StringUtils.hasText(token) && jwtTokenProvider.isValidToken(token);
    }
}

이번에는 OncePerRequestFilter를 상속받은 필터를 하나 만들어준다. 내부적으로 doFilter() 메소드를 구현해주면서 우리에게 필요한 필터링을 거친다. GenericFilterBean을 상속받아도 동일하게 동작하는데 직접 테스트해보니 OncePerRequestFilter를 상속받았을때는 나중에 유저정보를 조회하는 쿼리가 1회만 나가지만 GenericFilterBean의 경우 2번의 쿼리가 나갔었다. OncePerRequestFilter가 내부적으로 정확하게 1번만 필터를 거치도록 내부 설계되었다고 한다.

Authentication authentication = jwtTokenProvider.getAuthentication(token);

라인을 통해 아래에서 설명할 jwtTokenProvider에서 우리가 얻은 토큰을 기반으로 필요한 정보를 가져온다.

SecurityContextHolder.getContext().setAuthentication(authentication);

그리고 위 라인을 통해 우리의 정보를 저장한다. SecurityContextHolder는 인증 관련 정보를 저장하는 공간이라고 생각하면 된다. 추후 생성할 어노테이션에서 위 값을 참조할 것이다.

2-3. JwtTokenProvider

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    private final String secretKey = Base64.getEncoder().encodeToString("fitlog".getBytes());
    private final CustomUserService customUserService;

    public Authentication getAuthentication(String token) {
        UserDetail userDetails = customUserService.loadUserByUsername(getUserId(token));
        return new UsernamePasswordAuthenticationToken(
                userDetails.getUser(), "", userDetails.getAuthorities()
        );
    }

    public String getUserId(String token) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody()
                .get("user_id").toString();
    }

    public boolean isValidToken(String token) {
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return true;
        } catch (SignatureException e) {
            System.out.println("Invalid JWT signature");
        } catch (MalformedJwtException e) {
            System.out.println("Invalid JWT token");
        } catch (ExpiredJwtException e) {
            System.out.println("JWT token is expired");
        } catch (UnsupportedJwtException e) {
            System.out.println("JWT token is unsupported");
        } catch (IllegalArgumentException e) {
            System.out.println("JWT claims string is empty");
        }
        return false;
    }
}

getAuthentication()을 보면 customUserService에서 데이터를 가져온다음 UsernamePasswordAuthenticationToken 객체로 총 3가지를 감싸서 넘겨주고 있다.

- 첫 번째 인자: principal에 대한 정보를 저장한다. 다른 예제에서는 아마 이 부분에 User 엔티티가 아닌 User에 관한 다른 DTO성의 객체를 저장하지만 본 예제에서는 실제 디비에서 조회한 엔티티 자체를 저장한다.

- 두 번째 인자: credentials에 대한 정보를 저장한다. 사용하지 않을 예정이므로 빈 값을 넣었다.

- 세 번째 인자: 다른 예제에서는 Role에 관한 정보들을 여기에 저장하던데 나는 사용하지 않을 예정이다. 나중에 구현할 userDetails.getAuthorities()에서는 빈값을 리턴하도록 할 것이다.

2-4. CustomUserService

@Service
@AllArgsConstructor
public class CustomUserService implements UserDetailsService {
    private final UserService userService;

    @Override
    public UserDetail loadUserByUsername(String id) throws UsernameNotFoundException {
        User user = userService.findById(Long.parseLong(id));
        return UserDetail.builder()
                .id(user.getId())
                .user(user)
                .build();
    }
}

UserDetailsService를 상속받은 커스텀한 서비스를 하나 생성한다. 그리고 loadUserByUsername() 메소드를 오버라이딩하여 우리에게 알맞게 수정한다. 나는 기존에 생성해놓은 UserService에서 엔티티를 조회하고 UserDetail이라는 DTO성 유저 클래스에 담아서 리턴해줬다.

2-5. UserDetail

@Getter
@NoArgsConstructor
public class UserDetail implements UserDetails {
    private Long id;
    private User user;

    @Builder
    public UserDetail(Long id, User user) {
        this.id = id;
        this.user = user;
    }

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

    @Override
    public String getPassword() {
        return null;
    }

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

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

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

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

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

UserDetails를 상속받은 UserDetail 클래스를 생성한다. 여기에 유저에 관한 데이터가 들어간다. 타 예제들과는 다르게 user라는 필드를 하나 만들어줬는데, 여기에 실제 디비에서 불러온 유저 엔티티를 담을 것이다.

2-6. CurrentUser

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {
}

유저 정보를 가져올 때 사용하는 어노테이션을 하나 추가한다. 추후 컨트롤러의 인자에 추가하여 필터를 통해 추출된 유저 정보를 담을 것이다. @Target() 어노테이션을 통해 파라미터에 선언될 때 사용한다고 명시해줬고, @Retension() 어노테이션을 통해 런타임동안 유지될 것을 명시했다.

2-7. CurrentUserArgumentResolver

@Component
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentUser.class);
    }

    @Override
    public Object resolveArgument(
            MethodParameter parameter, ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, WebDataBinderFactory binderFactory
    ) throws Exception {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (!(authentication instanceof AnonymousAuthenticationToken)) {
            return authentication.getPrincipal();
        }
        throw new PermissionException();
    }
}

HandlerMethodArgumentResolver를 상속받은 커스텀한 Resolver를 하나 만들어준다. 그리고 supportsParameter()를 오버라이딩 해주는데, 해당 메소드에 바인딩할 클래스를 명시해준다.

그리고 resolveArgument()를 오버라이딩하여 어떠한 작업을 진행할 지 작성하면 된다. 나같은 경우 필터에서 담았던 정보를 SecurityContextHolder에서 꺼내온 후 리턴해줬다. 참고로 authentication.getPrincipal()을 리턴하는 이유는 principal에 유저 엔티티 정보를 담아줬기 때문이다.

2-8. AppConfig

@Configuration
@RequiredArgsConstructor
public class AppConfig implements WebMvcConfigurer {
    private final CurrentUserArgumentResolver currentUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(currentUserArgumentResolver);
    }
}

WebMvcConfigurer를 상속받은 설정용 클래스를 하나 생성한다. 그다음 addArgumentResolvers()를 오버라이딩하여 위에서 만들어준 resolver를 등록해준다.

3. Controller에 적용

    @GetMapping("/test")
    public void test(@CurrentUser User user) {
        Long id = user.getId();
        System.out.println("id = " + id);
    }

마지막으로 위처럼 권한 처리를 할 엔드포인트의 인자에 어노테이션을 추가해주면 내부에서 사용할 수 있다.

반응형