๋ฐฐ๋„ˆ ์ด๋ฏธ์ง€

JWT ์ธ์ฆ ๋ฐ ํ† ํฐ ๊ธฐ๋ฐ˜ ์ธ์ฆ ์‹œ์Šคํ…œ ์ •๋ฆฌ

2025. 4. 28. 23:17ยทCS๊ณต๋ถ€/Java & Spring

 

๐ŸŽฏ ๋ชฉํ‘œ

  1. ์„ธ์…˜ ๋ฐฉ์‹๊ณผ JWT ๋ฐฉ์‹์˜ ์ฐจ์ด์  ๋ช…ํ™•ํžˆ ์ดํ•ด
  2. JWT ์ธ์ฆ ๊ตฌ์กฐ์™€ Access Token, Refresh Token ์‚ฌ์šฉ๋ฒ• ์‹ฌ์ธต ํ•™์Šต

โœ… ํ•ต์‹ฌ ๊ฐœ๋… ๋ฐ ์ •์˜

์šฉ์–ด ์„ค๋ช…

์„ธ์…˜ ๋ฐฉ์‹ ์„œ๋ฒ„๊ฐ€ ํด๋ผ์ด์–ธํŠธ๋ณ„๋กœ ์„ธ์…˜ ์ •๋ณด๋ฅผ ์ €์žฅํ•˜๊ณ  ์‹๋ณ„ํ•˜๋Š” ๋ฐฉ์‹
JWT (JSON Web Token) ์ž๊ฐ€ ์ˆ˜์šฉ์ (self-contained) ์ •๋ณด ์ „์†ก ํ† ํฐ, ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ง์ ‘ ์ธ์ฆ ์ •๋ณด๋ฅผ ๋ณด์œ 
Access Token ๋ณดํ˜ธ๋œ ์ž์› ์ ‘๊ทผ์„ ์œ„ํ•œ ๋‹จ๊ธฐ ์‚ฌ์šฉ ํ† ํฐ (์ฃผ๋กœ 15~30๋ถ„)
Refresh Token Access Token ๋งŒ๋ฃŒ ์‹œ ์žฌ๋ฐœ๊ธ‰์„ ์œ„ํ•œ ์žฅ๊ธฐ ์‚ฌ์šฉ ํ† ํฐ (์ฃผ๋กœ 14~30์ผ)
ํ† ํฐ ์ €์žฅ์†Œ Redis, DB ๋“ฑ์—์„œ Refresh Token์„ ์ €์žฅํ•˜๊ณ  ๊ด€๋ฆฌํ•˜๋Š” ์‹œ์Šคํ…œ

๐Ÿ” ์„ธ์…˜ ๋ฐฉ์‹ vs JWT ๋ฐฉ์‹

์„ธ์…˜ ๋ฐฉ์‹

  • ์„œ๋ฒ„๊ฐ€ ์„ธ์…˜ ID๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ํด๋ผ์ด์–ธํŠธ ์ฟ ํ‚ค์— ์ €์žฅ (ex: JSESSIONID)
  • ์„œ๋ฒ„ ๋ฉ”๋ชจ๋ฆฌ๋‚˜ ๋ณ„๋„ DB์— ์„ธ์…˜ ๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ ํ•„์š”
  • ์„œ๋ฒ„ ํ™•์žฅ(Scale-out) ์‹œ ์„ธ์…˜ ํด๋Ÿฌ์Šคํ„ฐ๋ง ๋˜๋Š” Sticky Session ํ•„์š”
  • ์„ธ์…˜ ์œ ์ง€๋ฅผ ์œ„ํ•ด ์„œ๋ฒ„ ์ƒํƒœ(Stateful) ํ•„์š”
  • ์‚ฌ์šฉ์ž ์ •๋ณด์™€ ์ธ์ฆ ์ƒํƒœ๋ฅผ ์„œ๋ฒ„๊ฐ€ ์ง์ ‘ ๊ด€๋ฆฌ

