반응형
 이 글은 내가 우아한테크캠프에서 배웠던 내용들을 복습하기 위해 진행하는 토이프로젝트(https://github.com/minseokLim/woowahan-tech-camp-review)를 중심으로 작성되었다.

 

1. JWT란?

 JWT(Json Web Token)은 사용자 인증에 필요한 정보를 Json 포맷으로 구성한 토큰이다. 이때 이 값은 암호화되어 있는데, 개인키를 보유한 서버는 이를 통해 토큰의 유효성을 검증할 수 있다. 

 JWT는 각 구성요소가 점(.)으로 구분되어 있으며 Header, Payload, Signature로 구성된다.

 1) Header : 토큰의 타입이나, 서명에 어떠한 암호화 알고리즘이 사용되었는지가 저장된다. 위 그림에서는 HS512 알고리즘이 사용되었다.

 2) Payload : 인증에 필요한 사용자에 대한 실질적인 정보가 저장되는 곳이다. 여기에서 주의사항은 Payload에는 민감한 정보를 담지 않는 것이다. header와 payload의 경우 Base64로 인코딩되어있을 뿐, 암호화되어있는 것이 아니기 때문에, 탈취되었을 경우 누구나 값을 확인할 수 있다. key-value 형태로 저장되며 표준 스펙상 key는 3글자로 되어있다. Payload에 담기는 일반적인 key값들은 아래와 같다.

 - iss(issuer): 토큰 발급자

 - sub(subject): 토큰 제목. 사용자에 대한 식별자가 된다.

 - aud(audience): 토큰 대상자

 - exp(expiration time): 토큰 만료 시간

 - nbf(not before): 토큰 활성화 시간(이 날짜 이전의 토큰은 활성화되지 않음을 보장)

 - iat(issued at): 토큰 발급 시간

 - jti(JWT id): JWT 토큰 식별자(issuer가 여러 명일 때, 이를 구분하기 위한 값)

 3) Signature : 암호화된 서명 값. 토큰의 유효성을 검증하기 위해 사용된다. 구조는 아래 그림에서 확인할 수 있듯이, header와 payload를 합친 값을 개인키로 암호화한 상태이다.

 

2. JWT의 장점과 단점

장점

 1) 세션과 같이 별도의 저장소가 필요하지 않다. 

 2) 별도의 저장소가 없기 때문에 Stateless하고 확장성이 뛰어나다.

 3) 세션 인증과 비교했을 때 성능면에서도 유리하다. (세션의 경우 매번 저장소에 접근하여 사용자 정보를 확인해야함)

 4) 서명을 통한 보안성을 갖춘다.

단점

 1) 사용자 인증에 필요한 정보를 base64 인코딩을 통해 전달하므로, 세션과 비교했을 때 데이터 전달량이 많다(세션의 경우 Session ID만 전달하면 됨). 네트워크 부하가 발생할 수 있다. -> 사용자 인증에 필요한 정보만 포함시키도록 함

 2) Payload는 암호화되지 않기 때문에, 민감한 정보를 저장할 수 없다. -> 민감한 정보를 저장하지 않도록 주의

 3) 토큰이 탈취당하면 만료될 때까지 대체가 불가능하다. -> 액세스 토큰의 만료 시간을 짧게 가져가고, 대신 사용성을 위해 별도의 리프레쉬 토큰을 만들고 이는 별도의 저장소에 저장한다.

 

3. 디펜던시 설정

 인증 기능을 구현하기 위해 아래와 같이 build.gradle 파일 내의 dependencies를 추가해준다.

JWT 관련 라이브러리를 어떤 걸 써야할지 많은 고민을 했었다. JWT관련 구글링을 하면 대부분 아래의 의존성을 추가하여 구현을 하였다.

implementation 'io.jsonwebtoken:jjwt:0.9.1'

헌데 이걸 그냥 쓰기엔 뭔가 마음에 들지 않는 부분들이 많았다.

 

