Book Study/스프링 부트 핵심 가이드

13. 서비스의 인증과 권한 부여 (2)

정진킴 2023. 12. 12. 08:59

13.4 JWT

  • JWT(Json Web Token) 은 URI로 이용할 수 있는 문자열로만 구성되어 있고, 디지털 서명이 적용돼 있어 신뢰할 수 있다.

13.4.1 JWT의 구조

  • JWT는 점(.)으로 구분된 아래의 세 부분으로 구성된다.
    • 헤더(Header)
    • 내용(Content)
    • 서명(Signature)

헤더

  • JWT 헤더는 검증과 관련된 내용을 담고 있다.
    • alg : 해싱 알고리즘을 지정한다.
      • 해싱 알고리즘은 보통 SHA256 또는 RSA를 사용한다.
      • 토큰을 검증할 때 사용되는 서명 부분에 사용한다.
    • typ : 토큰의 타입을 지정한다.
    • Base64Url 형식으로 인코딩돼 사용한다.
{
    "alg": H256, // HMAC SHA256 알고리즘
    "typ": "JWT"
}

 

내용

  • 토큰을 담는 정보를 포함한다. 이곳에 포함된 속성들은 클레임(Claim)이라 하며, 크게 세 가지로 분류한다.
    • 등록된 클레임(Registered Claims)
    • 공개 클레임(Public Claims)
      • 키 값을 마음대로 정의할 수 있다. 다만 충돌이 발생하지 않을 이름으로 설정해야 한다.
    • 비공개 클레임(Private Claims)
      • 통신 간에 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임을 의미
  • 등록된 클레임은 필수는 아니지만 토큰에 대한 정보를 담기 위해 이미 이름이 정해져 있는 클레임을 뜻한다.
    • iss: JWT의 발급자(Issure) 주체를 나타낸다. iss의 값은 문자열이나 URI를 포함하는 대소문자를 구분하는 문자열이다.
    • sub: JWT의 제목(Subject)
    • aud: JWT의 수신인(Audience) 입니다. JWT를 처리하려는 각 주체는 해당 값으로 자신을 식별해야 합니다.
      • 요청을 처리하는 주체가 'aud' 값으로 자신을 식별하지 않으면 JWT는 거부됩니다.
    • exp: JWT의 만료시간(Expiration)입니다. 시간은 NumericDate 형식(ex) 1480849147370) 으로 지정해야 합니다.
    • nbf: 'Not Before'를 의미
    • iat: JWT가 발급된 시간(Issured at)
    • jti: JWT의 식별자(JWT ID). 주로 중복 처리를 방지하기 위해 사용
{
    "sub": "wikibooks payload",
    "exp": "1602076408",
    "userId": "wikibooks",
    "username": "flature"
}
  • Base64Url 형식으로 인코딩 되어 사용

서명

  • 인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성
    • 예를 들어, HMAC SHA256 알고리즘을 사용해서 서명을 생성

13.4.2 JWT 디버거 사용하기

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

 

13.5 스프링 시큐리티와 JWT 적용

  • 스프링 시큐리티는 기본적으로 UsernamePasswordAuthenticationFilter를 통해 인증을 수행하도록 구성되어 있다.
    • 해당 필터에서 인증이 실패하면 로그인 폼이 포함된 화면으로 전달하지만 해당 교제는 화면이 없다.
    • 대안으로 JWT를 사용하는 인증 필터를 구현하고 usernamePasswordAuthenticationFilter 앞에 인증 필터를 배치해서 인증 주체를 변경한다.
public interface UserDetails extends Serializable {
	Collection<? extends GrantedAuthority> getAuthorities(); //계정이 가지고 있는 권한 목록을 리턴
    
    String getPassword(); // 계정의 비밀번호를 리턴
    
    String getUsername(); // 계정의 이름을 리턴. 일반적으로 ID를 리턴
    
    boolean isAccountNonExpired(); // 계정 만료여부 리턴, true - 만료되지 않음
    
    boolean isAccountNonLocked(); // 계정 잠금여부 리턴, true - 잠기지 않음
    
    boolean isCredentialsNonExpired(); // 비밀번호 만료여부 리턴, true - 만료되지 않음
    
    boolean isEnabled(); // 계정 활성화 여부 리턴, true - 활성화 상태를 의미
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    User getById(String uid);
}
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailService {
    private final Logger LOGGER = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
    
    private final UserRepository userRepository;
    
    @Override
    public UserDetails loadUserByUsername(String username) {
        LOGGER.info("[loadUserByUsername] loadUserByUsername 수행. username : {}", username);
        return userRepository.getByUid(username);
    }
}
  • UserDetailService를 구현하도록 설정되어 있고, loadUserByUsername() 메서드를 구현하도록 정의되어 있다.
  • UserDetails는 스프링 시큐리티에서 제공하는 개념으로, UserDetails의 username은 각 사용자를 구분할 수 있는 ID를 의미한다.