JWT ๋ฐฉ์‹

  • ์„œ๋ฒ„๋Š” ๋กœ๊ทธ์ธ ์‹œ ํ† ํฐ๋งŒ ๋ฐœ๊ธ‰ํ•˜๊ณ  ์ดํ›„ ์ƒํƒœ๋ฅผ ์ €์žฅํ•˜์ง€ ์•Š์Œ (Stateless)
  • ํด๋ผ์ด์–ธํŠธ๊ฐ€ JWT๋ฅผ ์ €์žฅํ•˜๊ณ  ๋งค ์š”์ฒญ๋งˆ๋‹ค Authorization ํ—ค๋”์— ํฌํ•จํ•˜์—ฌ ์ „์†ก
  • ์„œ๋ฒ„๋Š” JWT์˜ ์„œ๋ช…(Signature)๋งŒ ๊ฒ€์ฆํ•˜์—ฌ ์‹ ๋ขฐ์„ฑ ํ™•๋ณด
  • ์„œ๋ฒ„ ํ™•์žฅ์— ์œ ๋ฆฌํ•˜๋ฉฐ, ๋งˆ์ดํฌ๋กœ์„œ๋น„์Šค ์•„ํ‚คํ…์ฒ˜์™€ ์ž˜ ๋งž์Œ
  • ํ† ํฐ ํƒˆ์ทจ ์‹œ ๋งŒ๋ฃŒ๊นŒ์ง€ ์ทจ์†Œ๊ฐ€ ์–ด๋ ค์›Œ ๋ณ„๋„์˜ ๋ฌดํšจํ™” ์ „๋žต ํ•„์š”

๐Ÿ” Access Token + Refresh Token ์ธ์ฆ ํ๋ฆ„

1. ๋กœ๊ทธ์ธ ์„ฑ๊ณต

  • ์„œ๋ฒ„: ์ธ์ฆ ์™„๋ฃŒ ํ›„ Access Token (30๋ถ„), Refresh Token (14์ผ) ๋ฐœ๊ธ‰
  • ํด๋ผ์ด์–ธํŠธ:
    • Access Token์€ ๋ฉ”๋ชจ๋ฆฌ์— ์ €์žฅ ํ›„ Authorization ํ—ค๋”๋กœ ์ „์†ก
    • Refresh Token์€ HttpOnly, Secure ์†์„ฑ์ด ์ ์šฉ๋œ ์ฟ ํ‚ค์— ์ €์žฅ

2. API ์š”์ฒญ

  • ๋ชจ๋“  ๋ณดํ˜ธ๋œ API ์š”์ฒญ ์‹œ Authorization: Bearer {Access Token} ํ—ค๋” ํฌํ•จ

3. Access Token ๋งŒ๋ฃŒ

  • ์„œ๋ฒ„: 401 Unauthorized ๋ฐ˜ํ™˜
  • ํด๋ผ์ด์–ธํŠธ: ์ €์žฅ๋œ Refresh Token์„ ์ด์šฉํ•ด ์ƒˆ Access Token ๋ฐœ๊ธ‰ ์š”์ฒญ
  • ์„œ๋ฒ„: Redis์—์„œ Refresh Token ๊ฒ€์ฆ ํ›„ ์ƒˆ Access Token ์žฌ๋ฐœ๊ธ‰

4. ๋กœ๊ทธ์•„์›ƒ

  • ์„œ๋ฒ„: Redis์—์„œ ํ•ด๋‹น Refresh Token ์‚ญ์ œ
  • ํด๋ผ์ด์–ธํŠธ: ๋ฉ”๋ชจ๋ฆฌ์™€ ์ฟ ํ‚ค์—์„œ ๊ฐ๊ฐ Access Token๊ณผ Refresh Token ์‚ญ์ œ