1) Spring boot의 dependency management를 활용한 버전 관리가 불가능하다.

  Spring boot는 각 버전별로 호환성이 검증된 라이브러리들의 버전들을 아래와 같이 제공해준다. 따라서 gradle의 build.gradle이나 maven의 pom.xml에 의존성 추가 시, Spring boot에서 호환성을 검증한 라이브러리라면 버전을 명시할 필요가 없다. 이는 향후 전체 프로젝트의 버전을 업그레이드하고자할 때 Spring boot의 버전만 변경하면 나머지는 알아서 변경된다는 것을 의미한다. 헌데 위에 라이브러리는 Spring에서 버전을 제공하지 않는, 딱히 대중적이지 않은(?) 라이브러리로 보였다.(그렇다고 하기엔 구글링하면 전부 저것만 나옴...)

2) 버전이 0으로 시작한다. 정식 릴리즈가 아니란 뜻일텐데... 가장 최근 업데이트도 2018년인 것으로 보아 더 이상의 추가적인 지원은 없을 것 같았다.

 

 그렇다면 대체 어떤 라이브러리를 써야한단 말인가...? 이전에 MSA를 공부할 때 Spring cloud에서 JWT를 썼던 기억이 있었다. 그래서 스프링에서 제공하는 라이브러리를 검색하기 시작했고 org.springframework.security:spring-security-jwt 를 찾았다!! 근데 어째서인지 Spring boot BOM에 버전이 등록되어있지 않았고, 버전을 명시해서 다운을 받아봤다.

죄다 Deprecated네... 뭘 쓰라는 거지-_-;

 Jwt 라이브러리의 가장 핵심이 되는 Jwt 인터페이스가 Deprecated 되어있었다. 구글링을 하다보니 스프링 공식 홈페이지에서 아래와 같은 내용을 찾을 수 있었다.

 어떤 이유에서인지는 알 수 없으나 Spring Security OAuth 프로젝트는 중단되었고, Spring Security는 이제 Authorization Server에 대한 서포트를 하지 않는다는 내용이다. JWT를 만드는 주체는 Authorization Server가 될테니 비슷한 이유로 Deprecated된 게 아닐까 추측해봤다. 그나저나 그럼 대체 뭘 쓰라는 것인가...?

 

 결국 처음으로 돌아와 io.jsonwebtoken:jjwt 를 쓰기로 했다. 나름 다른 프로젝트에서도 많이 쓰이고 있고, 다들 나와 같은 이유로 많은 고민을 하다가 결국 이 라이브러리를 쓰는 건가 싶었다.

나름 669곳에서 쓰고 있음. 페이스북도 쓰더라...

 io.jsonwebtoken:jjwt을 바로 쓰지 않고 굳이 3개의 디펜던시로 나눠서 의존성을 추가한 이유는, 얘네들은 마지막 릴리즈가 2020년 6월이다. 그렇다면 왜 io.jsonwebtoken:jjwt에는 반영하지 않았을까? 필요한 것만 가져다쓰라는 의도일까? 낸들 알겠는가...;ㅋ

 

4. 인증 구현

