Spring Security + JWT

전홍영·2023년 1월 8일
0

Spring

목록 보기
5/21
post-thumbnail
post-custom-banner

Spring Security

스프링 시큐리티는 스프링 기반 어플리케이션의 보안(인증과 인가)를 담당하는 스프링 하위 프레임워크이다.
보안과 관련해서 체계적으로 많은 옵션들을 제공해주기 때문에 개발자의 입장에서는 하나하나 보안 관련 로직을 작성하지 않아도 된다는 장점이 있다.
스프링 시큐리티는 인증과 권한 등의 많은 기능을 편리하게 적용할 수 있도록 제공하지만 사용법은 결코 쉽지 않다. 공부해야 할 양도 방대하고 접근도 어렵다.

JWT

JWT는 JSON Web Token의 약자로 사용자의 정보를 담아 이를 암호화한 JSON 객체이다. 기존의 Cookie와 Session는 각각 보안 취약점과 stateless 위반의 문제가 있었지만 JWT는 이를 해역하기 위해 등장했다. JWT는 암호화 알고리즘을 통해 디지털로 서명되기 때문에 인증되었고, 신뢰할 수 있는 토큰이다. JWT는 공개키 암호 방식(PKC) 즉 비대칭키 방식이다.

JWT 발급과 인증 과정

JWT는 클라이언트가 인증 요청(로그인 시도)을 하면 서버가 가지고 있는 Secret Key를 통해 Token을 발급한다. 클라이언트는 서버로부터 받은 토큰을 쿠키나 저장소에 저장하고, 서버에 요청을 보낼 때부터 저장해둔 토큰을 같이 전달합니다.
서버는 클라이언트로 전달받은 토큰을 검증(복호화)하고 만약 토큰이 변조되지 않았다는게 검증되면 클라이언트의 요청을 수행한다.
서버는 Refresh Token과 Access Token 두가지를 보내는데 Access Token은 요청에 대한 다양한 정보를 담고 실질적 인증 역할을 하며, Refresh Token은 Access Token의 만료 기간을 조정하는 역할을 합니다.

JWT 구조

JWT는 Header, Payload, Signature 3가지 부분으로 구성된다.

  • Header는 두가지 정보를 가지고 있다. 서명에 사용할 암호화 알고리즘 관련 정보와 토큰의 유형을 알리는 정보가 담겨 있다.
  • Payload는 Claims라는 정보들을 가지고 있다. 클레임은 사용자와 같은 엔티티들이나 추가 정보들의 상태를 뜻한다. 클레임에는 Registered, Public, Private 3가지가 있다.
    • Registered 클레임은 미리 정의된 클레임 세트이다. JWT 발급자, 토큰 제목, 토큰 만료 시간, 토큰 활성 날짜, 토큰 발급 시간, JWT 고유 식별자 등, 다양한 정보가 포함되는 클레임이다.
    • Public 클레임은 사용자가 정의할 수 있는 클레임이다.사용자가 직접 정의하기 때문에 이 클레임에서 충돌이 발생할 수도 있다. 따라서 Public 클레임의 이름은 보통 URI로 정의해야 한다.
    • Private 클레임도 사용자가 정의할 수 있는 클레임이다. 통신 당사자 간의 정보를 공유하기 위해 만들어진 클레임으로 공개되어도 상관없는 '사용자를 특정할 수 있는' 정보를 담는다.
  • Signature는 토큰에 포함된 정보들이 통신 과정에서 변경되지 않았다는 것을 검증하는 부분이다. Signature는 Header와 Payload를 Base64-URL로 인코딩한 문자열들과 Secret Key를 포함해 HMAC SHA256 암호화 알고리즘으로 암호화합니다.

JWT 예시

빨간색은 Header, 보락색은 Payload, 파란색은 Signature이다.

Spring Security + JWT 동작과정