๐Ÿ› ํ”„๋กœ์ ํŠธ ์ธ์ฆ ์‹œ์Šคํ…œ ์‹ค์ œ ์ฝ”๋“œ ์˜ˆ์‹œ

JwtService.java

@Service
@RequiredArgsConstructor
public class JwtService {
    private final RedisTemplate<String, String> redisTemplate;

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.access-token-validity-in-seconds}")
    private long accessTokenValidity;

    @Value("${jwt.refresh-token-validity-in-seconds}")
    private long refreshTokenValidity;

    public String createAccessToken(Long userId, String email, MemberRole role) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("id", userId);
        claims.put("email", email);
        claims.put("role", role.name());

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + accessTokenValidity * 1000))
                .signWith(getSignKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    public String createRefreshToken(Long userId) {
        String refreshToken = Jwts.builder()
                .setSubject(userId.toString())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + refreshTokenValidity * 1000))
                .signWith(getSignKey(), SignatureAlgorithm.HS256)
                .compact();

        redisTemplate.opsForValue().set(
                "RT:" + userId,
                refreshToken,
                refreshTokenValidity,
                TimeUnit.SECONDS
        );

        return refreshToken;
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(getSignKey())
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public Long getUserIdFromToken(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(getSignKey())
                .build()
                .parseClaimsJws(token)
                .getBody();

        return Long.parseLong(claims.get("id").toString());
    }

    public String refreshAccessToken(String refreshToken) {
        try {
            Claims claims = Jwts.parserBuilder()
                    .setSigningKey(getSignKey())
                    .build()
                    .parseClaimsJws(refreshToken)
                    .getBody();

            Long userId = Long.parseLong(claims.getSubject());

            String storedRefreshToken = redisTemplate.opsForValue().get("RT:" + userId);

            if (storedRefreshToken == null || !storedRefreshToken.equals(refreshToken)) {
                throw new IllegalArgumentException("Invalid refresh token");
            }

            // ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ ๋กœ์ง ํ•„์š” (์˜ˆ: UserRepository)
            User user = userRepository.findById(userId)
                    .orElseThrow(() -> new UsernameNotFoundException("User not found"));

            return createAccessToken(user.getId(), user.getEmail(), user.getRole());

        } catch (Exception e) {
            throw new IllegalArgumentException("Failed to refresh token", e);
        }
    }

    public void logout(Long userId) {
        redisTemplate.delete("RT:" + userId);
    }

    private Key getSignKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

AuthController.java

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
    private final AuthService authService;
    private final JwtService jwtService;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request, HttpServletResponse response) {
        User user = authService.authenticate(request.getEmail(), request.getPassword());

        String accessToken = jwtService.createAccessToken(user.getId(), user.getEmail(), user.getRole());
        String refreshToken = jwtService.createRefreshToken(user.getId());

        Cookie refreshTokenCookie = new Cookie("refresh_token", refreshToken);
        refreshTokenCookie.setHttpOnly(true);
        refreshTokenCookie.setSecure(true);
        refreshTokenCookie.setPath("/");
        refreshTokenCookie.setMaxAge((int) jwtService.getRefreshTokenValidity());
        response.addCookie(refreshTokenCookie);

        Map<String, Object> responseBody = new HashMap<>();
        responseBody.put("accessToken", accessToken);
        responseBody.put("user", UserDto.from(user));

        return ResponseEntity.ok(responseBody);
    }

    @PostMapping("/refresh")
    public ResponseEntity<?> refreshToken(@CookieValue(name = "refresh_token", required = false) String refreshToken) {
        if (refreshToken == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Refresh token is missing");
        }

        try {
            String newAccessToken = jwtService.refreshAccessToken(refreshToken);
            Map<String, String> response = new HashMap<>();
            response.put("accessToken", newAccessToken);
            return ResponseEntity.ok(response);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid refresh token");
        }
    }

    @PostMapping("/logout")
    public ResponseEntity<?> logout(@RequestHeader("Authorization") String authHeader, HttpServletResponse response) {
        String token = authHeader.replace("Bearer ", "");
        Long userId = jwtService.getUserIdFromToken(token);

        jwtService.logout(userId);

        Cookie refreshTokenCookie = new Cookie("refresh_token", null);
        refreshTokenCookie.setMaxAge(0);
        refreshTokenCookie.setPath("/");
        response.addCookie(refreshTokenCookie);

        return ResponseEntity.ok().body("Successfully logged out");
    }
}