public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
  • username은 각 사용자를 구분할 수 있는 ID를 의미
  • username을 가지고 UserDetails 객체를 리턴하게끔 정의되어 있다.
@Getter
@Setter
@NoArgsConstructor
@AllArgsContructor
@Entity
@Builder
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    
    ...
}
  • UserDetail의 구현체로 User 엔티티를 생성했기 때문에 User 객체를 리턴하도록 구현

13.5.2 JwtTokenProvider 구현

  • JWT 토큰을 생성하는 JwtTokenProvider를 생성
@Component // 애플리케이션 가동되면 Bean으로 자동 주입
@RequredArgsConstructor
public class JwtTokenProvider {
    private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
    private final UserDetailsService userDetailsService;
    
    @Value("${springboot.jwt.secret}")
    private String secretKey = "secretKey"; // application.properties 에 값이 없는 경우 설정
    private final long tokenValidMillisecond = 1000L * 60 * 60;
    
    // Bean 으로 자동 주입 시 자동으로 실행
    @PostConstruct // 해당 객체가 빈 객체로 주입된 이후 수행되는 메서드 
    protected void init() {
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
        secretKey = Base64.getEncoder()
            .encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
        
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
    }
    
    public String createToken(String userUid, List<String> roles) {
        LOGGER.info("[createToken] 토큰 생성 시작");
        Claims claims = Jwts.claims().setSubject(userUid);
        claims.put("roles", roles); // 사용자 권한 확인을 위해 별도로 추가
        Date now = new Date();
        
        String token = Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
            .signWith(SignatureAlogrithm.HS256, secretKey)
            .compat();
            
        LOGGER.info("[createToken] 토큰 생성 완료");
        return token;
    }
    
    /**
     * 필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성하는
     * 역할을 한다.
     * Authentication 을 구현하는 편한 방법은 UsernamePasswordAuthenticationToken을 사용한다.
     */
    public Authentication getAuthentication(String token) {
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");
        UserDetails userDetails = userDetailsService
            .loadUserByUsername(this.getUserName(token));
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName: {}",
            userDetails.getUsername());
            
        return new UsernamePasswordAuthenticationToken(userDetails,
            "",
            userDetails.getAuthorities());
    }
    
    public String getUsername(String token) {
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
        String info = Jwts.parser()
            .setSigningKey(secretKey)
            .parseClaimsJws(token)
            .getBody()
            .getSubject();
            
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}", info);
        
        return info;
    }
    
    public String resolveToken(HttpServletRequest request) {
        LOGGER.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
        return request.getHeader("X-AUTH-TOKEN");
    }
    
    public boolean validateToken(String token) {
        LOGGER.info("[validToken] 토큰 유효 체크 시작");
        try {
            Jws<Claims> claims = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimseJws(token);
                
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
            return false;
        }
    }
}
  • UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받고 있는데 AbstractAuthenticationToken은 Authentication 인터페이스의 구현체입니다.
  • 이 토큰 클래스를 사용하려면 초기화를 위한 UserDetails가 필요하다.

13.5.3 JwtAuthenticationFilter 구현

  • JwtAuthenticationFilter는 JWT 토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스
@Configuration
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private final JwtTokenProvider jwtTokenProvider;
    
    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }
    
    @Override
    public void doFilterInternal(ServletRequest servletRequest, 
        HttpServletResponse servletResponse,
        FilterChain filterChain) throws IOException, ServletException {
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) servletRequest);
        LOGGER.info("[doFilterInternal] token 값 추출 완료, token : {}", token);
        
        LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            LOGGER.info("[doFilterInternal] token 값 유혀성 체크 완료");
        }
        
        // doFilter를 기준으로 서블릿이 실행되기 전에 실행
        filterChain.doFilter(servletRequest, servletResponse);
        // doFilter를 기준으로 서블릿이 실행된 후 실행
    }
}

 

  • GenericFilterBean은 기존 필터에서 가져올 수 없는 스프링의 설정 정보를 가져올 수 있게 확장된 추상 클래스이다.
    • 다만 서블릿은 사용자의 요청을 받으면 서블릿을 생성해서 메모리에 저장해두고 동일한 클라이언트의 요청을 받으면 재활용하는 구조여서 GenericFilterBean을 상속받으면 RequestDispathcer에 의해 다른 서블릿으로 디스패치되면서 필터가 두 번 실행되는 현상이 발생할 수 있다.
  • 이 같은 문제를 해결하기 위해 등장한 것이 OncePerRequestFilter이며, 이 클래스 역시 GenericFilterBean을 상속받고 있다.
    • 다만 이 클래스를 상속받아 구현한 필터는 매 요청마다 한 번만 실행되게끔 구현된다.

