작업기록2. Spring Security/JWT

박서영·4일 전
post-thumbnail

Spring Security/JWT 기반 인증/인가 구현

Security Filter Chain

Spring Security에서는 인증/인가를 Security Filter Chain에서 담당하며, 1개의 필터가 아니라 여러 개의 필터를 순서대로 처리하는 방식으로 진행됨.

구현을 빨리하는게 목표였어서 스프링 시큐리티의 필터 체인 부분을 뭔가 제대로 이해하지 못한 거 같아서 아쉬운. 제대로 공부한다면 사실 스프링 시큐리티하고 필터체인에 대해서는 엄청 많은 내용이 있을 것 같다.

전체적인 흐름만 대충 이해한 바로는 이미 존재하는 필터 체인들 사이에 커스텀으로 필터 하나를 넣는 느낌. 유튜브에서 본 것을 기반으로는 이 필터 위치가 UsernamePasswordAuthentication Filter 앞에 넣는 거였다.

(1) JwtUtil

  • 이전 로그인 부분 구현할 때 대부분 작성해두었었는데, 추가적으로 다른 내용들이 필요해 작성하였다.
  • 추가적으로 작성한 부분은 ‘남은 시간(만료 시간) 반환하는 메소드, 토큰 검증하는 메소드, HTTP 요청 헤더에서 토큰을 추출하는 메소드’ 였다.
@Component
@Slf4j
public class JwtUtil {

    private final SecretKey key;
    private final Long accessExpirationMs;
    private final Long refreshExpirationMs;

    ...
    
    public Long getAccessExpirationMs() {return accessExpirationMs;}

    //토큰 유효성 검증
    public Claims validateToken(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(key)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();

        } catch (ExpiredJwtException e) {
            //만료된 토큰
            log.debug("만료된 토큰");
            return null;
        } catch (MalformedJwtException | SignatureException | UnsupportedJwtException e) {
            //형식이 잘못된 경우/서명이 다른 경우/미지원 토큰
            log.debug("유효하지 않은 토큰 (형식 오류/서명 불일치/미지원)");
            return null;
        } catch (IllegalArgumentException e) {
            //토큰이 비거나 null인 경우
            log.debug("토큰이 비어있거나 null");
            return null;
        }
    }

    //HTTP 요청 헤더에서 토큰 추출
    public static String resolveToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) return header.substring(7);
        return null;
    }
}

토큰 검증 메소드: validateToken()

  • Jwts: jjwt 라이브러리의 진입점 클래스로 토큰을 만들 때는 Jwts.builder(), 토큰을 검증/해석할 때는 Jwts.parser()를 쓰도록 설계되어있음.
  • Claims: jjwt 라이브러리에서 제공하는 인터페이스로. JWT 토큰 안에 들어있는 내용물(payload)를 담는 것. memberId, issuedAt, expiration 같은 정보들이 들어있음.
    • verifyWith(): 해당 서명 키로 검증하라고 지정하는 것. 이때 키는 JwtUtil의 생성자에서 만든 SecretKey.
    • build(): 설정 종료. 실제 파서(parser) 객체를 완성한 것.
    • parseSignedClaims(): 진짜 검증 작업.
    • 서명이 맞지 않으면 SignatureException, 만료되었으면 ExpiredJwtException이 던져짐
    • getPayload(): 검증 통과 시, 토큰 내에 있던 내용을 꺼냄
  • 예외 정리
    • MalformedJwtException: 토큰 형식 자체가 이상할 때. JWT 구조가 깨져있음
    • UnsupportedJwtException: 지원하지 않는 형식의 JWT.
public Claims validateToken(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(key)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();

        } catch (ExpiredJwtException e) {
            //만료된 토큰
            log.debug("만료된 토큰");
            return null;
        } catch (MalformedJwtException | SignatureException | UnsupportedJwtException e) {
            //형식이 잘못된 경우/서명이 다른 경우/미지원 토큰
            log.debug("유효하지 않은 토큰 (형식 오류/서명 불일치/미지원)");
            return null;
        } catch (IllegalArgumentException e) {
            //토큰이 비거나 null인 경우
            log.debug("토큰이 비어있거나 null");
            return null;
        }
    }

토큰 추출하는 메소드: resolveToken()

  • HttpServletRequest: Servlet 표준 API. 웹 서버가 HTTP 요청 하나를 자바 객체로 표현할 때 사용하는 표준 인터페이스.
    • 톰캣(내장 서블릿 컨테이너)가 요청 내용(헤더, body, URL, 파라미터)를 HttpServletRequest 객체로 감싸서 코드에 전달.
  • getHeader()를 통해 요청의 헤더 부분 중 “Authorization”의 값 가져옴.
  • 헤더값이 널(null)이 아니라면, “Bearer “ 7자 이후부터의 문자열을 부분문자열로 만들어서 리턴함으로써 토큰을 추출하는 것
public static String resolveToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) return header.substring(7);
        return null;
    }

