๐ฏ ๋ชฉํ
- ์ธ์ ๋ฐฉ์๊ณผ JWT ๋ฐฉ์์ ์ฐจ์ด์ ๋ช ํํ ์ดํด
- 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 |