Why?
- Spring Security
- 강력한 인증 및 인가를 내장하여 로그인, 권한 검증, CSRF 보호, 세션 관리 등 여러 보안 기능을 쉽게 구현 가능하다.
- 커스텀 필터, 핸들러 추가로 세밀한 보안 정책을 추가, 관리할 수 있다. - Jwt
- 서버 측에 별도의 세션 정보를 저장하지 않는 토큰 기반 인증 방식으로 서버 확장이 용이해짐.
- Jwt 토큰 자체에 필요한 사용자 정보와 권한 정보 (Claim) 이 포함되어 있어, 별도의 데이터베이스 조회 없이 토큰만으로 인증 및
인가 처리가 가능
- 여러 서버나 마이크로서비스 환경에서 인증 상태를 공유해야 할 때 중앙의 세션 저장소가 필요없으므로 간편하게 인증처리가 가능
- 토큰에 서명(Signature)을 포함하여 변조 방지, 만료 시간(expiration)을 설정해 재사용 공격 방지
이 장점들을 결합해 강력하고 유연한 인증 및 인가 시스템을 구현할 수 있다. 데이터 흐름부터 보고 코드로 들어가보자.
How?

흐름을 간단하게 요약해보자면
1. JWT Token 발급 이전 로직
• 첫 로그인 요청 : 사용자가 아이디와 비밀번호로 로그인 요청
• 초기 인증 : Spring Security의 필터가 이 요청을 가로채고, AuthenticationManager 및 AuthenticationProvider를 통해 데이터베이스에서 사용자를 조회하여 인증을 진행
• JWT Token 발급 : 인증이 성공하면, 서버가 사용자 정보를 기반으로 JWT 토큰을 생성하여 발급
2. JWT Token 발급 이후 로직
• 요청 시 토큰 전송 : 사용자는 JWT 토큰을 포함해 API 요청
• JWT 검증 : 서버는 JWT 토큰의 서명과 만료 시간을 확인하여 토큰의 유효성을 검증
• 추가 DB 호출 불필요 : JWT가 자체적으로 사용자 식별자와 권한 등의 정보를 담고 있으므로, 일반적인 요청의 경우 추가적인 DB 호출 없이 인증이 이루어짐
• 단, 주의사항 : 사용자 정보(예: 권한)가 변경된 경우, 토큰에 반영되지 않으므로 재인증이나 토큰 갱신 등의 추가 처리가 필요하다
1. build.gradle 의존성 주입
dependencies {
// 인증 JWT + Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}
2. SecurityConfig
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
// White List 정의로 손쉽게 요청 URL 제한이 가능하다.
private final static String[] WHITELIST = {
"/**"
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // csrf 설정
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> {
auth.requestMatchers(WHITELIST).permitAll()
.anyRequest().authenticated();
})
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
Security FilterChain 정의
- API 요청 URL 화이트리스트를 간단하게 정의하여 올바르지 않은 요청을 쉽게 필터링 가능하다. ( 임시로 모든 요청 허용 /** )
- JwtAuthenticationFilter (정의로 Jwt 인증 처리를 먼)
3. JwtAuthenticationFilter
@NoArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private JwtUtil jwtUtil;
private MemberDetailService memberDetailService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String authHeader = request.getHeader("Authentication");
String userEmail = null;
String token = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
userEmail = jwtUtil.extractEmail(token);
}
if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
CustomMemberDetails userDetails = memberDetailService.loadUserByUsername(userEmail);
if (jwtUtil.validateToken(token, userDetails.getEmail())) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
- JwtAuthenticationFilter는 Token이 request Header에 존재 여부로 분기하여 처리한다.
1. Token이 있을때는 Token 정보를 기반으로 사용자 정보 인증
2. Token이 미존재 시 DB 조회를 통해 인증
CustomUserDetails
public class CustomMemberDetails implements UserDetails {
private String email;
private String password;
private boolean enabled;
private Collection<? extends GrantedAuthority> authorities;
public CustomMemberDetails(String email, String password, boolean enabled, Collection<? extends GrantedAuthority> authorities) {
this.email = email;
this.password = password;
this.enabled = enabled;
this.authorities = authorities;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
}
UserDetailService
public CustomMemberDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member user = userRepository.findUserByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
return new CustomMemberDetails(user.getEmail(), user.getPassword(), true, user.getAuthorities());
}
- 실제 DB에서 사용자를 가져와서 CustomMemberDetails로 매핑
Jwt Filter 등록
@Configuration
public class FilterConfig {
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
}
JWT Util 정의
public class JwtUtil {
private Key secretKey;
private long EXPIRATION_TIME; // 24시간
public JwtUtil(@Value("${jwt.secret}") String secretKey,
@Value("${jwt.expiration}") long expirationTime) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.secretKey = Keys.hmacShaKeyFor(keyBytes);
this.EXPIRATION_TIME = expirationTime;
}
public String generateToken(String email) {
return Jwts.builder()
.subject(email)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(secretKey)
.compact();
}
public String extractEmail(String token) {
return Jwts.parser()
.verifyWith((SecretKey) secretKey)
.build()
.parseSignedClaims(token).getPayload().get("sub")
.toString();
}
public boolean validateToken(String token, String email) {
return email.equals(extractEmail(token)) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
Claims claims = Jwts.parser()
.verifyWith((SecretKey) secretKey)
.build()
.parseClaimsJws(token)
.getBody();
Date expiration = claims.getExpiration();
return expiration.before(new Date());
}
}
- 생성자에서 가져오는 application.yml에 선언한 Secret Key는 임시로 사용하는 키
- 배포시에는 서버에서 환경 변수로 설정하여 주입받거나 비밀 관리 시스템(예: Vault, AWS Secrets Manager)를 이용하는 것이 안전
일단 임시 키는 터미널 명령어로 만들어 넣어놓았습니다.
openssl rand -base64 32
application.yml 설정값
jwt:
expiration : 86400000
secret: 위 명령어로 만든 key를 입력
Test
class JwtTokenProviderTest {
private JwtUtil jwtUtil;
private final String secretKey = "uMWSG55ZD61gPsgB9U8PvTUVRXBG2wOvI/U70I++6DE=";
private final long expirationTime = 3600000;
@BeforeEach
void setup() {
jwtUtil = new JwtUtil(secretKey, expirationTime);
}
@Test
void testGenerateAndExtractEmail() {
String email = "test@example.com";
String token = jwtUtil.generateToken(email);
assertNotNull(token, "토큰 값이 있어야합니다.");
String extractedEmail = jwtUtil.extractEmail(token);
assertEquals(email, extractedEmail, "토큰 값에서 추출한 이메일이 같아야합니다.");
}
@Test
void testValidateToken() {
String email = "user@example.com";
String token = jwtUtil.generateToken(email);
// 올바른 이메일로 검증 시 true 반환
assertTrue(jwtUtil.validateToken(token, email), "올바른 이메일입니다.");
// 잘못된 이메일로 검증 시 false 반환
assertFalse(jwtUtil.validateToken(token, "other@example.com"), "올바르지 않은 이메일입니다.");
}
@Test
void testTokenExpiration() throws InterruptedException {
// 만료시간을 1초로 설정한 JwtUtil 인스턴스 생성
long shortExpiration = 1000; // 1초
JwtUtil shortLivedJwtUtil = new JwtUtil(secretKey, shortExpiration);
String email = "expire@example.com";
String token = shortLivedJwtUtil.generateToken(email);
// 2초 대기하여 토큰이 만료되도록 함
Thread.sleep(2000);
// 만료된 토큰은 validateToken()에서 false를 반환해야 함
assertFalse(shortLivedJwtUtil.validateToken(token, email), "만료된 토큰입니다.");
}
}'Backend > Spring' 카테고리의 다른 글
| [JWT] RefreshToken의 DB에서 Redis로 이사하기 (0) | 2025.07.04 |
|---|---|
| [API문서화] Spring Boot 3.x 버전 Swagger 적용하기 (0) | 2025.03.20 |
| [querydsl] kotlin gradle에서 초기세팅하기 (0) | 2024.03.29 |
| [JPA] 쿼리 파라미터 로그로 직접 살펴보기 (0) | 2024.03.11 |