Spring Security 5.7 버전이 되면서 SecurityConfig 클래스를 WebConfiguration을 상속하는 대신 Bean으로 등록하는 방식으로 변경되었다. 따서 나는 5.7버전으로 프로젝트를 진행했기 때문에 Bean으로 등록하였다.

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
	/*
    Redis를 이용하여 Logout 로직을 구현할 것이기 때문에 RedisTemplate을 DI하였다.
    */
    private final TokenProvider tokenProvider;
    private final RedisTemplate redisTemplate;


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .csrf().disable()//jwt는 서버에 인증정보를 보관하지 않기 때문에 csrf 코드를 작성할 필요가 없기 때문에 disable한다. 
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)//jwt는 stateless하게 설정
                .and()
                .authorizeRequests()
                .antMatchers("/signup", "/login","/").permitAll()
                .antMatchers("/log-out").authenticated()
                .antMatchers("/luckybags/**").authenticated()
                .antMatchers("/members/**").authenticated()//어떤 요청인지에 따라 인증여부를 구분하는 설정
                .and()
                .addFilterBefore(new JwtFilter(tokenProvider, redisTemplate), UsernamePasswordAuthenticationFilter.class)
                .cors();//cors코드 적용과 JwtFilter 적용 설정
        return        http.build();
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();//패스워드 인코더를 빈으로 등록한다.
    }

}

0. JwtTokenProvider, JwtFilter

Jwt를 사용하기 위해 구현해야 할 것은 크게 TokenProvider와 JwtFilter이다. TokenProvider는 jwt 발급하고 검증하는 로직을 구현한 클래스이고 JwtFilter는 토큰을 읽어들여 정상 토큰이면 Security Context에 저장하는 클래스이다.

@Component
public class TokenProvider {
    private final Key key;//Secret key
    private final long validityTime;//유효 시간



    public TokenProvider(
            @Value("${jwt.secret}") String secretKey,
            @Value("${jwt.token-validity-in-milliseconds}") long validityTime) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.validityTime = validityTime;
    }

    public TokenDTO createToken(Authentication authentication) {//토큰 생성
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(",")); //authentication 객체에서 권한을 반환한다.
       
       //만료시간과 현재 시간 
        long now = System.currentTimeMillis();
        Date tokenExpiredTime = new Date(now + validityTime);
		
        //accessToken 생성
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())//id
                .claim("auth", authorities)
                .setExpiration(tokenExpiredTime)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
		//refrehToken 생성
        String refreshToken = Jwts.builder()
                .setExpiration(tokenExpiredTime)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
        //Toekn 반환
        return TokenDTO.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshTokenExpirationTime(tokenExpiredTime)
                .refreshToken(refreshToken)
                .build();
    }
    
	//토큰으로부터 클레임을 만들고, 이를 통해 User 객체를 생성하여 Authentication 객체를 반환
    public Authentication getAuthentication(String accessToken) {
        Claims claims = parseClaims(accessToken);//accessToken에서 클레임을 가져온다.
        if (claims.get("auth") == null) {
            throw new RuntimeException("권한 정보가 담겨있지 않은 토큰입니다.");
        }

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());//사용자의 Role과 권한을 조회하여 SimpleAuthority 목록을 authorities에 세팅한다.

        UserDetails principal = new User(claims.getSubject(), "", authorities);//계정정보를 담은 User 객체를 생성한다.
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);//Authentication 객체를 반환한다.
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
	
    //토큰 검증 
    public boolean validateToken(String token) {
        try {
            log.info("토큰 검증");
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException exception) {
            log.info("Invalid JWT Token", exception);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.",e);
        }
        return false;
    }
	//토큰 만료시키기
    public Long getExpiration(String accessToken) {
        Date expiration = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody().getExpiration();
        long now = (new Date()).getTime();
        return (expiration.getTime() - now);
    }
}

JWT 토큰을 발행하고, Payload에 들어간 클레임을 통해 User객체를 생성하여 Authentication 객체를 반환하고 토큰을 검증해주는 TokenProvider를 구현했다.

