[JWT] JWT 정리 1차본

김태현·2023년 10월 24일

flow
1. 사용자 인증 및 토큰 발급
2. 토큰의 저장 및 관리
3. 인증 및 인가 필터
4. 토큰 재발행
5. 서비스간의 보안 통신

코드 구성 -> JwtUtils, WebSecurity, AppConfig, RedisConfig, AuthorizationHeaderFilter, CustomAuthFilter, AuthController


# 1. 사용자 인증 및 토큰 발급 로그인 시도시 사용자의 자격 검증이 필요하다. 이 과정에서 JwtUtils클래스를 이용한다. 자격 증명이 유효하면 JwtUtils는 사용자의 정보(이메일)를 기반을 JWT 생성한다. 이 토큰은 access token, refresh token으로 구분된다.

두 토큰을 생성하는 코드를 살펴보자. JwtUtils 클래스 부분이다.

 public String createAccessToken(MemberDto memberDto) {
            Claims claims = Jwts.claims();
            claims.put("email", memberDto.getEmail());
    //        claims.put("nickname", memberDto.getNickname());
            Date now = new Date();
            return Jwts.builder()
                    .setClaims(claims)
                    .setIssuedAt(now)
                    .setExpiration(new Date(now.getTime() + accessTokenTime))
                    .signWith(SignatureAlgorithm.HS256, secretKey)
                    .compact();
        }

        public String createRefreshToken(MemberDto memberDto) { // 새로 만들기 전에 기존 refreshToken 지우고 만들기
            Claims claims = Jwts.claims();
            claims.put("email", memberDto.getEmail());
    //        claims.put("nickname", memberDto.getNickname());
            Date now = new Date();
            return Jwts.builder()
                    .setClaims(claims)
                    .setIssuedAt(now)
                    .setExpiration(new Date(now.getTime() + refreshTokenTime))
                    .signWith(SignatureAlgorithm.HS256, secretKey)
                    .compact();
        }

access token은 짧은 유효기간을 가지고 사용자가 시스템의 리소스에 접근할 떄 사용된다. refresh token은 더 긴 유효기간을 가지며 access token이 만료되었을 때 새로은 access token을 받기 위해 사용된다. 토큰들은 HTTP 응답을 통해 클라이언트에 반환되며 클라이언트는 이후의 요청에서 이 토큰들을 사용하여 자신을 인증한다.



2. 사용자 인증 및 토큰 발급

발급된 refresh token은 redis와 같은 데이터 스토어에 저장된다. 이는 updateRefreshToken 에서 수행된다.

해당 부분의 코드를 살펴보자. JwtUtils 클래스 부분이다.

        public void updateRefreshToken(MemberDto memberDto, String refreshToken) { // 만든 refreshToken redis에 저장하는 함수
            // 사용자의 이메일과 refreshToken을 Redis에 저장, refreshToken의 유효 시간도 함께 설정
            stringRedisTemplate.opsForValue().set(memberDto.getEmail(), refreshToken, refreshTokenTime, TimeUnit.MILLISECONDS);
        }

토큰 탈취시 서버측에서 토큰 무효화가 가능하며 토큰 관리 로직은 JwtUtils클래스에서 수행된다. JwtUtils클래스에서는 토큰의 생성, 검증, 만료확인, redis DB와의 상호작용을 담당한다.

토큰 생성의 경우 1번의 경우에서 살펴보았다.


Redis와의 상호작용 코드는 다음과 같다.

        public void updateRefreshToken(MemberDto memberDto, String refreshToken) { // 만든 refreshToken redis에 저장하는 함수
            // 사용자의 이메일과 refreshToken을 Redis에 저장, refreshToken의 유효 시간도 함께 설정
            stringRedisTemplate.opsForValue().set(memberDto.getEmail(), refreshToken, refreshTokenTime, TimeUnit.MILLISECONDS);
        }

        public String getRefreshToken(String email) { // 사용자 구글 이메일(로그인 이메일)로 refreshToken 조회하는 함수
            return stringRedisTemplate.opsForValue().get(email);
        }
        
            public void deleteRefreshToken(String email) { // 사용자 구글 이메일(로그인 이메일)로 refreshToken 삭제하는 함수
        if (getRefreshToken(email) != null) {
            stringRedisTemplate.delete(email);
        }
    }

