Spring Security와 JWT를 활용한 로그인 기능 구현
주요 기능
1. 회원가입
- 아이디(사용자명), 이메일, 비밀번호 검증
- 중복 체크
- 비밀번호 암호화 (BCrypt)
- JWT 토큰 자동 발급
2. 로그인
- 사용자 인증
- JWT 토큰 발급
3. JWT 인증
- Authorization 헤더의 Bearer 토큰 검증
- 자동 인증 처리
라이브러리 추가
jjwt의 경우 보통 0.11.x 버전을 쓰는 것 같았는데, 공부 목적이니까 제일 최근 버전인 0.12.3을 쓰기로 함
변경된 부분이 있으므로 코드 작성 시 주의 필요
dependencies {
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
// JWT Token
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
JWT 발급 및 검증
인증 과정
- 로그인 요청
- LoginFilter에서 사용자 인증 진행
- 인증 성공 시 JWT 발급
- 클라이언트는 JWT를 Authorization 헤더에 포함하여 요청
- 서버에서 JWT 검증 후 사용자 인증
- JWT Util을 사용하여 토큰 검증
JWT Util
JWT Util 생성, 검증 및 유효성 검사 기능
1. 비밀키, 토큰 유효 시간 설정값 주입
2. 서명 키 생성
3. 토큰에서 username, 만료시간, 공통 클레임 추출
4. 모든 클레임 파싱
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
- 토큰 서명 검증 --> 서명이 틀리면 예외 발생 (1차 검증)
- 위조 여부 체크
- Payload(Claims) 추출
5. 토큰 만료 여부 확인
6. 토큰 생성 (기본)
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername());
}
- 로그인 성공 시 호출
- 현재는 username만 subject로 저장
- 필요하면 roles, userId 등을 claims에 추가 가능
7. 실제 JWT 생성
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.claims(claims) // 사용자 정의 정보
.subject(subject) // 사용자 식별자 (username)
.issuedAt(new Date(System.currentTimeMillis())) // 토큰 발급 시간
.expiration(new Date(System.currentTimeMillis() + expiration)) // 토큰 만료 시간
.signWith(getSigningKey()) // 서명
.compact();
}
8. 토큰 유효성 검증
전체 코드
💡 JWT 암호화 키 관리
JWT를 서명하고 검증하기 위해서는 비밀키를 안전하게 관리해야 함
비밀키를 코드 내부에 하드코딩하지 않고 설정 파일(application.properties)에서 관리하는 것이 안전
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
// Secret Key 생성
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
// 토큰에서 username 추출
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// 토큰에서 만료 시간 추출 - 토큰 만료 여부 판단에 사용
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
// 토큰에서 특정 클레임 추출
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
// 모든 클레임 추출
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
// 토큰 만료 여부 확인
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
// 토큰 생성
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername());
}
// 토큰 생성 (클레임 포함)
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
// 토큰 유효성 검증
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
JWT Filter
매 요청마다 JWT를 검사해서 이 요청이 누구의 요청인지를 SecurityContext에 세팅함
전체 흐름
- Client Request
- JwtAuthenticationFilter
- Authorization 헤더 검사
- JWT 파싱 & 검증
- SecurityContext 인증 등록
- Controller 접근 가능
1. OncePerRequestFilter 상속
- 요청당 1번만 실행됨
- 동일 요청에서 여러 번 인증되는 문제 방지
- Spring Security에서 JWT 필터는 OncePerRequestFilter를 상속하는 게 정석
2. 의존성 필드
JwtUtil- 토큰 파싱
- username 추출
- 만료 여부 검증
UserDetailsService- username --> UserDetails 조회
- DB 기반 사용자 검증
- JWT + DB 정보 모두 확인하는 구조
3. 모든 HTTP 요청마다 실행
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
)
4. Authorization 헤더 추출
- 클라이언트 요청 예)
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
5. Bearer 토큰 여부 확인
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
try {
username = jwtUtil.extractUsername(jwt);
} catch (Exception e) {
logger.error("JWT 토큰 파싱 오류: " + e.getMessage());
}
}
- Authorization 헤더 존재 여부 확인
- "Bearer "로 시작하는지 확인
- "Bearer " 제거 --> 실제 JWT
- 토큰에서 username 추출
6. SecurityContext 중복 인증 방지
if (username != null
&& SecurityContextHolder.getContext().getAuthentication() == null) {
- username != null
- 토큰 파싱 성공
- getAuthentication() == null
- 이미 인증된 사용자가 아님
- 이미 인증된 요청에 대해 다시 인증하지 않음
- 필터 체인 중복 처리 방지
7. UserDetails 조회
- DB에서 사용자 정보 조회
- 계정 존재 여부 + 권한 정보 확보
8. 토큰 유효성 검증
- 토큰 subject == DB의 username
- 토큰 만료 여부
- 토큰과 DB 정보 교차 검증
9. Authentication 객체 생성
- principal : 인증된 사용자
- credentials : null (JWT는 비밀번호 없음)
- authorities : 권한 목록
- 이 객체가 Spring Security가 인식하는 로그인 상태
10. 요청 정보 세팅
- IP, Session ID 등 요청 메타 정보
- 감사 로그, 보안 로그에 활용 가능
11. SecurityContext에 인증 정보 저장
- 해당 요청이 인증된 사용자의 요청임을 나타냄
전체
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// Authorization 헤더 추출
final String authorizationHeader = request.getHeader("Authorization");
String username = null;
String jwt = null;
// Bearer 토큰 확인
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
try {
username = jwtUtil.extractUsername(jwt);
} catch (Exception e) {
logger.error("JWT 토큰 파싱 오류: " + e.getMessage());
}
}
// 토큰이 유효하고 SecurityContext에 인증 정보가 없는 경우
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
// 토큰 유효성 검증
if (jwtUtil.validateToken(jwt, userDetails)) {
// Authentication 객체 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
// 요청 정보 세팅
authenticationToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// SecurityContext에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
filterChain.doFilter(request, response);
}
}
Spring Security 설정 (SecurityConfig)
JWT 기반의 인증 및 인가를 적용하는 설정 파일
SecurityFilterChain
Spring Security의 전체 동작 규칙을 정의하는 핵심 메서드
1. CSRF 비활성화
- JWT + REST API 구조에서는 세션을 안 쓰기 때문에 CSRF 불필요
2. CORS 설정
- 프론트엔드와의 통신 허용
- 아래에서 정의할 CorsConfigurationSource Bean 사용
- 프론트엔드와 백엔드 도메인 분리 시 필수
3. 요청별 접근 제어 (인가)
- 누구나 접근 가능한 경로와 아닌 경로 구분
4. 세션 관리 정책
- 서버 세션 생성 안 함
- 요청마다 JWT로 인증
- JWT + Session 혼용 시 버그 발생하므로 Security가 세션을 생성하지 않도록 막음
5. AuthenticationProvider 등록
- 로그인 요청 시 사용할 인증 전략
- JWT 검증 X
- ID + password 검증용
6. JWT 필터 등록
- 기본 로그인 필터보다 JWT 필터를 먼저 실행
- JWT 인증 실패 시 일반 로그인 로직으로 넘어감
AuthenticationProvider (로그인 담당)
아이디/비밀번호 기반 로그인 처리
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider =
new DaoAuthenticationProvider(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
- UserDetailsService.loadUserByUsername
- DB 비밀번호 조회
- PasswordEncoder로 비교
AuthenticationManager
로그인 컨트롤러에서 사용
이를 통해 AuthenticationProvider가 호출됨
CORS 설정
허용할 프론트엔드 주소 설정
전체
import 프로젝트경로.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
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 org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, CorsConfigurationSource corsConfigurationSource) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 요청별 접근 제어
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/test/**").permitAll()
.requestMatchers("/admin").hasAuthority("ADMIN")
.anyRequest().authenticated()
)
// 세션 관리 정책
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// 로그인 인증 담당
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// CORS 설정
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
전체 인증 흐름
로그인 (POST /로그인url)
- AuthenticationManager
- DaoAuthenticationProvider
- UserDetailsService
- 비밀번호 검증
- JWT 발급
일반요청 (Request)
- JwtAuthenticationFilter
- JWT 검증
- SecurityContext 인증
- Controller
'SpringBoot' 카테고리의 다른 글
| [SpringBoot] Flyway로 DB 마이그레이션 및 DDL 작성 (1) | 2026.06.08 |
|---|---|
| [SpringBoot] Swagger를 이용한 API 문서 자동화 (0) | 2026.05.31 |
| [AWS] SpringBoot + AWS EC2 Ubuntu 서버 생성 및 배포 (0) | 2026.04.07 |
| [AWS] RDS DB 인스턴스 생성(PostgreSQL) 및 SpringBoot 연동 (0) | 2026.04.07 |
| [스프링 부트] 어노테이션 (0) | 2022.07.14 |