13.5.4 SecurityConfiguration 구현

  • 스프링 시큐리티와 관련된 설정하는 대표적인 방법은 WebSecurityConfigureAdapter를 상속받는 Configuration 클래스를 구현
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    private final JwtTokenProvider jwtTokenProvider;
    
    @Autowired
    public SecurityConfiguration(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }
    
    @Override
    protected void configure(HttpSecurity httpSecurity) thorws Exception {
        // 모든 설정은 HttpSecurity 에 설정된다.
        httpSecurity.httpBasic().disable() // UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화.
            .csrf().disable()  // CSRF 보안 비활성화 (브라우저 사용이 아닌 경우)
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션을 사용하지 않는 설정
            .and()
            .authorizeRequests() // 요청에 대한 권한 체크
            .antMatchers("/sing-api/sign-in", "sign-api/sign-up", "/sign-api/exception") // 해당 경로 허용
            .permitAll()
            .antMachers(HttpMethod.GET, "/product/**") // /product로 시작하는 경로의 GET 요청 모두 허용
            .permitAll()
            .antMachers("**exception**") // 'exception' 단어가 들어간 경로 모두 허용
            .permitAll()
            .andRequest().hasRole("ADMIN") // ADMIN 인증 권한 가진 사용자 허용
            .and()
            .exceptionHandling().accessDeniedHandler(new CustomAuthenticationEntryPoint()) // 권한을 확인하는 과정에서 통과하지 못하는 예외가 발생할 경우 예외를 전달
            .and()
            .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint()) // 인증 과정에서 예외가 발생할 경우 예외를 전달
            .and()
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider).
                UsernamePasswordAuthenticationFilter.class); // UsernamePasswordAuthenticationFilter 앞에 정의하여 해당 클래스는 자동 통과됨
    }
    
    /** 
     * WebSecurity는 HttpSecurity 앞단에 적용되며, 전체적으로 스프링 시큐리티의 영향권 밖에 있다.
     * 인증과 인가 모두 적용되기 전에 동작하는 설정
     * 인증과 인가가 적용되지 않는 리소스 접근에 대해서만 사용
     * ignore() 를 사용하여 예외 추가
     */
    @Override
    public void configure(WebSecurity webSecurity) {
    	webSecurity.ignoring().antMatchers("v2/api-docs", "/swagger-resources/**",
            "/swagger-ui.html", "./webjars/**", "/swagger/**", "/sign-api/exception");
    }
}
  • HttpSecurity 파라미터를 받은 configure() 메서드 부터 보자
    • HttpSecurity를 통해 진행되고 대표적인 기능은 아래와 같다.
      1. 리소스 접근 권한 설정
      2. 인증 실패 시 발생하는 예외 처리
      3. 인증 로직 커스터마이징
      4. csrf, cors 등의 스프링 시큐리티 설정

13.5.5 커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현

  1. AccessDeniedHandler 인터페이스 작성
    1. handle() 메서드를 오버라이딩해서 구현
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    private final Logger = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
    
    @Override
    public void handle(HttpServleRequest request, HttpSevletResponse response,
        AccessDeniedException exception) throws IOException {
        LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
        response.sendRedirect("/sign-api/exception");
    }
}
  • AccessDeniedException은 액세스 권한이 없는 리소스에 접근할 경우 발생하는 예외
    • 해당 예외를 처리하기 위해 AccessDeniedHandler 인터페이스가 사용되며, SecurityConfiguration에도 exceptionHandling() 메서드를 통해 추가
    • 파라미터로 HttpServletRequest, HttpSevletResponse, AccessDeniedException을 파라미터로 가져온다.

2. AuthenticationEntryPoint 인터페이스 작성

  1. commence() 메서드를 오버라이딩해서 코드를 구현합니다.
  2. HttpServletRequest, HttpServletResponse, AuthenticationException을 매개변수로 받고, 예외처리를 위해 리다이렉트가 아니라 직접 Response를 생성해서 클라이언트에게 응답하는 방식으로 구현
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    private final Logger LOGGER = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class);
    
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
        AuthenticationExeception ex) throws IOException {
         ObjectMapper objectMapper = new ObjectMapper();
         LOGGER.info("[commence] 인증 실패로 response.sendError 발생");
         
         EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
         entryPointErrorResponse.setMsg("인증이 실패하였습니다.");
         
         response.setStatus(401);
         response.setContentType("application/json");
         response.setCharacterEncoding("UTF-8");
         response.getWriter().wrtie(objectMapper.writeValueAsString(entryPointErrorResponse));
    }
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
    private String msg;
}