토큰 검증 코드의 경우 다음과 같다. 주석처리부분 추후 재작성 예정


    //    토큰 유효성 검증
    //    => apigateway-service로 옮기기

    //    public boolean validateToken(String token) {
    //        if(!StringUtils.hasText(token)) {
    //            throw new RuntimeException();
    //        }
    //        if (isLogout(token)) {
    //            throw new RuntimeException();
    //        }
    //        try {
    //            Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
    //            return true;
    //        } catch (SignatureException | MalformedJwtException e) {
    //            throw new RuntimeException(e.getMessage());
    //        } catch (ExpiredJwtException e) {
    //            throw new RuntimeException(e.getMessage());
    //        }
    //    }

토큰 만료확인의 경우 다음과 같다.

    public Long getExpiration(String token) {
        Date expiration = getClaims(token).getExpiration();
        return expiration.getTime() - new Date().getTime();
    }

토큰에서 사용자 정보 추출은 다음과 같다.

    public String getEmailFromToken(String token) {
        return (String) getClaims(token).get("email");
    }

로그아웃 혹은 토큰 무효화 처리는 다음과 같다.

    public void setBlackList(String accessToken) { // 로그아웃 시 redis에 만료된 accessToken이라고 저장하는 함수
        Long expiration = getExpiration(accessToken);
        stringRedisTemplate.opsForValue().set(accessToken, "logout", expiration, TimeUnit.MILLISECONDS);
    }



3. 인증 및 인가 필터

AuthController를 통해 인증(Authentication)을 처리하며 사용자는 시스템에 로그인 할 때 자신의 자격증명을 제공해야한다. 이 정보를 바탕으로 시스템은 사용자를 식별하고 이후에 사용될 Jwt 토큰을 발급한다. 인가(Authorization)의 경우 사용자가 인증을 받은 후 수행하려는 작업에 필요한 적절한 권한을 가지고 있는지 확인하는 단계로 이 과정에서 CustonAuthFilter과 AutorizationHeaderFilter와 같은 필터가 사용된다. 해당 필터들은 들어오는 요청의 HTTP 헤더들을 조사하여 Authorization헤더에 포함된 Jwt 토큰이 유효한지 검사한다.

Jwt토큰은 토큰의 발행자, 토큰의 만료시간 등의 정보를 가지고 유효한 토큰의 경우 filter를 통과하고 서비스의 다음 계층으로 전달되어 사용자의 요청을 처리할수 있다. 만약 유효하지 않거나 만료된 토큰의 경우 오류메시지(주로 401 error)와 함께 요청이 중단된다.

AuthContorller 코드를 세부적으로 살펴보자.

아래 메소드는 사용자의 로그인을 처리하며 사용자로부터 이메일과 비밀번호를 받아들이며 해당정보를 기반으로 인증을 시도한다. authenticationManager.authenticate()메소드를 통해 자격증명을 인증하고 유효한 자격증명이 제공되면 Authentication객체가 반환된다. 객체가 성공적으로 인증 되었다면 사용자의 상세정보(DTO)를 가져와 Jwt 토큰을 생성한다. 생성되는 토큰의 종류는 이전에 자세히 살펴보았기 때문에 생략한다. refresh token의 경우 추후 access token을 새로 받기 위해 redis와 같은 저장소에 저장된다. 마지막으로 클라이언트에 모든 토큰을 담은 응답이 반환된다.

    @PostMapping("/login")
    public ResponseEntity<?> authenticateUser(@RequestBody LoginRequest loginRequest) {
        // 1. 사용자 인증 요청 검증
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.getEmail(),
                        loginRequest.getPassword()
                )
        );

        // 2. 인증 정보 설정
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 3. JWT 토큰 생성
        MemberDto member = (MemberDto) authentication.getPrincipal(); // Assuming 'MemberDto' is your user model class
        String accessToken = jwtUtils.createAccessToken(member);
        String refreshToken = jwtUtils.createRefreshToken(member);

        // 4. redis에 refresh token 저장
        jwtUtils.updateRefreshToken(member, refreshToken);

        // 5. 토큰 반환
        Map<String, String> tokens = new HashMap<>();
        tokens.put("accessToken", accessToken);
        tokens.put("refreshToken", refreshToken);

        return ResponseEntity.ok(tokens);
    }