@EnableWebSecurity // (1)
@EnableGlobalMethodSecurity(prePostEnabled = true) // (2)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final AuthenticationExceptionHandler authenticationExceptionHandler;
    private final JwtFilter jwtFilter;

    public SecurityConfig(final AuthenticationExceptionHandler exceptionHandler, final JwtFilter jwtFilter) {
        this.authenticationExceptionHandler = exceptionHandler;
        this.jwtFilter = jwtFilter;
    }

    @Override
    protected void configure(final HttpSecurity http) throws Exception {
        http
            .csrf()
            .disable() // (3)

            .headers()
            .frameOptions()
            .sameOrigin() // (4)

            .and()
            .cors() // (5)

            .and()
            .logout()
            .disable() // (6)
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // (7)

            .and()
            .authorizeRequests()
            .antMatchers(HttpMethod.POST, "/login").permitAll()
            .anyRequest().authenticated()

            .and()
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) // (8)
            .exceptionHandling()
            .authenticationEntryPoint(authenticationExceptionHandler)
            .accessDeniedHandler(authenticationExceptionHandler);
    }

    @Override
    public void configure(WebSecurity web) {
        web
            .ignoring()
            .antMatchers("/h2-console/**", "/favicon.ico");
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

(1) 스프링 시큐리티 설정을 활성화한다.

(2) 메서드 단위로 시큐리티 설정을 추가하고 싶을 때 사용한다.

(3) 백엔드 서버이므로 csrf 설정이 불필요하다.

(4) h2-console을 사용하기 위해 설정한다.

(5) 향후 corsfilter를 사용하기 위해 선언한다.

(6) 스프링 시큐리티에서 디폴트로 설정된 logout을 비활성화한다.

(7) 세션과 비교했을 때 JWT의 장점은 별도의 저장소를 사용하지 않는 Stateless(무상태성)하다는 것이다.

(8) 스프링 시큐리티에서 요청에 대한 인증 여부를 검사하는 필터(UsernamePasswordAuthenticationFilter) 이전에, 별도로 작성한 JwtFilter를 추가한다.

 

@Component
public class JwtFilter implements Filter {
    private static final String BEARER_TYPE = "Bearer";

    private final JwtTokenParser tokenParser;

    public JwtFilter(final JwtTokenParser tokenParser) {
        this.tokenParser = tokenParser;
    }

    @Override
    public void doFilter(
        final ServletRequest request,
        final ServletResponse response,
        final FilterChain chain
    ) throws ServletException, IOException {
        final HttpServletRequest httpServletRequest = (HttpServletRequest)request;
        final String token = resolveAccessToken(httpServletRequest);

    // (1)
        if (tokenParser.validateAccessToken(token)) {
            final Authentication authentication = tokenParser.extractAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }

    // (2)
    private String resolveAccessToken(final HttpServletRequest request) {
        final String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (StringUtils.hasText(bearerToken) && bearerToken.toLowerCase().startsWith(BEARER_TYPE.toLowerCase())) {
            return bearerToken.substring(BEARER_TYPE.length()).trim();
        }
        return "";
    }
}

(1) Request로부터 전달된 토큰에 대한 유효성 검사를 진행한 후, 유효한 토큰일 경우 토큰에 있는 정보를 바탕으로 Authentication을 생성한다.

(2) Request의 Authorization 헤더 값을 가져와 거기에서 토큰 값을 추출한다.

 

@Component
public class JwtTokenParser {
    private final JwtParser jwtParser;

    public JwtTokenParser(@Value("${custom.jwt.secret-key}") final String secretKey) {
        final Key key = Keys.hmacShaKeyFor(secretKey.getBytes());
        this.jwtParser = Jwts.parserBuilder().setSigningKey(key).build();
    }

    // (1)
    public Authentication extractAuthentication(final String accessToken) {
        try {
            final Claims claims = jwtParser.parseClaimsJws(accessToken).getBody();
            final Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(AUTHORITY_DELIMITER))
                    .map(Role::of)
                    .collect(Collectors.toList());
            final UserDetails principal = User.builder()
                .username(claims.getSubject())
                .password("N/A")
                .authorities(authorities)
                .build();

            return new UsernamePasswordAuthenticationToken(principal, accessToken, authorities);
        } catch (final JwtException | IllegalArgumentException | NullPointerException exception) {
            throw new BadCredentialsException(exception.getMessage());
        }
    }

    // (2)
    public boolean validateAccessToken(final String token) {
        return validateToken(token, false);
    }

    private boolean validateToken(final String token, final boolean isRefreshToken) {
        try {
            final Claims claims = jwtParser.parseClaimsJws(token).getBody();
            final boolean additionalCondition = (claims.getSubject() != null) ^ isRefreshToken;
            return !claims.getExpiration().before(new Date()) && additionalCondition;
        } catch (final JwtException | IllegalArgumentException exception) {
            return false;
        }
    }
}

(1) 토큰 값으로부터 사용자 정보를 추출하여 Authentication을 생성한다.

(2) 액세스 토큰의 유효성을 검증한다.

 

5. 로그인 구현

@Service
public class LoginService {
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final TokenService tokenService;

    public LoginService(
        final AuthenticationManagerBuilder authenticationManagerBuilder,
        final TokenService tokenService
    ) {
        this.authenticationManagerBuilder = authenticationManagerBuilder;
        this.tokenService = tokenService;
    }

    public TokenResponse login(final LoginRequest loginRequest) {
        // (1) 
        final AuthenticationManager authenticationManager = authenticationManagerBuilder.getObject();
        final Authentication authentication = authenticationManager.authenticate(loginRequest.toAuthentication());

        // (2)
        final TokenResponse tokenResponse = tokenService.createTokenResponse(authentication);

        return tokenResponse;
    }
}

@Getter
public class LoginRequest {
    @NotBlank(message = "아이디를 입력해주세요.")
    private String loginId;

    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password;

    public Authentication toAuthentication() {
        return new UsernamePasswordAuthenticationToken(loginId, password);
    }
}

(1) 별도의 AuthenticationManager나 AuthenticationProvider를 정의하지 않는다면, 스프링에서 제공하는 디폴트 빈을 사용하게 된다. 디폴트 빈을 적절히 사용하기 위해선 UserDetailsService와 PasswordEncoder를 빈으로 생성해야한다. PasswordEncoder의 경우 아까 SecurityConfig에서 빈으로 생성하였다.

(2) 토큰을 생성하고 이를 반환한다.

 

@Service
@Transactional(readOnly = true)
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    public CustomUserDetailsService(final UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException {
        return userRepository.findByLoginIdValue(username)
            .map(this::toUserDetails)
            .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
    }

    private UserDetails toUserDetails(final User user) {
        return builder()
            .username(user.getId().toString())
            .password(user.getPassword())
            .authorities(user.getUserRoles())
            .disabled(user.isDeleted())
            .build();
    }
}

 

아래에 리프레쉬 토큰 관련 코드도 나오는데, 이에 대해서는 뒤에서 다룰 것이다.

@Service
public class TokenService {
    private final JwtTokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final long refreshTokenValidityInMilliseconds;

    public TokenService(
        final JwtTokenProvider tokenProvider,
        final RefreshTokenRepository refreshTokenRepository,
        @Value("${custom.jwt.refresh-token-validity-in-milliseconds}") final long refreshTokenValidityInMilliseconds
    ) {
        this.tokenProvider = tokenProvider;
        this.refreshTokenRepository = refreshTokenRepository;
        this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds;
    }

    public TokenResponse createTokenResponse(final Authentication authentication) {
        final String accessToken = tokenProvider.createAccessToken(authentication);
        final String refreshToken = tokenProvider.createRefreshToken();

        return new TokenResponse(accessToken, refreshToken);
    }
}
@Component
public class JwtTokenProvider {
    static final String AUTHORITIES_KEY = "auth";
    static final String AUTHORITY_DELIMITER = ",";

    private final Key secretKey;
    private final JwtProperty jwtProperty;

    @Autowired
    public JwtTokenProvider(final JwtProperty jwtProperty) {
        this.jwtProperty = jwtProperty;
        this.secretKey = Keys.hmacShaKeyFor(jwtProperty.getSecretKey().getBytes());
    }

    JwtTokenProvider(
        final String secretKey,
        final long accessTokenValidityInMilliseconds,
        final long refreshTokenValidityInMilliseconds
    ) {
        this(new JwtProperty(secretKey, accessTokenValidityInMilliseconds, refreshTokenValidityInMilliseconds));
    }

    public String createAccessToken(final Authentication authentication) {
        final Date now = new Date();
        final Date validity = new Date(now.getTime() + jwtProperty.getAccessTokenValidityInMilliseconds());

        return Jwts.builder()
            .setSubject(authentication.getName())
            .claim(AUTHORITIES_KEY, toString(authentication.getAuthorities()))
            .signWith(secretKey, SignatureAlgorithm.HS512)
            .setIssuedAt(now)
            .setExpiration(validity)
            .compact();
    }

    public String createRefreshToken() {
        final Date now = new Date();
        final Date validity = new Date(now.getTime() + jwtProperty.getRefreshTokenValidityInMilliseconds());

        return Jwts.builder()
            .signWith(secretKey, SignatureAlgorithm.HS512)
            .setIssuedAt(now)
            .setExpiration(validity)
            .compact();
    }

    private static String toString(final Collection<? extends GrantedAuthority> grantedAuthorities) {
        return grantedAuthorities.stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(AUTHORITY_DELIMITER));
    }
}

 

