Spring Security와 JWT 인증 구현 상세 가이드
1. JWT (JSON Web Token) 개요
1.1 특징
- 자가수용적(Self-contained): 필요한 모든 정보를 토큰 자체에 포함
- Stateless: 서버에 상태 저장 불필요
- Base64URL 인코딩된 문자열 형태
1.2 장점
- 서버 확장성 증가: 세션 저장/관리 불필요
- 다중 서버 환경에서 효율적
- 모바일 애플리케이션 통합 용이
- CORS 문제 해결 용이
1.3 단점
- 토큰 크기: 모든 요청에 추가 overhead
- 저장된 정보 변경 불가: 토큰 재발급 필요
- 토큰 탈취 시 즉각 무효화 어려움
2. 구현 상세
2.1 JWT 구조 및 생성 (JwtProvider)
@Component
public class JwtProvider {
private final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
private final long tokenValidityInMilliseconds = 3600000; // 1시간
public String createToken(String email, String role) {
Claims claims = Jwts.claims().setSubject(email);
claims.put("role", role); // 사용자 권한 정보 저장
Date now = new Date();
Date validity = new Date(now.getTime() + tokenValidityInMilliseconds);
return Jwts.builder()
.setClaims(claims) // 사용자 정보
.setIssuedAt(now) // 발급 시간
.setExpiration(validity) // 만료 시간
.signWith(key) // 비밀키로 서명
.compact(); // 토큰 생성
}
// 토큰으로부터 인증 정보 추출
public Authentication getAuthentication(String token) {
Claims claims = extractClaims(token);
Collection<? extends GrantedAuthority> authorities =
Collections.singleton(new SimpleGrantedAuthority(claims.get("role").toString()));
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
// 토큰 유효성 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false; // 서명 불일치, 만료, 형식 오류 등
}
}
}
2.2 인증 필터 동작 원리 (JwtAuthenticationFilter)
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request); // Authorization 헤더에서 토큰 추출
// 토큰 검증 및 인증 처리
if (token != null && jwtProvider.validateToken(token)) {
Authentication auth = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
// 인증 정보를 SecurityContext에 저장하여 이후 필터에서 인증됨을 확인
}
// 토큰이 없거나 유효하지 않으면 SecurityContext는 비어있음
// -> 인증이 필요한 경로 접근 시 예외 발생
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // "Bearer " 제거
}
return null;
}
}
2.3 Security 설정 상세 (SecurityConfig)
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtProvider jwtProvider;
private final CustomUserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // JWT는 CSRF 공격에 안전
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 미사용
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll() // 인증 없이 접근 가능
.requestMatchers("/api/v1/**").authenticated()) // 인증 필요
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider),
UsernamePasswordAuthenticationFilter.class) // JWT 필터 추가
.authenticationProvider(authenticationProvider()); // 로그인 시 사용될 Provider
return http.build();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService); // 사용자 정보 조회
authProvider.setPasswordEncoder(passwordEncoder()); // 비밀번호 검증
return authProvider;
}
}
3. 상세 인증 프로세스
3.1 로그인 프로세스
1.로그인 요청 (/api/v1/auth/login
)
{
"username": "user@example.com",
"password": "password123"
}
2.CustomUserDetailsService에서 사용자 조회
- DB에서 사용자 정보 조회
- UserDetails 객체로 변환
3.DaoAuthenticationProvider
- 비밀번호 검증 (PasswordEncoder 사용)
- 인증 성공/실패 결정
4.JWT 토큰 생성
- 사용자 정보, 권한 포함
- 만료 시간 설정
- 비밀키로 서명
5.응답 반환
{
"token": "eyJhbGciOiJIUzI1NiJ9..."
}
3.2 인증된 요청 처리
1.JwtAuthenticationFilter
- Authorization 헤더에서 토큰 추출
- 토큰 유효성 검증
- SecurityContext에 인증 정보 저장
2.Security Filter Chain
- URL 패턴 매칭
- 권한 검사
- Controller 메소드 실행
3.3 토큰 없는 요청 처리
1.JwtAuthenticationFilter
- 토큰 없음 확인
- SecurityContext 비워짐
2.Security Filter Chain
- 보호된 리소스 접근 시도
- 인증 정보 없음 확인
- 401 Unauthorized 응답
3.4 예외 처리
@RestControllerAdvice
public class AuthenticationExceptionHandler {
@ExceptionHandler(BadCredentialsException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public Result<?> handleBadCredentials() {
return Result.failure("잘못된 인증 정보입니다.");
}
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public Result<?> handleAuthentication(AuthenticationException e) {
return Result.failure("인증에 실패했습니다.");
}
}
4. 보안 고려사항
4.1 토큰 관리
- 적절한 만료 시간 설정
- Refresh Token 구현 고려
- 안전한 클라이언트 저장소 사용 (HttpOnly 쿠키 등)
4.2 비밀키 관리
- 환경변수로 관리
- 정기적인 키 교체
- 적절한 키 길이 사용
4.3 추가 보안
- HTTPS 사용 필수
- XSS 공격 대비
- 토큰 블랙리스트 구현 고려
5. 코드 연동 및 흐름
1.SecurityConfig
에서 전체 보안 설정
- JWT 필터 등록
- URL 별 접근 권한 설정
- 인증 Provider 설정
2.로그인 시
- CustomUserDetailsService로 사용자 검증
- JWT 토큰 발급
3.API 요청 시
- JwtAuthenticationFilter에서 토큰 검증
- SecurityContext에 인증 정보 저장
- 컨트롤러에서 인증 정보 사용
이러한 구조로 Stateless하고 확장 가능한 인증 시스템이 구현되며, 각 컴포넌트는 단일 책임을 가지고 독립적으로 동작하면서도 전체적으로 유기적으로 연결됩니다.
'스프링' 카테고리의 다른 글
스프링 - JPA 다대일 처리 (0) | 2024.12.22 |
---|---|
스프링 - OSIV (1) | 2024.12.22 |
스프링 - N + 1 문제 (1) | 2024.12.17 |
스프링 - REST API (0) | 2024.12.17 |
스프링 시큐리티 - 간단한 인증절차 정리 (0) | 2024.12.16 |
댓글