다음으로 filter 코드들을 살펴보자.

먼저 AuthorizationHeaderFilter는 들어오는 요청에서 Authorization헤더를 찾아 Jwt토큰을 검증한다. 헤더가 존재하지 않거나 토큰이 유효하지 않을 경우 요청이 거부된다. Jwt 토큰의 유효성 검사부분과 만료검사부분의 상세 원리는 추후 분석해 본다. 모든 검사가 통과된 후 필터 체인을 계속해서 진행한다.(요청이 서비스로 전달된다는 소리)

@Component
@Slf4j
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {

    Environment env;

    public AuthorizationHeaderFilter(Environment env) {
        super(Config.class);
        this.env = env;
    }

    public static class Config {

    }

    @Override
    public GatewayFilter apply(AuthorizationHeaderFilter.Config config) {
        return ((exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();

            if(!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
                return onError(exchange, "No authorization header", HttpStatus.UNAUTHORIZED);
            }

            String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
            String jwt = authorizationHeader.replace("Bearer", "");

            if(!isJwtValid(jwt)) {
                return onError(exchange, "JWT token is not valid", HttpStatus.UNAUTHORIZED);
            }

            if (isJwtExpired(jwt)) {
                return onError(exchange, "JWT token has expired", HttpStatus.UNAUTHORIZED);
            }

            return chain.filter(exchange);
        });
    }

    private boolean isJwtValid(String jwt) {
        boolean returnValue = true;
        String subject = null;

        try {
            Key secretKey = Keys.hmacShaKeyFor(env.getProperty("token.secret").getBytes(StandardCharsets.UTF_8));
            JwtParserBuilder jwtParserBuilder = Jwts.parserBuilder();
            JwtParserBuilder jwtParserBuilder1 = jwtParserBuilder.setSigningKey(secretKey);
            subject = jwtParserBuilder1
                    .build()
                    .parseClaimsJws(jwt).getBody()
                    .getSubject();
        } catch (Exception ex) {
            returnValue = false;
        }

        if(subject == null || subject.isEmpty()) {
            returnValue = false;
        }

        return returnValue;
    }

    private boolean isJwtExpired(String jwt) {
        boolean isExpired = false;

        try {
            Key secretKey = Keys.hmacShaKeyFor(env.getProperty("token.secret").getBytes(StandardCharsets.UTF_8));
            JwtParserBuilder jwtParserBuilder = Jwts.parserBuilder();
            JwtParserBuilder jwtParserBuilder1 = jwtParserBuilder.setSigningKey(secretKey);
            Date expiration = jwtParserBuilder1
                    .build()
                    .parseClaimsJws(jwt).getBody()
                    .getExpiration();

            // 현재 시간과 토큰의 만료 시간 비교
            Date currentTime = new Date();
            if (expiration != null && expiration.before(currentTime)) {
                isExpired = true;
            }
        } catch (Exception ex) {
            isExpired = true;
        }

        return isExpired;
    }

    private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(httpStatus);

        log.error(err);
        return response.setComplete();
    }
}

다음으로 CustomAuthFilter이다. 현재 별다른 기능이 없지만 추후 확장된 보안계층이나 인가로직을 구현할 예정이다.
@Component
public class CustomAuthFilter implements GatewayFilterFactory {

    @Override
    public GatewayFilter apply(Object config) {
        return (exchange, chain) -> {
            return chain.filter(exchange);
        };
    }

    @Override
    public String name() {
        return "CustomLoginFilter";
    }
}



4. 토큰 재발행

JwtUtils에서 토큰의 생성과 검증에 관련된 여러 메소드가 존재하고 AuthController에서 클라이언트의 로그인 요청을 처리하고 토큰 재발행을 할 수 있는 로직을 앞서 살펴보았다.



5. 서비스간 보안 통신

토큰 재발행과 마찬가지로 앞서 설명했던 부분에 모두 포함되었다.

0개의 댓글