6. 리프레쉬 토큰 구현

 토큰 방식 인증의 장점은 Stateless하다는 것이다. 이로 인해 별도의 저장소를 갖지 않아도 되고 확장에 용이하다. 하지만 이는 단점으로도 작용하는데, 토큰이 탈취되었을 경우 대응법이 마땅히 없다는 것이다(세션의 경우, 저장소에서 탈취당한 값을 삭제하거나 최악의 경우 초기화할 수 있다.). 

 이러한 단점을 보완하기 위해 리프레쉬 토큰을 사용한다. 액세스 토큰의 유효기간을 짧게 두고, 대신 리프레쉬 토큰의 유효기간을 길게 둔다. 액세스 토큰이 만료되었을 때 리프레쉬 토큰을 사용하여 액세스 토큰을 재발급 받는다.

 유효기간이 긴 리프레쉬 토큰이 탈취되었을 경우를 대비하여 리프레쉬 토큰은 별도의 저장소(이 프로젝트에선 Redis를 사용하였다)에 저장한다. 이는 Stateless라는 장점에 위배되는 것이지만, 보안을 위해서 나에게 이것말고 더 좋은 방법이 떠오르지 않았다. 그래도 세션을 사용할 땐 매 요청마다 저장소에 접근해야하지만, 토큰을 사용한다면 리프레쉬 토큰을 통해 액세스토큰을 재발급 받는 경우에만 접근하면 되므로 나름대로 토큰 인증의 장점을 취했다고 볼 수 있다.

 탈취되었을 경우를 대비한 또 하나의 장치는 'Refresh Token Rotation'이다. 액세스 토큰이 재발급될 때마다 리프레쉬 토큰을 재생성한다. 이후에 액세스 토큰 재발급을 위해 들어온 Request 내의 리프레쉬 토큰이 가장 최근에 만들어진 것이 아니라면, 리프레쉬 토큰이 탈취당했다고 보고 저장소에서 해당 사용자의 리프레쉬 토큰을 무효화한다.

 

