[밍글] Spring Security + JWT로 인증/인가 구현하기

KIM TAEHYUN·2023년 6월 6일
0

Spring Security

목록 보기
3/3

인증 흐름 소개

저번 글에서 JWT를 활용해 로그인 API에서 access, refresh token을 발급하고,
재발급 API로 access token 및 refresh token을 재발급하는 과정을 보았습니다.

이번 글에서는 Spring Security를 통해 발급된 Access token을 인증하는 과정을 적어보도록 하겠습니다.
사용되는 클래스가 많아 혼란이 올 수도 있으니,
인증/인가 단계를 거치며 호출되는 메소드 순서대로 각 클래스에 대한 설명을 적어보겠습니다.

전체적인 흐름은 아래와 같습니다. 아래 그림을 보며 글을 같이 읽는다면 이해에 도움이 될 것입니다.

이 글은 2개의 파트로 나뉘어져 있습니다.

  1. 토큰을 파싱하고 검증해 문제가 없을 시 SecurityContext에 인증된 유저 정보인 CustomAuthenticationToken 등록
  2. SecurityContext에 등록된 유저 정보로 접근 정책에 따른 검사 (MemberGuard)

그 전에, 간단히 Spring Security와 사용되는 용어에 대해 잠시 알아보겠습니다.


Spring Security 란

Spring Security 는 Spring 기반의 애플리케이션에서 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하며 Security 관련 기능을 쉽게 구현할 수 있도록 도와주는 프레임워크입니다.

Spring Security는 일련의(연결된) 필터들을 가지고 있으며, 요청(request)은, 인증(Authentication)과 권한부여(Authorization)를 위해 이 필터들을 통과하게 됩니다 .이 필터를 통과하는 과정은, 해당 요청과 관련된 인증 필터(인증 메커니즘/모델에 기반한 관련 인증 필터)를 찾을 때 까지 지속됩니다.

기본용어

  • 접근 주체(Principal) : 보호된 리소스에 접근하는 대상. (밑에서 Principal이 자주 나오니 기억해두시면 좋습니다.)
  • 인증(Authentication) : 보호된 리소스에 접근한 대상에 대해 누구인지, 애플리케이션의 작업을 수행해도 되는 주체인지 확인하는 과정 => 즉, 누구인지?
  • 인가(Authorize) : 해당 리소스에 대해 접근 가능한 권한을 가지고 있는지 확인하는 과정(After Authentication, 인증 이후) => 즉, 어떤 것을 할 수 있는지?
  • 권한 : 어떠한 리소스에 대한 접근 제한, 모든 리소스는 접근 제어 권한이 걸려있음. 인가 과정에서 해당 리소스에 대한 제한된 최소한의 권한을 가졌는지 확인

  • Authentication 객체 : Spring Security에서 한 유저의 인증 정보를 가지고 있는 객체, 사용자가 인증 과정을 성공적으로 마치면, Spring Security는 사용자의 정보 및 인증 성공여부를 가지고 Authentication 객체를 생성한 후 SecurityContext에 보관한다.
    Authentication은 인터페이스로 이 포스팅에서는 Authentication을CustomAuthenticationToken 객체로 구현해주었다.
  • SecurityContext : 인증된 사용자의 정보인 Authentication 객체를 저장하는 곳. 애플리케이션 어디에서든지 접근할 수 있다.
    • 예)Authentication authentication = SecurityContextHolder.getContext().getAuthentication()

인증 로직