RedisConfig.java

@Configuration
@EnableRedisRepositories
public class RedisConfig {
    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration(redisHost, redisPort);
        return new LettuceConnectionFactory(redisConfig);
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}

๐Ÿšง ๋ณด์•ˆ ๊ณ ๋ ค์‚ฌํ•ญ

  • Access Token์€ ๋ฉ”๋ชจ๋ฆฌ์—๋งŒ ์ €์žฅํ•˜์—ฌ XSS ์œ„ํ—˜ ์ตœ์†Œํ™”
  • Refresh Token์€ HttpOnly, Secure ์ฟ ํ‚ค๋กœ ๋ณดํ˜ธ
  • Refresh Token Rotation ๊ธฐ๋ฒ• ์ ์šฉ (์žฌ๋ฐœ๊ธ‰ ์‹œ ์ƒˆ ํ† ํฐ ๋ฐœ๊ธ‰)
  • HTTPS ํ•„์ˆ˜ ์ ์šฉ (Access/Refresh Token ๋ชจ๋‘ ์•”ํ˜ธํ™”๋œ ์ฑ„๋„๋กœ ์ „์†ก)
  • JWT ํด๋ ˆ์ž„ ํ•„์ˆ˜ ๊ฒ€์ฆ (exp, iat, iss ๋“ฑ)

โš ๏ธ ํ”ํ•œ ์ด์Šˆ์™€ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•

์ด์Šˆ ์›์ธ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•

ํ† ํฐ ํƒˆ์ทจ XSS ๊ณต๊ฒฉ์œผ๋กœ ํ† ํฐ ๋…ธ์ถœ HttpOnly ์ฟ ํ‚ค ์‚ฌ์šฉ ๋ฐ CSP(Content Security Policy) ์„ค์ •
CSRF ๊ณต๊ฒฉ ์ฟ ํ‚ค ์ž๋™ ์ „์†ก ์•…์šฉ SameSite=Strict ์„ค์ •, CSRF ํ† ํฐ ์ถ”๊ฐ€
ํ† ํฐ ๋งŒ๋ฃŒ ์ฒ˜๋ฆฌ ๋ณต์žก Access Token ๋งŒ๋ฃŒ ํ›„ ์ž๋™ ์žฌ์š”์ฒญ ํ•„์š” Axios Interceptor๋ฅผ ํ†ตํ•œ ์ž๋™ ๊ฐฑ์‹  ์ฒ˜๋ฆฌ
Refresh Token ํƒˆ์ทจ Refresh Token ๊ด€๋ฆฌ ๋ถ€์ฃผ์˜ Rotation ์ ์šฉ ๋ฐ ์žฌ๋ฐœ๊ธ‰ ์‹œ ๊ต์ฒด ํ•„์ˆ˜