@Service
public class AuthService {
    private final JwtTokenParser tokenParser;
    private final RefreshTokenValidator refreshTokenValidator;
    private final TokenService tokenService;

    public AuthService(
        final JwtTokenParser tokenParser,
        final RefreshTokenValidator refreshTokenValidator,
        final TokenService tokenService
    ) {
        this.tokenParser = tokenParser;
        this.refreshTokenValidator = refreshTokenValidator;
        this.tokenService = tokenService;
    }

    public TokenResponse refreshToken(final TokenRequest tokenRequest) {
        final String accessToken = tokenRequest.getAccessToken();
        final String refreshToken = tokenRequest.getRefreshToken();

	// (1)
        final Authentication authentication = tokenParser.extractAuthentication(accessToken);
	// (2)
        validateRefreshToken(authentication.getName(), refreshToken);

        final TokenResponse tokenResponse = tokenService.createTokenResponse(authentication);
	// (3)
        tokenService.saveRefreshToken(authentication.getName(), tokenResponse.getRefreshToken());

        return tokenResponse;
    }

    private void validateRefreshToken(final String userId, final String refreshToken) {
        if (!refreshTokenValidator.validate(userId, refreshToken)) {
            // (4)
            tokenService.invalidateRefreshToken(userId);
            throw new BadCredentialsException("리프레쉬 토큰이 유효하지 않습니다.");
        }
    }
}