전체적인 인증 로직을 간략히 소개해보겠습니다.

  1. 클라이언트가 API를 요청한다. 이 때, 로그인해서 발급받은 Access Token을 HTTP Authorization 헤더에 담아서 보내줍니다.
  2. 필터를 거치며 우리가 작성한 JwtAuthenticationFilter에 도착합니다. 필터에서는 Authorization 헤더에서 토큰을 검증합니다.
    토큰에 문제가 없다면 토큰을 파싱해 얻은 사용자의 정보인 PrivateClaims을 가져와 Authentication의 구현체인 CustomAuthenticationToken 형태로 SecurityContext에 저장합니다.
  3. 요청한 URL에 따라서 접근 허용 여부를 검사합니다.
    3-1. 인증된 사용자라면 SecurityContext에 저장된 Authentication (인증 정보를 가져와 MemberGuard를 거쳐 통과한다면 접근을 허용해줍니다.
    3-2. 인증 과정에서 에러가 발생했다면 그에 따른 에러를 보내줍니다.

Part 1

1. 스프링 시큐리티 설정

  • SecurityConfig.java

먼저 Spring Security 설정을 하기 위해 SecurityConfig 를 작성합니다.
@Configuration과 @EnableWebSecurity를 선언하여 Security 관련 설정과 빈들을 활성화 시켜주고,
JWT와 API 서버로 사용하기 위한 설정과, 인증이 필요한 URL들을 정의하고 JwtAuthenticationFilter를 추가하도록 설정하였습니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final TokenHelper tokenHelper; //1.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .httpBasic().disable()  //http basic 인증방법 비활성화
            .formLogin().disable()  //form login 비활성화
            .csrf().disable()       //csrf 관련 정책 비활성화
            //세션 관리정책 설정, 세션을 유지하지 않도록 설정
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            //2. 아래 url 에 대해 누구나 접근 허용
            .antMatchers(HttpMethod.POST, "/auth/**").permitAll() //가입 프로세스
            .antMatchers(HttpMethod.GET, "/auth/**").permitAll() //가입 프로세스
            .antMatchers(HttpMethod.GET, "/ping").permitAll() //서버 alive 체크
						//3. 그 외 나머지 API들은 로그인이 필요한 API들로 접근 권한 체크 활성화 
            .antMatchers("/post/**").access("@memberGuard.check()")
            .antMatchers("/member/**").access("@memberGuard.check()")
            .antMatchers("/comment/**").access("@memberGuard.check()")
            .antMatchers("/home/**").access("@memberGuard.check()")
            .and()
            .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint()) //4. 
            .and()
            .addFilterBefore(new JwtAuthenticationFilter(tokenHelper), UsernamePasswordAuthenticationFilter.class);
    return http.build();
}
  1. 토큰을 통해 사용자를 인증하기 위해 JwtAuthenticationFilter에서 필요한 의존성입니다. 토큰에 저장된PrivateClaims 로 사용자의 정보를 조회하는데 사용됩니다.
  2. /auth 로 시작하는 요청들을 누구나 접근 허용해줍니다. 회원가입 프로세스는 누구나 접근할 수 있기 때문입니다.
  3. 그 외 나머지 API들은 로그인이 필요한 API들로 접근 권한 체크를 활성화해줍니다. MemberGuard의 check()메소드를 통해 인증합니다. MemberGuard.check() 의 반환 결과가 true라면 요청을 수행할 수 있도록 해주었습니다.
  4. 인증되지 않은 사용자의 접근이 거부되었을 때 작동할 핸들러를 지정해줍니다.
  5. 토큰으로 사용자를 인증하기 위해 직접 정의한 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter의 이전 위치에 등록해줍니다. JwtAuthenticationFilter는 토큰 검증에 필요한 의존성인 TokenHelper를 주입받습니다.
    UsernamePasswordAuthenticationFilter는 자신이 처리할 요청이 들어오면 다음 필터를 거치지 않기 때문에, 그 이전에 필터를 등록해야 정상적으로 인증을 수행할 수 있습니다.
    JwtAuthenticationFilter를 거치며 valid한 access token을 통해 인증이 되었다면, 시큐리티 컨텍스트에 인증된 유저 정보를 담고있는 Authentication 객체가 담겨있을 것 입니다. 그렇다면 이제 컨트롤러로 리다이렉트되기 전 수행되는 memberGuard에서 진행되는 인증/인가 과정을 통과할 것입니다.

2. JwtAuthenticationFilter

