1. JWT + Redis + OAuth 정리
우선 기존에는 세션 + OAuth로 구현하였는데 JWT + OAuth + Redis로 변경해보려고 한다.
JWT(JSON Web Token)는 인증과 정보를 안전하게 주고 받기 위한 토큰 기반 인증 방식이다.
클라이언트와 서버 간의 인증을 stateless 방식으로 처리할 수 있게 설계되었다.
인증 & 인가 동작 방식
1) 사용자 로그인 > OAuth 인증 서버 요청
클라이언트가 OAuth 제공자(현재는 Google)에 로그인 요청을 보내고 OAuth 서버가 사용자를 인증한다.
2) OAuth 서버에서 Access Token 발급
인증이 완료되면 OAuth 서버는 Access Token을 클라이언트에 반환하는데 이 Access Token을 이용해서 JWT 형식을 생성하려고 한다.
Access Token 자체는 JWT가 따로 사용할 건데 사용자 정보를 사용하려고 한다.
3) 서버에서 JWT 검증
API 서버는 JWT를 검증하여 요청을 처리하는데 OAuth 서버에 매번 확인할 필요 없이 자체적으로 서명을 검증하고 사용자 정보를 추출할 수 있다.
JWT를 사용하는 이유
일반적으로 OAuth Access Token은 서버에 저장하고 매번 OAuth 서버에 검증 요청을 보내야하는데 JWT는 자체적으로 서명을 검증할 수 있기 때문에 별도의 검증 요청이 필요 없다.
JWT Token 보관 위치
1) Access Token
Access Token은 클라이언트 측에 저장하려고 한다.
쿠키 또는 메모리에 저장하려고 한다.
LocalStorage에 저장하는 것은 XSS 공격에 취약하여 보안상 위험하다고 한다.
그래서 Secure HTTPOnly Cookie에 저장하려고 한다.
2) Refresh Token
Refresh Token은 서버에서 관리하려고 한다.
Refresh Token이 탈취되면 무제한으로 Access Token을 무한히 발급 받을 수 있어서 Redis에서 관리하려고 한다.
Redis 사용 이유
Refresh Token을 DB 대신 Redis에 저장하는 이유는 속도와 효율성 때문이다.
상대적으로 Redis는 메모리 기반이므로 DB에 비해서 읽기 쓰기 속도가 매우 빠르고 TTL(만료 시간 설정) 지원한다.
또한 로그아웃한 사용자의 token을 블랙리스트로 지정하여 만료 시간 전에도 강제 무효화가 가능하다.
2. 코드 구현
코드 순서는 OAuth2에서 로그인을 하면 SecurityConfig에서 customOAuth2UserService를 실행하고 이후에는 /auth/signup으로 redirect한다.
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final CustomOAuth2UserService customOAuth2UserService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/auth/signup", "/css/**", "/js/**", "/health").permitAll()
.requestMatchers("/oauth2/**").permitAll()
.anyRequest().authenticated()
)
.csrf(AbstractHttpConfigurer::disable)
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
.defaultSuccessUrl("/auth/signup", true)
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login")
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("JSESSIONID", "SESSION")
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
CustomOAuth2UserService에서는 jwtTokenProvider를 통해서 accessToken을 만들고 refreshToken을 만드는데 나중에 프론트 코드를 작성하고 Authorization에서 accessToken을 사용하려고 한다.
refreshToken은 Redis에서 관리하도록 설정하였다.
user = existingUser.orElseGet(() -> User.builder()
.email(email)
.name(name)
.provider(provider)
.providerUid(providerUid)
.isRegistered(false)
.build());
String accessToken = jwtTokenProvider.createAccessToken(email);
if (accessToken == null) {
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
String refreshToken = jwtTokenProvider.createRefreshToken(email);
if (refreshToken == null) {
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
JwtTokenProvider에는 JWT를 활용해서 accessToken과 refreshToken을 생성하는 로직을 작성하였다.
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
private final StringRedisTemplate redisTemplate;
private Key getSigningKey() {
return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String createAccessToken(String email) {
// 30분
long accessTokenValidity = 1000 * 60 * 30;
return Jwts.builder()
.setSubject(email)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + accessTokenValidity))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public String createRefreshToken(String email) {
// 7일
long refreshTokenValidity = 1000 * 60 * 60 * 24 * 7;
String refreshToken = Jwts.builder()
.setSubject(email)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + refreshTokenValidity))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
// key: email, value: refreshToken
redisTemplate.opsForValue().set(email, refreshToken, refreshTokenValidity, TimeUnit.MILLISECONDS);
return refreshToken;
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public void deleteRefreshToken(String email) {
redisTemplate.delete(email);
}
public String getEmailFromToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
}
application.properties에 jwt.secret을 추가해주어야 잘 작동한다.
'dev > 프로젝트' 카테고리의 다른 글
[프로젝트] 프론트엔드(React & Redux) 적용 (8) | 2025.02.07 |
---|---|
[프로젝트] 프론트엔드(React & Redux) (5) | 2025.02.07 |
[프로젝트] 테스트 코드 적용 (5) | 2025.01.30 |
[프로젝트] QueryDSL 적용 (2) | 2025.01.29 |
[프로젝트] README 적용 (4) | 2025.01.28 |