(1) 이미 만료된 액세스 토큰이지만, 사용자 정보를 가지고 있기 때문에 이를 이용하여 Authentication을 생성한다.

(2) 리프레쉬 토큰에 대한 유효성 검사를 진행한다.

(3) 다시 생성된 리프레쉬 토큰값을 저장한다.

(4) 유효하지 않은 리프레시 토큰이 들어왔을 경우, 토큰 값이 탈취되었다고 보고 해당 유저의 리프레쉬 토큰을 삭제한다.

 

@Component
public class RefreshTokenValidator {
    private final JwtTokenParser tokenParser;
    private final RefreshTokenRepository refreshTokenRepository;

    public RefreshTokenValidator(
        final JwtTokenParser tokenParser,
        final RefreshTokenRepository refreshTokenRepository
    ) {
        this.tokenParser = tokenParser;
        this.refreshTokenRepository = refreshTokenRepository;
    }

    public boolean validate(final String userId, final String refreshToken) {
        final RefreshToken savedRefreshToken = getSavedRefreshToken(userId);
        return tokenParser.validateRefreshToken(refreshToken) && savedRefreshToken.hasSameValue(refreshToken);
    }

    private RefreshToken getSavedRefreshToken(final String userId) {
        return refreshTokenRepository.findById(userId)
            .orElseThrow(() -> new NotFoundException("리프레쉬 토큰을 찾을 수 없습니다."));
    }
}

 

7. 마무리

 지금까지 JWT를 이용해 인증 기능을 구현해봤다. 근데 만약 사용자의 권한 변경이 즉각적으로 반영되어야하는 요구사항이 있다면 어떻게 해야할까? JWT의 장점은 Stateless인데, 이 장점 때문에 즉각적인 반영은 불가능하다. 장점이 단점으로 작용하는 순간이다. 만약 최대한 빠른 반영을 위해 액세스 토큰의 유효기간을 짧게 가져간다면, 리프레쉬 토큰 재발급을 위해 저장소에 자주 접근하게 될 것이므로 이 또한 올바른 해결책 같아 보이진 않는다.

 나라면 이러한 요구사항엔 그냥 세션을 쓰겠다. 세션을 Redis에 저장하여 확장성도 확보할 수 있다. 이처럼 항상 옳은 방법은 없다. 요구사항에 맞춰 가장 바람직한 방법을 취하면 된다.

 

 

 

※ 참고

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt/dashboard

 

[무료] Spring Boot JWT Tutorial - 인프런 | 강의

Spring Boot, Spring Security, JWT를 이용한 튜토리얼을 통해 인증과 인가에 대한 기초 지식을 쉽고 빠르게 학습할 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com

https://brunch.co.kr/@jinyoungchoi95/1

 

JWT(Json Web Token) 알아가기

jwt가 생겨난 이유부터 jwt의 실제 구조까지 | 사실 꾸준히 작성하고 싶었던 글이지만 JWT를 제대로 개념을 정리하고 구현을 진행해본 적이 없었는데 리얼월드 프로젝트를 진행하면서 JWT에 대한

brunch.co.kr

https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/

 

What Are Refresh Tokens and How to Use Them Securely

Learn about refresh tokens and how they help developers balance security and usability in their applications.

auth0.com

 

반응형

+ Recent posts