먼저, 토큰과 함께 요청이 들어올 시 인증을 위해 거치는 JwtAuthenticationFilter를 살펴보겠습니다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter { //1

    private final TokenHelper tokenHelper; //2

    @Override
    public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { //3
        Optional<String> t = extractToken(request); //4. resolve Access Token 
        if (!(t.isEmpty())) {
            Authentication authentication = tokenHelper.validateToken(request, t.get()); //5. 토큰 검증 - 유효 여부 확인 & 인증된 인증 객체 생성 후 반환
            SecurityContextHolder.getContext().setAuthentication(authentication); // 6. SecurityContextHolder 에 인증 객체 저장
        } else {
            request.setAttribute("exception", "토큰 헤더가 잘못되었습니다. Authorization 을 넣어주세요.");
        }
			//다음 filterchain을 실행하며, 마지막 filterchain인 경우 Dispatcher Servlet이 실행된다.
        chain.doFilter(request, response);
    }

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

<주석 및 흐름 설명>

  1. doFilterInternal 메소드가 있는 OncePerRequestFilter를 상속받습니다.
  2. 토큰을 검증하기 위해 필요한 의존성인 TokenHelper를 주입받습니다.
  3. 오버라이드 된 doFilterInternal을 호출합니다. 이 메소드는 다음과 같은 역할을 수행합니다.
    1. Causes the next filter in the chain to be invoked, or if the calling filter is the last filter in the chain, causes the resource at the end of the chain to be invoked.
      Guaranteed to be just invoked once per request within a single request thread.
  4. Optional<String> t = extractToken(request);
    요청으로 전달 받은 Authorization 헤더에서 토큰 값을 꺼내옵니다.
    request.getHeader(): return a String containing the value of the requested header, or null if the request does not have a header of that name.
  5. Authentication authentication = tokenHelper.validateToken(request, t.get());
    1. 토큰이 유효한지 (우리가 지정했던 key로 sign 됐는지, 만료되지는 않았는지 등) 검증합니다.
    2. 토큰이 유효하다면 토큰에 담긴 유저 정보를 이용해 인증 객체(Authentication 인터페이스의 구현체인 CustomAuthenticationToken)를 만들고, isAuthenticated 를 true 로 세팅해줍니다.
    3. 인증된 CustomAuthenticationToken을 반환해줍니다.
  6. SecurityContextHolder.getContext().setAuthentication(authentication);
    SecurityContextHolder 에 인증된 Authentication 객체인 CustomAuthenticationToken을 등록해줍니다.
    아래는 javadoc 설명입니다.
    1. Changes the currently authenticated principal, or removes the authentication information.
    2. Params: authentication – the new Authentication token, or null if no further authentication information should be stored.

이제 5번의 과정들을 수행하는 tokenHelper의 validateToken() 메소드를 보겠습니다.


3. TokenHelper

JwtAuthenticationFilter에 필요한 TokenHelper를 살펴보겠습니다.

Access token을 검증하고 인증된 객체인 CustomAuthenticationToken을 반환해주는 TokenHelper의 validateToken() 메소드를 가지고 있습니다.

지난 시간에 토큰을 만드는 메소드들을 추가했었는데, 이 부분은 생략하겠습니다.

@Service
@RequiredArgsConstructor
public class TokenHelper { 

    private final JwtHandler jwtHandler;
    private final RedisService redisService;
    **private final CustomUserDetailsService customUserDetailsService;**

    @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;

    private static final String ROLE_TYPES = "ROLE_TYPES";
    private static final String MEMBER_ID = "MEMBER_ID";

		//토큰 생성 관련 메소드 - 2편에서 구현 완료. 여기서는 생략
    public String createAccessToken(PrivateClaims privateClaims) {...}
    public String createRefreshToken(PrivateClaims privateClaims, String email) {...}
    public Optional<PrivateClaims> parseRefreshToken(String token, String email) {...}
    private PrivateClaims convert(Claims claims) {...}

    @Getter
    @AllArgsConstructor
    public static class PrivateClaims {
        private String memberId;
        private UserRole roleTypes;
    }

    /**
     * validate ACCESS Token. JwtAuthenticationFilter의 doFilter() 에서 쓰인다.
		 * 
     */
    public Authentication validateToken(HttpServletRequest request, String token) throws BadRequestException {
        String exception = "exception";
        try {
            Jwts.parser().setSigningKey(accessKey.getBytes()).parseClaimsJws(jwtHandler.untype(token)); 
            return getAuthentication(token); //loadByUsername으로 반환된 CustomUserDetails을 Authentication 형식인 CustomAuthenticationToken으로 반환
        } catch (BadRequestException e) {
            request.setAttribute(exception, "토큰을 입력해주세요. (앞에 'Bearer ' 포함)");
        } catch (MalformedJwtException | SignatureException | UnsupportedJwtException e) {
            request.setAttribute(exception, "잘못된 토큰입니다."); 
        } catch (ExpiredJwtException e) {
            request.setAttribute(exception, "토큰이 만료되었습니다.");
        } catch (IllegalArgumentException e) {
            request.setAttribute(exception, "토큰을 입력해주세요.");
        }
        return null;
    }

    /**
     * loadUserByUsername 으로 CustomUserDetail 반환
     * 인증된 객체인 CustomAuthenticationToken 형식으로 만들어 반환 
     */
    private Authentication getAuthentication(String token) { 
        CustomUserDetails userDetails = customUserDetailsService.loadUserByUsername(token);
        return new CustomAuthenticationToken(userDetails, userDetails.getAuthorities()); //(principal, authorities)
    }
}

새로운 의존성인 CustomUserDetailsService 이 추가되었습니다.

밑에서 자세하게 살펴보도록 하고, validateToken() 메소드의 흐름을 자세히 알아보겠습니다.

  • JwtAuthenticationFilter에서 validateToken() 메소드를 호출합니다.
  • 받아온 토큰(access token)을 파싱하고, 우리가 설정한 key로 사인이 되었고 유효한 토큰인지 확인합니다.
    만일 만료되거나 invalid한 토큰이라면 exception을 발생시킵니다.
  • 토큰을 파싱했을 때 문제가 없다면, 인증 권한을 줄 수 있는 유저라는 뜻입니다. 이에 권한이 부여된 인증 객체인CustomAuthenticationToken 객체를 받아오기 위해 getAuthentication() 메소드를 호출합니다.
    • 토큰에 담긴 정보만으로 인증된 객체를 만드는 이유:
    • 보통 Spring Security를 적용할때는 토큰에 담긴 정보(eg. memberId) 로 DB에 접근해 사용자의 정보(인증에 필요한 정보)를 조회합니다.
    • 하지만 요청을 받을 때마다 사용자의 정보를 데이터베이스에서 조회하는 것은 비효율적인 작업입니다. 이에 토큰에 인증에 필요한 사용자의 모든 정보를 담아둘 수 있습니다. 인증에 필요한 정보가 토큰에 담겨있다면, 데이터베이스에 접근할 필요는 없어지게 됩니다.
    • 하지만, 만일 토큰에 담긴 사용자의 정보가 변경된다면 이미 발급한 토큰에서는 반영이 되지 않습니다.
    • 또한, 만일 토큰에 담긴 정보로만 인증을 하게 된다면 변경된 정보가 발급된 토큰으로 인해 사용자에게 즉시 반영되지 않기에, 문제의 소지가 있습니다.
    • 그럼에도 이 프로젝트에서 데이터베이스를 접근하지 않는것은, 우리는 토큰에 memberId와 UserRole으로 이루어진 PrivateClaims을 넣어줍니다. 현재 인증에 필요한 필드는 이 두가지밖에 없습니다. 그리고 memberId는 PK이기에 불변하고, UserRole 이 변경될만한 비즈니스 로직을 현재 가지고 있지 않습니다.
    • 이에, 토큰에 낡은 정보가 담길 위험이 없다고 판단하여 DB를 접근하지 않고 토큰이 valid하다면 담겨있는 정보로 유저를 인증해주는 로직을 선택 한 것입니다.
    • 그러한 상황이 생긴다고 하더라도, 단순히 토큰을 다시 생성해주는 것이 DB에 접근하는 것 보다 더 효율적일것입니다.
  • validateToken() 에 의해 호출된 getAuthentication() 메소드는 CustomUserDetailsServiceloadUserByUsername()을 호출합니다.
    • loadUserByUsername() 메소드는 토큰에서 유저 정보를 추출해 유저 정보와 부여된 권한이 담긴 CustomUserDetails 를 반환합니다.
  • 그 후, loadUserByUsername()으로 반환된 CustomUserDetails과 CustomUserDetails의 권한을 담아 인증된 Authentication 객체를 CustomAuthenticationToken 형태로 반환합니다.

우선, 최종 인증된 객체인 Authentication 을 구현하는 CustomAuthenticationToken 을 먼저 살펴보겠습니다.


3-1. CustomAuthenticationToken

CustomAuthenticationToken은 CustomUserDetailsService를 이용하여 조회된 사용자의 정보 CustomUserDetails와 유저 권한인 authorities를 UserRole 형태로 저장해줍니다.

public class CustomAuthenticationToken extends AbstractAuthenticationToken {
    private CustomUserDetails principal;

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

    @Override
    public CustomUserDetails getPrincipal() {
        return principal;
    }
    @Override
    public Object getCredentials() {
        throw new UnsupportedOperationException();
    }
}

Spring Security에서 제공해주는 추상 클래스 AbstractAuthenticationToken(Base class for Authentication objects)을 상속받아서, 사용자를 인증하는데 필요한 최소한의 정보를 기억하도록 하였습니다.

Principal에 CustomUserDetails, authorities에 권한 정보로 enum인 UserRole을 넣어주었습니다.

Authentication.setAuthenticated(true) 로 인증 여부를 true로 설정해주었습니다. 이렇게 생성된 CustomAuthenticationToken은 인증이 된 Authentication 객체를 나타냅니다.

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

이제 CustomAuthenticationToken의 Principal로 넣어줄 CustomUserDetails을 살펴보겠습니다.


3-2. CustomUserDetails

CustomUserDetails는 사용자의 정보(memberId)권한/authorities(UserRole)을 담고 있습니다.

@Getter
public class CustomUserDetails implements UserDetails {

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

  public CustomUserDetails(String memberId, SimpleGrantedAuthority authorities){
      this.memberId = memberId;
      this.authorities = Set.of(authorities);
  }

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

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

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

  @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 인터페이스를 구현한 클래스입니다.

UserDetails에 대해 제공되는 javadoc을 읽어보면,

“Provides core user information. Implementations are not used directly by Spring Security for security purposes. They simply store user information which is later encapsulated into Authentication objects. This allows non-security related user information (such as email addresses, telephone numbers etc) to be stored in a convenient location.”

라고 써져있습니다.

우리는 Authentication object를 CustomAuthenticationToken으로 구현합니다.

그리고 그 CustomAuthenticationToken을 만들 때 principal로 CustomUserDetails 를 넣어줍니다.

javadoc의 설명대로, 우리는 나중에 Authentication object로 encapsulate 될 유저 정보(userId, authorities) 들을 넣어줄 뿐입니다.

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

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

CustomUserDetails 설명

우리는 JWT 토큰을 발급할 때 JWT body 안에 claim으로 PrivateClaims 객체를 넣어줍니다.

그리고 validateToken()을 할 때 우리는 토큰을 파싱해보며 우리가 발급했던 토큰인지, 만료되지는 않았는지, 제대로 된 토큰이 들어왔는지 등 토큰의 유효성을 검증했습니다.

이처럼 조작되지 않은 토큰이라는 것을 확인했다면, 이 토큰을 가지고 요청하는 유저에게는 인증을 해줘야 할 것입니다. 이에 우리는 memberId 와 권한(authorities)을 담은 CustomUserDetails 형태를 반환해줄 것입니다.

JWT에 담긴 PrivateClaims는 String type 인 memberId 와, enum 타입인 UserRole 을 가지고 있습니다.

이에 사용자의 정보/권한을 담는 CustomUserDetails을 만들기 위해 우리는 JWT에서 PrivateClaims 을 추출해, 그 안의 memberId 와 UserRole을 가지고 memberId와 UserRole.toString() 형태의 권한인 SimpleGrantedAuthority를 넣은 CustomUserDetails를 만들어줍니다.

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

  • SimpleGrantedAuthority: Basic concrete implementation of a GrantedAuthority.
    Stores a String representation of an authority granted to the Authentication object.
  • GrantedAuthority: Represents an authority granted to an Authentication object.
    A GrantedAuthority must either represent itself as a String or be specifically supported by an AccessDecisionManager.

4. CustomUserDetailsService

이제 CustomUserDetails 를 반환하는 loadUserByUsername() 을 구현하는 CustomUserDetailsService 클래스를 살펴보겠습니다.

@Component
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final JwtHandler jwtHandler;

    @Override
    public CustomUserDetails loadUserByUsername(String parsedToken) throws UsernameNotFoundException { //1
        return convertTokenToUserDetail(parsedToken);
    }

    /**
     * JWT 에서 Claim 추출 후 UserDetail 반환
     * @param parsedToken
     */
    private CustomUserDetails convertTokenToUserDetail(String parsedToken) { //2
        Optional<TokenHelper.PrivateClaims> privateClaims = jwtHandler.createPrivateClaim(parsedToken);
	        return new CustomUserDetails(privateClaims.get().getMemberId(), new SimpleGrantedAuthority(privateClaims.get().getRoleTypes().toString()));
    }
}

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

  1. loadUserByUsername(token): 오버라이드한 메소드 명과는 달리, username으로 유저 정보를 가져오는게 아닌 JWT에서 유저 정보를 가져옵니다. 이에 해당 작업을 수행하는 convertTokenToUserDetail(token) 메소드를 호출합니다. 이 메소드는 CustomUserDetails 을 반환합니다.
  2. convertTokenToUserDetail(token): JwtHandler 의 createPrivateClaim(parsedToken)을 호출해 받아온 PrivateClaims 으로 CustomUserDetails를 만들어 반환해줍니다.

5. JwHandler

JWT에서 PrivateClaims를 만드는 createPrivateClaim() 메소드를 가지고 있는 클래스입니다.

@Component
@RequiredArgsConstructor
public class JwtHandler {

    private String type = "Bearer";

    private final RedisService redisService;

    @Value("${jwt.key.access}") // parse 할때 필요
    private String accessKey;

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

		//생략. 2편에서 구현
    public String createToken(String key, Map<String, Object> privateClaims, long maxAgeSeconds) {...} // PrivateClaims으로 토큰 생성
    public Optional<Claims> checkRefreshToken(String key, String refreshToken, String email) throws BaseException {...}// refresh Token 재발급할떄 validate

    /**
     * ===== 토큰 parse 후 privateClaim 으로 변환 =====
     * PrivateClaim에 memberId와 UserRole 을 가지고있다. 
     */
    public Optional<TokenHelper.PrivateClaims> createPrivateClaim(String token) {
        Optional<Claims> claims1 = parseAccessToken(accessKey, token); //토큰 파싱
        return claims1.map(claims -> convertClaim(claims)); //
    }

    /**
     * ㄴ 토큰 parse : getBody() : Returns the JWT body, either a String or a Claims instance. Claim 반환
     */
    public Optional<Claims> parseAccessToken(String key, String token) {
        try {
            return Optional.of(Jwts.parser().setSigningKey(key.getBytes()).parseClaimsJws(untype(token)).getBody());
        } catch (JwtException e) {
            return Optional.empty();
        }
    }

    /**
     * ㄴ Claim -> privateClaim 변환
     */
    private TokenHelper.PrivateClaims convertClaim(Claims claims) {
        return new TokenHelper.PrivateClaims(claims.get("MEMBER_ID", String.class), UserRole.valueOf(claims.get("ROLE_TYPES", String.class)));
    }
}

저번 편에서 우리는 토큰을 생성할 때 body에 우리가 직접 정의한 PrivateClaims을 넣어주었습니다. 이에 토큰을 파싱해 안에 있는 PrivateClaims을 반환해줄 수 있습니다.

PrivateClaims는 String type 인 memberId 와, enum 타입인 UserRole 을 가지고 있습니다.

  1. createPrivateClaim(token): 받아온 토큰을 parse 후 privateClaims 으로 변환합니다.
  2. parseAccessToken(key, token): 토큰을 검증하고, getBody() 로 Claims을 받아옵니다. 유효하지않은 토큰이라면, 비어있는 Optional을 반환해주도록 하였습니다.
  3. convertClaim(claims): 받아온 claims을 PrivateClaims 형태로 만들어 반환합니다.

이렇게 차례대로 doFilter()의 validateToken() 을 호출했을 시 호출되는 메소드들과 클래스들에 대해 알아보았습니다. 이제 반환된 CustomAuthenticationToken 객체는 SecurityContextHolder에 저장이 되었을 것입니다.

이제 저장된 인증 객체를 꺼내와 요청과 접근 정책에 따라 검사하는 부분을 살펴보겠습니다.


Part 2 - 접근 권한 검사

SecurityContext에 담긴 Authentication 구현체로 요청과 접근 정책에 따른 검사를 진행하는 부분을 살펴보겠습니다. 이에 필요한 MemberGuard 와 AuthHelper 2가지 클래스를 보겠습니다.

6-1 MemberGuard

처음 설정한 SecurityConfig 에서 우리는 로그인이 필요한 API들에 대해 접근 권한 체크를 활성화해주었습니다.

이는 MemberGuard의 check()메소드를 통해 검사를 하며, MemberGuard.check() 의 반환 결과가 true라면 요청을 수행할 수 있도록 해주었습니다.

MemberGuard는, 사용자의 접근 권한, 즉 UserRole 뿐만아니라 요청하는 자원에 대한 소유주인지에 대해서도 검증할 수 있습니다. 하지만, 이는 Database에 접근을 필요로 합니다. 이에 따른 장단점이 있는데, 이는 밑에서 더 자세히 서술하도록 하겠습니다.

결론적으로, 지금은 자원 소유주 검증 로직이나 권한 별로 인가 로직을 따로 시큐리티 단에서 다루고 있진 않습니다.

권한 별로 인가 로직을 설정해주는 비즈니스 로직을 가진 API가 따로 없기 때문입니다.

이에 회원가입을 제외한 모든 API를 검사하는 MemberGuard의 hasAuthority() 는 어떤 UserRole이던 role을 가지고 있다면 인증/인가를 해주도록 구현하였습니다.

그렇기에 지금은 MemberGuard의 역할이 크게 와닿지 않지만, 나중에 권한 별 인가 로직이 필요하게 된다면 이를 통해 쉽게 도입할 수 있을 것 입니다.

이제 MemberGuard의 메소드들을 살펴보겠습니다.

@Component
@RequiredArgsConstructor
@Slf4j
public class MemberGuard {

  private final AuthHelper authHelper;

  public boolean check() {
      return authHelper.isAuthenticated() && hasAuthority(); //인증,인가. 지금으로썬 인가 로직은 없어서 모든 Role을 다 열어놓음
  }

  private boolean hasAuthority() {
      Set<UserRole> memberRoles = authHelper.extractMemberRoles();
      return memberRoles.contains(UserRole.USER) || memberRoles.contains(UserRole.ADMIN) || memberRoles.contains(UserRole.KSA) || memberRoles.contains(UserRole.FRESHMAN) ;
  }
}
  1. check(): 지금 요청한 사용자가 인증되었는지 (isAuthenticated()), 자원 접근 권한(현재로써는 모든 UserRole)을 가지고 있는지 (hasAuthority())를 검사하게 됩니다.
  2. hasAuthority(): extractMemberRoles() 로 추출한 UserRole 이 USER, ADMIN, KSA, FRESHMAN 중 하나라도 가지고 있는지 확인합니다.

이러한 검사 작업을 도와주기 위해 작성된 AuthHelper 클래스는 아래와 같습니다.


6-2 AuthHelper

SecurityContextHolder에 저장되어있는 사용자의 인증 정보를 추출하기 위해 도움을 줄 클래스입니다.


@Component
@Slf4j
public class AuthHelper {

	// return true if the token has been authenticated and the AbstractSecurityInterceptor does not need to present the token to the AuthenticationManager again for re-authentication.
  public boolean isAuthenticated() { //1. 
      return getAuthentication() instanceof CustomAuthenticationToken && getAuthentication().isAuthenticated(); //Authentication.isAuthenticated()
  }

  //obtains the currently authenticated authentication request token
  private Authentication getAuthentication() { //2.
      return SecurityContextHolder.getContext().getAuthentication();
  }

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

  private CustomUserDetails getUserDetails() { //4.
      return (CustomUserDetails) getAuthentication().getPrincipal(); 
  }
}
  1. isAuthenticated(): getAuthentication() 을 통해 받아온 Authentication 객체가 CustomAuthenticationToken 의 구현체인지 확인하고, Authentication.isAuthenticated() 로 인증된 토큰인지 확인해 true/false로 반환합니다.
    a. 인증되지 않은 사용자여도 Spring Security에서 등록해준 필터에 의해 AnonymousAuthenticationToken을 발급받게 되기 때문에, getAuthentication()의 반환 값이 우리가 직접 정의한 CustomAuthenticationToken일 때에만 인증된 것으로 판별해주었습니다.


  2. getAuthentication(): Authenticated 된 CustomUserDetail (principal) 을 가지고있는 Authentication 구현체(CustomAuthenticationToken)를 컨텍스트에서 가져와 반환합니다.
    즉, 현재 시큐리티 컨텍스트에 담긴 인증된 CustomAuthenticationToken을 가져옵니다.
    Authentication.getAuthentication() 호출 : Obtains the currently authenticated authentication request token.

  3. extractMemberRoles(): getUserDetails() 를 호출해 CustomUserDetails를 받아옵니다.
    받아온 CustomUserDetails에서 authorities 를 추출해 UserRole 형태로 반환합니다.

  4. getUserDetails(): 2번의 getAuthentication() 를 호출해 현재 컨텍스트에 담긴 객체인CustomAuthenticationToken의 principal, 즉 CustomUserDetails 를 받아옵니다.

참고) 위에서 CustomUserDetails를 만들때, 아래와 같이 memberId와 권한인 authorities를 담아 UserDetails를 구현했었습니다.