(2) JwtFilter

  • OncePerReqeustFilter: 요청 하나 당 딱 한 번만 실행되도록 보장해주는 필터 베이스 클래스. 스프링이 제공하는 추상 클래스.
    • JwtFilter는 토큰을 검증하고, SecurityContext를 채우는 로직이기에 딱 한 번만 실행되어야 유의미하고, 여러 번 실행되면 불필요한 중복 작업 또는 부작용이 생길 수 있음.
    • doFilterInternal()을 오버라이드해야함
  • 필드주입이 아닌 생성자 주입 방식 사용 + 롬복 없이 수동으로 작성.
@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtFilter(JwtUtil jwtUtil) {this.jwtUtil = jwtUtil;}

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = JwtUtil.resolveToken(request);

        if (token == null) {
            log.debug("토큰 없음 - URI: {}", request.getRequestURI());
        }
        else{
            Claims claims = jwtUtil.validateToken(token);

            if (claims == null) log.warn("유효하지 않은 토큰 - URI: {}", request.getRequestURI());
            else {
                Long memberId = claims.get("memberId", Long.class);

                CustomUserDetails userDetails = new CustomUserDetails(memberId);
                Authentication authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authToken);

                log.debug("인증 성공 - memberId: {}, URI: {}", memberId, request.getRequestURI());
            }
        }

        filterChain.doFilter(request, response);
    }
}
  • 앞에서는 일단 JwtUtil에 만들어둔 토큰 추출하는 메소드를 호출해서 토큰을 꺼냄
  • 이후에 JwtUtil에 있는 토큰 검증 메소드를 사용해서, 검증 성공 시에 Claims를 가져와 요청(request)의 내용을 가져옴.
    • 이 Claims 안에 있는 memberId 가져오고, 이를 바탕으로 CustomUserDetails 로 감싸서 객체 만들기
    • CustomDetails를 인자로 해서, Authentication 만들고 이를 SecurityContextHolder에 등록 → 이걸 @AuthMember가 이를 활용.
  • 마지막에 항상 실행되는 부분: filterChain.doFilter(request, response): SecurityContext를 채운 여부에 관계없이 다음 필터로 요청 전달

(3) CustomUserDetails

  • Spring Security에서 제공하는 인터페이스인 UserDetails를 구현(implements)
    • 인증된 사용자가 누구인지 표현하기 위해 정의해둔 표준 인터페이스.
    • Security 내부 컴포넌트들이 로그인된 사용자 정보를 다룰 때에 항상 해당 타입을 기준으로 동작. (예: AuthenticationManager, SecurityContext)
    • UserDetails 인터페이스
      public interface UserDetails extends Serializable {
      	//권한 목록
      	Collection<? extends GrantedAuthority> getAuthorities();
      	String getPassword(); //비밀번호 (해시)
      	String getUsername(); //식별자(보통 아이디 또는 이메일)
      	boolean isAccountNonExpired(); //계정 만료 여부
      	boolean isAccountNonLocked(); //계정 잠금 여부
      	boolean isCredentialsNonExpired(); //비밀번호 만료 여부
      	boolean isEnabled(); //계정 활성화 여부
      }
public class CustomUserDetails implements UserDetails {

    private final Long memberId;

    public CustomUserDetails(Long memberId) {this.memberId = memberId;}

    //MemberID 반환 메소드
    public Long getMemberId () {return this.memberId;}

    //불필요한 메소드 (ROLE/비밀번호/사용자 이름 등)
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_USER"));
    }

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

    @Override
    public String getUsername() {return String.valueOf(memberId);}

    //불필요한 메소드: 만료일/계정 정지/계정 탈퇴 등 구현 시 필요
    @Override
    public boolean isAccountNonExpired() {return true;}
    @Override
    public boolean isAccountNonLocked() {return true;}
    @Override
    public boolean isCredentialsNonExpired() {return true;}
    @Override
    public boolean isEnabled() {return true;}
}
  • 해당 프로젝트에서는 JWT 기반 인증을 사용하고 있으니 비밀번호, 문자열 아이디를 매 요청 들고 다니지X.
    • memberId(Long)을 핵심 데이터로 들고
    • getPassword()의 경우 null 반환 ⇒ JWT 인증 이후에는 비밀번호 재검증 불필요
    • getUsername()의 경우 memberId를 문자열로 변환해서 반한 (인터페이스 스펙 상 String 타입이 강제되니, 식별자로 memberId 재활용)
    • 아직 계정 탈퇴/정지는 미구현이라 true 반환으로 고정.
  • 이 CustomUserDetails를 통해 헤더에 담긴 accessToken을 토대로 memberId를 꺼내서 사용해야하는데? 커스텀 어노테이션을 만들어서 사용할 생각으로 해당 부분까지 구현함.
    • 그냥 CustomUserDetails에서 꺼내쓰는건 @AuthenticationPrincipal 어노테이션을 사용해서 컨트롤러에서 꺼내쓸 수 있음.
    • @AuthenticationPrincipal: 내부적으로 SecurityContextHolder.getContext().getAuthentication().getPrincipal()을 꺼내서 CustomUserDetails로 캐스팅하여 넣어주는 어노테이션.