@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends GenericFilter {
    private final TokenProvider tokenProvider;
    private final RedisTemplate redisTemplate;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        log.info("인증 시도");
        String token = resolveToken((HttpServletRequest) request);//http로부터 bearer 토큰을 가져온다.

        if (Strings.hasText(token) && tokenProvider.validateToken(token)) {
            //토큰이 존재한다면 검증해서 유효한 토큰이라면
            String isLogout = (String) redisTemplate.opsForValue().get(token);
            //redis에 해당 accessToken logout 여부 확인 후 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
            if (ObjectUtils.isEmpty(isLogout)) {
                Authentication authentication = tokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        chain.doFilter(request, response);//다음 필터 체인 실행
    }
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

JWTFilter는 사용자의 요청이 들어오면 Servlet req, res 객체가 생성되어 넘어오게 되는데, req 객체에서 JWT Token을 추출하고, token을 통해 정상 토큰인지 확인후 토큰을 통해 생성한 Authenticaiton 객체를 Security Context에 저장해준다.
Authentication 객체는 Spring Security에서 한 유저의 인증 정보를 가지고 있는 객체인데, Spring Security는 사용자의 Pricipal과 Credetial 정보를 Authentication 객체에 담아 생성한 후 보관한다.

1. 로그인 정보를 담아 서버에 인증을 요청한다.

doFilter(requets: HttpServletRequest, response: HttpResponse)

브라우저의 로그인화면에서 아이디와 비밀번호를 입력하고 서버에 로그인 요청을 보내면 스프링 시큐리티에서 Chain형태로 구성된 Filter들의 doFilter메소드들이 순서에따라 호출되어 각각의 역할 로직들이 수행되게 된다.
일반적으로 ID, PASSWORD 기반의 인증이라고 할때 가장 처음 Application Filter라는 필터 뭉치에 도달하고 그 필터 뭉치 중 Authentication Filters라는 필터 뭉치에 다시 도달한다.

2. 구현한 JwtFilter를 통해 JWT 토큰 인증 방식이 작동한다.

attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication

form 기반 인증을 처리하는 필터인 UsernamePasswordAuthenticationFilter에 도착하게 되기 전에 구현한 JwtFilter가 작동한다. 여기서 http request를 통해 토큰이 있는지 없는지 있다면 유효한 토큰인지 검사하고 있다면 Security Context 저장해놓는다.

3. UsernamePasswordAuthenticationToken 객체를 생성한다.

UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDTO.getMemberId(), loginDTO.getMemberPassword());

UsernamePasswordAuthenticationToken 객체를 request로 넘어온 ID와 PASSWORD를 인자로 하여 생성한다.

4. AuthenticationManager가 적절한 AuthenticationProvider를 찾는다.

Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
  • AuthenticationManager는 인터페이스이며 구현체는 ProviderManager이다. ProviderManager는 실제 인증 처리하는 로직이 포함된 AuthenticationProvider 인터페이스의 구현체들 중에 설정된 인증 처리방식의 구현체를 찾아 실행한다.

4. 실제 인증처리하는 AuthenticationProvider의 인증처리 메소드를 호출한다.

authenticate(authRequest) : Authentication
  • 일반적으로 클라이언트에서 아이디 비번을 받아 인증하는 방식을 사용한다면 AuthenticationProvider 인터페이스의 구현체 AbstractUserDetailsAuthenticationProvider 추상클래스를 호출하게 되고 실제 상속한 클래스의 DaoAuthenticationProvider에서 인증 처리를 하게 된다.

5. 인증제공자는 UserDetailService를 호출하여 사용자를 가져온다.

@Service
@RequiredArgsConstructor
public class jwtUserDetailService implements UserDetailsService {
    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return memberRepository.findByMemberId(username)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다."));
    }

    private UserDetails createUserDetails(Member member) {
        return User.builder()
                .username(member.getMemberId())
                .password(member.getPassword())
                .roles(member.getRoles().toArray(new String[0]))
                .build();
    }
}

개발자는 UserDetailsService 인터페이스를 구현(jwtUSerDetailService)해야 한다. UserDetailsService의 구현체에는 일반적으로 회원정보가 DB에 있다고 한다면 사용자의 이름(ID)로 DB를 조회하여 비밀번호가 일치하는지 확인하여 인증을 처리한다. 인증이 완료되면 UsernamePasswordAuthenticationToken에 회원정보를 담아 리턴한다. UsernaemPasswordAuthenticationToken은 Authentication을 구현한 클래스로 Authentication 객체로 반환한다.

6. 이렇게 인증완료된 Authentication을 가지고 JWT를 생성하여 반환한다.

return tokenProvider.createToken(authentication);

[참조]

스프링시큐리티 기본개념과 동작구조의 이해
Spring Security 시큐리티 동작 원리 이해하기 - 1
Spring Security 시큐리티 동작 원리 이해하기 - 2
Spring Security - 인증 절차 인터페이스 구현 (1) UserDetailsService, UserDetails
Spring Security - JWT

profile
Don't watch the clock; do what it does. Keep going.
post-custom-banner

0개의 댓글