
RESTful API와 같이 상태를 유지하지 않는 아키텍처에서 클라이언트와 서버 간의 인증 상태를 효율적으로 관리하는 것이 매우 중요합니다.
이때 널리 사용되는 인증 방식 중 하나가 JWT입니다.
JWT는 가볍고 자체적으로 필요한 정보를 포함하고 있어, 세션 관리가 필요 없는 환경에서 유리합니다.
이를 통해 인증 상태를 클라이언트가 보관하고, 서버는 각 요청에서 JWT를 검증하여 인증을 처리할 수 있습니다.
이번 글에서는 Spring Security와 JWT를 활용하여 인증 시스템을 구축하는 방법을 정리해보겠습니다.
1. JWT란?
JWT (JSON Web Token)는 JSON 기반의 토큰으로, 클라이언트와 서버 간에 인증 및 정보 교환에 사용됩니다.
JWT는 다음과 같은 특징을 가지고 있습니다:
- 세션을 사용하지 않고 인증 상태를 클라이언트 측에서 유지합니다.
- 토큰 안에 필요한 정보(사용자 ID, 권한 등)가 포함되어 있습니다.
- 서명된 토큰을 사용하여 위변조를 방지합니다.
JWT는 세 가지 주요 부분으로 구성됩니다.
<Header>.<Payload>.<Signature>
- Header: 토큰의 타입(JWT)과 해싱 알고리즘(HS256 등)을 포함.
- Payload: 토큰에 담길 데이터(사용자 정보, 만료 시간 등).
- Signature: Header와 Payload를 비밀키로 서명하여 생성.
JWT의 장점
- 서버의 상태 정보를 유지할 필요 없이 클라이언트 측에서 인증 정보를 보관할 수 있습니다.
- 확장성이 좋아 마이크로서비스와 같이 분산된 시스템에서 효율적입니다.
JWT의 단점
- 클라이언트에 저장되기 때문에 토큰이 탈취되면 악용될 수 있으므로 보안 관리가 필요합니다.
- 크기가 커질수록 네트워크 대역폭을 더 소모할 수 있습니다.
2. JWT 동작 원리
- 사용자 로그인 요청
- 클라이언트가 서버에 사용자 인증 정보를 전달합니다.
- 서버에서 JWT 생성
- 인증 성공 시, 서버는 사용자 정보를 바탕으로 JWT를 생성하고 클라이언트에게 반환합니다.
- 클라이언트에서 JWT 저장
- 클라이언트는 받은 JWT를 로컬 저장소 또는 쿠키에 저장합니다.
- 클라이언트의 인증 요청
- 클라이언트는 요청 헤더에 JWT를 포함시켜 서버에 인증 요청을 보냅니다.
- 서버에서 JWT 검증
- 서버는 받은 JWT의 서명을 검증하여 요청의 유효성을 확인합니다.
- 응답 반환
- 인증이 성공하면 서버는 요청된 자원 또는 데이터를 반환합니다.
JWT의 동작 과정에서 보안을 고려하여 아래와 같이 설계해야 합니다.
- 토큰 탈취: HTTPS를 사용하여 전송 중 토큰이 노출되지 않도록 보호해야 합니다.
- 토큰 만료: 만료 시간을 설정하여 오래된 토큰의 악용을 방지합니다.
3. Security Config 설정
Spring Security와 JWT를 통합하기 위해 SecurityConfig를 작성해야 합니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import com.project.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
@Configuration
@EnableWebSecurity //시큐리티 활성화, 웹 보안 설정 구성에 사용
@RequiredArgsConstructor //생성자 주입
public class SecurityConfig{
//JWT 인증 필터 가져오기
private final JwtAuthenticationFilter jwtAuthenticationFilter;
//인증 매니저
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
//비밀번호 암호화
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
//Security 필터 체인 설정
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
//교차 사이트 요청 위조 비활성화
http.csrf((csrf) -> csrf.disable())
//교차 출처 리소스 공유 비활성화
.cors(Customizer.withDefaults())
//세션 관리 정책 없음 -> 스프링 시큐리티가 생성하거나 기존 것을 사용하지 않도록 함
//JWT와 같은 토큰 방식을 사용할 때 필요한 설정
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
//요청 권한 설정 (현재는 모든 링크를 열어두었음)
.authorizeHttpRequests(auth -> auth.requestMatchers("*").permitAll().anyRequest().authenticated())
//JWT 필터 추가
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
4. JwtAuthenticationFilter 설정
JwtAuthenticationFilter는 요청에서 JWT를 검증하고,
유효한 경우에 인증 정보를 SecurityContextHolder에 설정하여
Spring Security의 인증 상태를 유지하도록 돕는 필터입니다.
import java.io.IOException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
// JwtTokenProvider는 토큰 생성, 검증, 사용자 정보를 추출하는 역할을 수행
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 요청 헤더에서 JWT 토큰 추출
String accessToken = getJwtFromRequest(request);
if (accessToken != null) { // 토큰이 존재할 경우
if (jwtTokenProvider.validateToken(accessToken)) {
// 액세스 토큰이 유효하면 인증 설정
setAuthentication(accessToken, request);
} else {
// 액세스 토큰이 만료되었거나 유효하지 않다면 리프레시 토큰 처리
handleRefreshToken(request, response);
}
}
// 다음 필터로 요청을 전달
filterChain.doFilter(request, response);
}
// 리프레시 토큰 처리
private void handleRefreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 쿠키에서 리프레시 토큰을 추출
String refreshToken = getRefreshTokenFromCookies(request);
if (refreshToken != null && jwtTokenProvider.validateToken(refreshToken)) {
// 리프레시 토큰이 유효하면 사용자 정보를 기반으로 새 액세스 토큰 생성
String userSeq = jwtTokenProvider.getUserSeqFromToken(refreshToken);
String newAccessToken = jwtTokenProvider.generateToken(userSeq, JwtTokenProvider.TokenType.ACCESS);
// 새 액세스 토큰을 응답 헤더에 추가
response.setHeader("Authorization", "Bearer " + newAccessToken);
// 새 액세스 토큰으로 인증 설정
setAuthentication(newAccessToken, request);
} else {
// 리프레시 토큰이 유효하지 않으면 예외 발생
throw new IllegalArgumentException("Invalid or missing refresh token.");
}
}
// 쿠키에서 리프레시 토큰 추출
private String getRefreshTokenFromCookies(HttpServletRequest request) {
// 요청에 쿠키가 포함되어 있는지 확인
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
// "refreshToken"이라는 이름의 쿠키를 찾음
if ("refreshToken".equals(cookie.getName())) {
return cookie.getValue(); // 해당 쿠키의 값을 반환
}
}
}
return null; // 리프레시 토큰이 없으면 null 반환
}
// 인증 정보를 설정하는 메서드
private void setAuthentication(String token, HttpServletRequest request) {
// 토큰에서 사용자 식별 정보(userSeq)를 추출
String userSeq = jwtTokenProvider.getUserSeqFromToken(token);
// UsernamePasswordAuthenticationToken을 생성하여 인증 객체 생성
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userSeq, null);
// 요청의 추가 정보를 설정
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// SecurityContextHolder에 인증 객체를 설정하여 현재 요청을 인증 상태로 만듦
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 요청 헤더에서 JWT 토큰 추출
private String getJwtFromRequest(HttpServletRequest request) {
// "Authorization" 헤더 값을 가져옴
String bearerToken = request.getHeader("Authorization");
// "Bearer "로 시작하면 토큰 값만 반환
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null; // 토큰이 없거나 형식이 잘못되었으면 null 반환
}
}
5. JwtTokenProvider 설정
JwtTokenProvider에는 총 4개의 메서드가 있습니다.
- generateSecretKey : 비밀키 생성, HS512 알고리즘에 맞는 키를 생성합니다.
- generateToken : userSeq, TokenType에 따라 JWT 토큰을 생성합니다.
- validateToken : JWT 토큰의 유효성을 검증합니다.
- getUserSeqFromToken : JWT 토큰에서 UserSeq를 추출합니다.
import java.security.Key;
import java.util.Date;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.stereotype.Component;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
// 비밀키 생성 및 저장 (애플리케이션에서 동일한 키를 사용)
private final Key secretKey = generateSecretKey();
// 리프레시 토큰 유효 기간 (24시간)
private final long refreshTokenValidity = 86_400_000; // 24 hours
// 액세스 토큰 유효 기간 (30분)
private final long accessTokenValidity = 1_800_000; // 30 minutes
// 토큰 타입 (ACCESS와 REFRESH 구분)
public enum TokenType {
ACCESS, REFRESH
}
/**
* 비밀키 생성
* @return SecretKey - HS512 알고리즘에 사용할 비밀키
*/
public Key generateSecretKey() {
String keyString = "thisisssafyrecruitapplicationfromssafy12thseoulclass5team!wehave4members"; // 비밀키 문자열
return new SecretKeySpec(keyString.getBytes(), SignatureAlgorithm.HS512.getJcaName()); // 키 생성
}
/**
* JWT 토큰 생성
* @param userSeq - 사용자 고유 식별자
* @param tokenType - 토큰 타입 (ACCESS 또는 REFRESH)
* @return 생성된 JWT 토큰 문자열
*/
public String generateToken(String userSeq, TokenType tokenType) {
Date now = new Date(); // 현재 시간
long validity = (tokenType == TokenType.ACCESS) ? accessTokenValidity : refreshTokenValidity; // 유효 기간 설정
Date expiryDate = new Date(now.getTime() + validity); // 만료 시간 계산
return Jwts.builder()
.setSubject(userSeq) // 사용자 정보 저장
.setIssuedAt(now) // 발행 시간
.setExpiration(expiryDate) // 만료 시간
.signWith(secretKey, SignatureAlgorithm.HS512) // 서명 알고리즘과 비밀키 사용
.compact(); // 최종 토큰 생성
}
/**
* JWT 토큰 유효성 검증
* @param token - 클라이언트로부터 받은 JWT 토큰
* @return 토큰이 유효한 경우 true, 그렇지 않은 경우 false
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(secretKey) // 비밀키 설정
.build()
.parseClaimsJws(token); // 토큰 검증
return true; // 유효한 경우 true 반환
} catch (Exception e) {
return false; // 예외 발생 시 false 반환
}
}
/**
* JWT 토큰에서 사용자 고유 식별자 추출
* @param token - 클라이언트로부터 받은 JWT 토큰
* @return 사용자 식별자 (userSeq)
*/
public String getUserSeqFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey) // 비밀키 설정
.build()
.parseClaimsJws(token) // 토큰에서 클레임 추출
.getBody();
return claims.getSubject(); // 클레임에서 사용자 식별자(subject) 반환
}
}
결론 및 정리
이번 프로젝트에서는 JWT를 활용해 Spring Security 기반의 인증 시스템을 구현했습니다.
JWT는 RESTful 환경에서 세션리스 인증을 제공하며, 이를 통해 확장성과 효율성을 높일 수 있었습니다.
JWT를 활용하여 서버의 부담을 감소시키고 확장성을 확보하고 보안성을 강화할 수 있을 것을 기대합니다.
'BackEnd > Spring' 카테고리의 다른 글
[Spring] 스프링 의존성 주입 Dependency Injection 총정리 (2) | 2024.10.11 |
---|