(4) @AuthMember 커스텀 어노테이션

  • 스프링 기본 제공 방법 기본적으로 Spring Security에서 제공하는 방법은 @AuthenticationPrincipal 어노테이션을 사용해 컨트롤러의 메소드 쪽에서 CustomUserDetails를 받는 방식. 별도로 ArgumentResolver나 커스텀 어노테이션을 만들지 않고 스프링이 구현한 것을 가져다 쓰면 됨. CustomUserDetails에서 getMemberId() 메소드를 호출해 회원의 ID를 가져옴
  • 다만 CustomUserDetails에서 memberId를 가져오지 않고 커스텀 어노테이션 + Resolver를 통해 memberId를 파라미터 쪽에서 바로 받아서 사용할 수 있도록 구현
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthMember {
}
  • @Target(ElementType.PARAMETER): 해당 어노테이션을 메소드의 파라미터 위치에만 붙일 수 있게 제한
  • @Retention(RetentionPolicy.RUNTIME): 어노테이션 정보를 런타임까지 유지하겠다는 의미. ⇒ 스프링이 요청 처리 중에 해당 파라미터에 @AuthMember가 붙어있는지를 확인해야하기에 RUNTIME 설정이 필수적. (SOURCE/CLASS이면 이미 정보가 사라져서 못 읽는다고함.)
  • 내용이 빈 마커 어노테이션으로 별도의 속성값이 없이, 표시 역할만 수행 → 따라서 짝이 되는 HandlerMethodArgumentResolver가 따로 실제 값을 채워주는 역할 담당.
  • 추가적으로 WebMvcConfigurer에 등록해야 실제로 동작한다고함.

(5) AuthMemberResolver

public class AuthMemberResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasAnnotation = parameter.hasParameterAnnotation(AuthMember.class);
        boolean isLongType = Long.class.equals(parameter.getParameterType());

        return hasAnnotation && isLongType;
    }

    @Override
    public @Nullable Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
                                            NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || !((authentication.getPrincipal()) instanceof CustomUserDetails userDetails)) {
            throw new BusinessException(GlobalErrorCode.UNAUTHORIZED);
        }
        return userDetails.getMemberId();
    }
}
  • supportsParameter(): 스프링이 컨트롤러의 메소드를 호출하기 전, 각각의 파라미터마다 supportsParameter() 메소드를 호출해 해당 파라미터를 처리가능한지 확인함 아래에서는 아래 두 가지 조건을 검사하여 모두 만족해야 true를 반환 → 이래야 스프링이 Resolver의 resolveArgument를 호출.
    • 파라미터에 @AuthMember가 붙어있는지
    • 파라미터 타입이 정확히 Long인지.
  • resolveArgument(): 실제로 파라미터에 값을 만들어서 반환해주는 메소드. 1- SecurityContextHolder를 통해 현재 요청의 인증 정보를 가져옴. → JwtFilter가 채워둔 Authentication 객체. 2- 인증정보(Authentication 객체)가 널(null)이거나 principal이 CustomUserDetails 타입이 아니면(=인증X 또는 예상과 다른 타입) BusinessException(401 UNAUTHORIZED)를 던짐 3-위의 과정 통과시 userDeatils.getMemberId()를 통해 회원의 ID를 반환해줌

⇒ 실질적으로 매 컨트롤러 메소드에서 CustomUserDetails를 통해 회원ID를 꺼내 쓰는 것을 조금 더 간편화하기 위해 이런걸 만든건데..? 실제로 사용하기에 편하긴 했던..? 중복 코드도 좀 줄이고…? 그런데 왠지 공통적으로 스프링에서 애초에 제공하는걸 사용하는 것도 괜찮을 것 같기도한 느낌..?

(6) WebConfig

앞에서 설명한 WebConfig 부분에 커스텀으로 만든 Resolver를 등록해줘야 실제 사용이 가능하기에 아래처럼 등록을 해줌.

  • ❓WebMvcConfigurer? Spring MVC가 기본적으로 제공하는 인터페이스로 Spring MVC의 기본 동작을 커스터마이징 할 수 있는 훅(hook)들을 모아둠. 스프링 부트가 알아서 해주는 기본설정(auto-configuration) 중 특정 부분을 바꿀 때 인터페이스를 구현해 오버라이드하는 방식으로 사용.
@Configuration
public class WebConfig implements WebMvcConfigurer {

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

(8) SecurityConfig

SecurityFilterChain에 앞에서 만든 JwtFilter를 등록하는 과정 역시 필요. 앞에서 말했듯이 UsernamePasswordAuthenticationFilter 앞의 위치에 삽입.

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

        //필터 등록
        http.addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);

        ...

        return http.build();
    }

(7) 전체 흐름

로그인

인증/인가

profile
이불 밖은 위험해.

0개의 댓글