๐Ÿ’ก ํ•™์Šต ์ •๋ฆฌ

  • ์„ธ์…˜ ๋ฐฉ์‹์€ ์„œ๋ฒ„ ์ƒํƒœ ์ €์žฅ ํ•„์š”, JWT๋Š” ์™„์ „ Stateless ์ธ์ฆ ๊ฐ€๋Šฅ
  • Access Token์€ ์งง์€ ์œ ํšจ๊ธฐ๊ฐ„, Refresh Token์€ ๊ธด ์œ ํšจ๊ธฐ๊ฐ„ ์„ค์ •
  • Redis์—๋Š” Refresh Token๋งŒ ์ €์žฅํ•˜์—ฌ ์‹œ์Šคํ…œ ๋ถ€ํ•˜ ์ตœ์†Œํ™”
  • Rotation ์ „๋žต์„ ํ†ตํ•ด Refresh Token ๊ฐฑ์‹  ๋ฐ ๋ณด์•ˆ์„ฑ ํ–ฅ์ƒ
  • ๋‹ค์ธต ๋ณด์•ˆ ์ ์šฉ (XSS, CSRF, HTTPS, ํ† ํฐ ์„œ๋ช… ๊ฒ€์ฆ)์œผ๋กœ ์ธ์ฆ ์‹œ์Šคํ…œ ๊ฐ•ํ™”

 

'CS๊ณต๋ถ€ > Java & Spring' ์นดํ…Œ๊ณ ๋ฆฌ์˜ ๋‹ค๋ฅธ ๊ธ€

collect(Collectors.toList()) vs Stream.toList()  (1) 2025.06.07
๋ฌธ์ž์—ด ํด๋ž˜์Šค ์ •๋ฆฌ: String vs StringBuffer vs StringBuilder  (0) 2025.05.25
์ฆ‰์‹œ ๋กœ๋”ฉ(Eager Loading)๊ณผ ์ง€์—ฐ ๋กœ๋”ฉ(Lazy Loading)  (0) 2025.04.23
Spring Boot์˜ Bean๊ณผ ์–ด๋…ธํ…Œ์ด์…˜  (1) 2025.04.23
MVC ๋ชจ๋ธ์ด๋ž€ ๋ฌด์—‡์ธ๊ฐ€?  (0) 2025.04.23
'CS๊ณต๋ถ€/Java & Spring' ์นดํ…Œ๊ณ ๋ฆฌ์˜ ๋‹ค๋ฅธ ๊ธ€
  • collect(Collectors.toList()) vs Stream.toList()
  • ๋ฌธ์ž์—ด ํด๋ž˜์Šค ์ •๋ฆฌ: String vs StringBuffer vs StringBuilder
  • ์ฆ‰์‹œ ๋กœ๋”ฉ(Eager Loading)๊ณผ ์ง€์—ฐ ๋กœ๋”ฉ(Lazy Loading)
  • Spring Boot์˜ Bean๊ณผ ์–ด๋…ธํ…Œ์ด์…˜
quokkaST
quokkaST
  • quokkaST
    stquokka
    quokkaST
    • ๊ฐœ๋ฐœ์ž (77)
      • n8n (2)
      • CS๊ณต๋ถ€ (46)
        • Java & Spring (15)
        • ์ธํ”„๋ผ (7)
        • ์šด์˜์ฒด์ œ & ์‹œ์Šคํ…œ (9)
        • ๊ธฐํƒ€ CS์ง€์‹ (7)
        • ๋„คํŠธ์›Œํฌ (6)
        • ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค (2)
      • ์•Œ๊ณ ๋ฆฌ์ฆ˜ (16)
      • ํ”„๋กœ์ ํŠธ (8)
        • ๊ฐ์ •&๊ธˆ์œต์ฑ—๋ด‡ (8)
      • ๋ฆฌํŒฉํ† ๋ง (5)
        • horong (5)
  • ๋ธ”๋กœ๊ทธ ๋ฉ”๋‰ด

    • ํ™ˆ
  • ๋งํฌ

  • ๊ณต์ง€์‚ฌํ•ญ

  • ์ธ๊ธฐ ๊ธ€

  • ํƒœ๊ทธ

  • ์ตœ๊ทผ ๋Œ“๊ธ€

  • ์ตœ๊ทผ ๊ธ€

  • hELLOยท Designed By์ •์ƒ์šฐ.v4.10.3
์ƒ๋‹จ์œผ๋กœ

ํ‹ฐ์Šคํ† ๋ฆฌํˆด๋ฐ”