public class CustomUserDetails implements UserDetails {

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

  public CustomUserDetails(String memberId, SimpleGrantedAuthority authorities) {
      this.memberId = memberId;
      this.authorities = Set.of(authorities);
  }

그리고 이렇게 만든 CustomUserDetails을 principal로 가지고 있는 CustomAuthenticationToken 을 만들었습니다.

public class CustomAuthenticationToken extends AbstractAuthenticationToken { //implements Authentication
    private CustomUserDetails principal;

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

스프링에서 제공하는 Authentication 에 대한 문서를 읽어보면, 다음과 같은 설명이 있습니다.

Once the request has been authenticated, the Authentication will usually be stored in a thread-local SecurityContext managed by the SecurityContextHolder by the authentication mechanism which is being used. An explicit authentication can be achieved, without using one of Spring Security's authentication mechanisms, by creating an Authentication instance and using the code:

SecurityContextHolder.getContext().setAuthentication(anAuthentication);

우리는 JwtAuthenticationFilter 에서 위의 코드로 explicit authentication을 구현해 thread-local한 SecurityContext에 등록주었습니다.
이에 같은 스레드를 공유하고 있다면, 어떤 위치에서 사용하든지 저장해둔 Authentication 데이터를 공유할 수 있습니다.
AuthHelper는 그렇게 저장된 사용자 정보(Authentication)를 통해, 우리가 필요한 요청자의 id나 인증 여부, 권한 등을 추출하는데 도움을 주게 됩니다.


Part 3. Exception Handling

이제 마지막으로, 검증에 실패 시 호출되는 CustomAuthenticationEntryPoint 에 대해 알아보겠습니다.

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

이러한 핸들러의 작동은 컨트롤러 계층에 도달하기 전에 수행되기 때문에, 여기에서 예외가 발생한다해도, 우리가Exception을 편리하게 다루기 위해 사용하는 BaseException 으로 이 예외를 처리할 수 없습니다.

Security단에서 예외가 발생해도 Spring의 DispatcherServlet까지 닿을 수가 없기 때문입니다.

따라서 여기에서는 스프링에서 제공해주는 응답 방식을 이용할 수 없습니다. 그렇기에 그냥 여기에서 각각의 상황에 맞게 직접 응답을 작성하였습니다.


CustomAuthenticationEntryPoint 을 알아보기 전에,
이전에 TokenHelper의 validatToken() 메소드를 작성할 때 아래와 같이 request.setAttribute()로 각 에러마다 해당 예외가 어떤 종류의 예외인지 구분할 수 있도록 에러 코드를 넣어주었습니다.

public Authentication validateToken(HttpServletRequest request, String token) throws BadRequestException {
    String exception = "exception";
    try {
        Jwts.parser().setSigningKey(accessKey.getBytes()).parseClaimsJws(jwtHandler.untype(token)); 
        return getAuthentication(token); //loadByUsername으로 반환된 CustomUserDetails을 Authentication 형식인 CustomAuthenticationToken으로 반환
    } catch (BadRequestException e) {
        request.setAttribute(exception, "토큰을 입력해주세요. (앞에 'Bearer ' 포함)");
    } catch (MalformedJwtException | SignatureException | UnsupportedJwtException e) {
        request.setAttribute(exception, "잘못된 토큰입니다."); 
    } catch (ExpiredJwtException e) {
        request.setAttribute(exception, "토큰이 만료되었습니다.");
    } catch (IllegalArgumentException e) {
        request.setAttribute(exception, "토큰을 입력해주세요.");
    }
    return null;
}

위 코드에서 예외가 발생하면 AuthenticationEntryPoint로 갑니다. 그러므로 우리는 CustomAuthenticationEntryPoint를 구현해서 나머지 코드를 작성하면 됩니다.

7. CustomAuthenticationEntryPoint

CustomAuthenticationEntryPoint로 넘어간 뒤에도 동일한 Request가 있으므로 위에서 처리했던 Attribute를 가져와서 응답 값을 설정해주었습니다.

그리고 setResponse()라는 메소드를 이용해서 Response에 원하는 형태의 에러 메세지를 출력할 수 있도록 하였습니다.

public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint{

  private static final ObjectMappermapper= new ObjectMapper();

	@Override
  public void commence(HttpServletRequest request, HttpServletResponse response,  AuthenticationException authException) throws IOException, ServletException {
      String exception = (String) request.getAttribute("exception");
      setResponse(response);
      BasicResponse exceptionDto = new BasicResponse(exception, HttpStatus.FORBIDDEN);
      response.getWriter().print(convertObjectToJson(exceptionDto));
  }

  private void setResponse(HttpServletResponse response) {
      response.setStatus(HttpStatus.FORBIDDEN.value());
      response.setContentType("application/json");
      response.setCharacterEncoding("UTF-8");
  }

  public String convertObjectToJson(Object object) throws JsonProcessingException {
      if (object == null) {
          return null;
      }
      returnmapper.writeValueAsString(object);
  }
}

이렇게 길었던 Spring Security 적용기가 3편으로 마무리 되었습니다.

생각보다 customize 할 수 있는 부분이 많고, Spring Security 자체가 방대한 프레임워크라 이해하고 적용하는데 많은 시간이 걸렸던 것 같습니다.

제가 적용한 방법만이 정답이 아니며, 각자의 상황을 잘 분석해 적용하는 것이 좋을 것 같습니다.

감사합니다.

0개의 댓글