개요
도 했고,
도 했으니까 이제 실제 프로젝트에 한번 구현해보려고 한다.
이 글에서는 JWT, Security 코드만 정리한다.
1. 의존성 추가
build.gradle에서 사용할 라이브러리들의 의존성을 먼저 추가하기
//Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
2. TokenProvider 추가
JWT 인증 시스템에서 토큰 생성, 검증, 정보 추출을 담당하는 클래스이다.
먼저, application.properties에 비밀키와 만료 기간을 설정해줬다.
Secret Key는 JWT 토큰을 생성하고 검증하는 데 핵심적인 역할을 하는 보안키이다.
이 키를 서명 생성과 검증 과정에서 사용하고, 토큰의 무결성(= 임의로 토큰을 수정하지 못하도록)을 보장한다.
openssl rand -base64 32
-> 터미널에서 위에 코드와 같이 생성해주고, 아래 secret-key 부분에 넣어주었다.
// application.properties
jwt.secret= secret-key
jwt.access-expiration=900000
jwt.refresh-expiration=604800000
TokenProvider
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
@Component
public class TokenProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-expiration}")
private long accessTokenExpirationTime;
@Value("${jwt.refresh-expiration}")
private long refreshTokenExpirationTime;
//키 생성
private Key getSigningKey() {
byte[] keyBytes = Base64.getDecoder().decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
// Access Token 생성
public String createAccessToken(String riderId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenExpirationTime);
return Jwts.builder()
.setSubject(riderId)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
// Refresh Token 생성
public String createRefreshToken(String riderId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + refreshTokenExpirationTime);
return Jwts.builder()
.setSubject(riderId)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
// 토큰에서 라이더 ID 추출 (Access, Refresh 동일하게 사용)
public String getRiderIdFromToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
// 토큰 유효성 검증 (Access, Refresh 동일하게 사용)
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
-> 전체 코드인데 하나씩 잘라서 살펴보자.
@Component
public class TokenProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-expiration}")
private long accessTokenExpirationTime;
@Value("${jwt.refresh-expiration}")
private long refreshTokenExpirationTime;
: @Component
-> @Component 어노테이션을 사용해 Spring에서 이 클래스를 Bean으로 등록했다.
: @Value()
-> application.properties에 설정된 각 secret key와 유효기간을 불러왔다.
private Key getSigningKey() {
byte[] keyBytes = Base64.getDecoder().decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
: .signWith(SignatureAlgorithm.HS256, "my-secret");
-> 예전에는 Key를 이런식으로 문자열 형태로 만들었다.
-> 하지만 이 코드는 Deprecated 되었는데, 최신 버전에서는 더 안전한 키 구조를 위해 Key 타입을 사용해야 한다.
: byte[] keyBytes = Base64.getDecoder().decode(secretKey);
-> Base64로 인코딩된 secret Key 텍스트를 바이트 배열로 디코딩한다.
:Keys.hmacShaKeyFor(keyBytes);
-> JJWT에서 제공하는 메서드로, byte 배열을 기반으로 Key 객체를 만든다.
-> 이때, 만들어진 Key 객체는 JWT를 서명(sign) 하거나, 서명을 검증할 때 사용한다.
public String createAccessToken(String riderId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenExpirationTime);
return Jwts.builder()
.setSubject(riderId)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
-> 사용자가 로그인하게 되면 이 메서드가 호출되어 Access Token을 생성한다.
: Jwts.builder()
-> JWT를 생성하기 위해 사용하는 코드이다.
: setSubject(riderId)
-> 토큰의 주체를 지정한다.
: compact()
-> 설정한 값들을 하나의 문자열로 만들어 반환한다.
public String createRefreshToken(String riderId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + refreshTokenExpirationTime);
return Jwts.builder()
.setSubject(riderId)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
-> RefreshToken도 accessToken 만들 때와 동일하게 작성해준다.
public String getRiderIdFromToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
: parserBuilder()
-> JWT를 파싱하기 위한 빌더이다.
: parseClaimsJws(token)
-> 전달받은 token을 검증하고 파싱한다.
-> 이때, 유효하지 않거나 위조된 토큰이면 예외가 발생한다.
: getBody()
-> 파싱된 JWT의 payload를 가져온다.
: getSubject()
-> payload에 있는 subject를 가져오는데 나의 코드에서는 라이더 ID를 꺼낸다.
3. JWT 검증 필터 추가
얘의 역할은 클라이언트에서 요청이 들어올 때마다,
Authorization : Bearer xxx 라는 헤더에서 JWT를 꺼내고,
토큰의 유효성 검사 및 토큰에서 사용자의 ID를 추출한다.
그 뒤, 사용자 정보를 Spring Security Context에 등록해서 인증 완료 처리까지가 얘의 역할이다.
JwtAuthenticationFilter
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.web.filter.OncePerRequestFilter;
import signal.com.yongjisa.rider.RiderService;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final RiderService riderService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7); // "Bearer " 제거
if (tokenProvider.validateToken(token)) {
String riderId = tokenProvider.getRiderIdFromToken(token); // subject 추출
UserDetails userDetails = riderService.loadUserByUsername(riderId);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
// SecurityContext에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response); // 다음 필터로 넘기기
}
}
: OncePerRequestFilter
-> 요청당 딱 한 번 동작하게 해주는 Spring Security 필터
: String header = request.getHeader("Authorization");
-> HTTP 요청 헤더에서 Authorization 값을 가져온다.
-> 보통 클라에서 다음과 같이 보내기 때문!
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5...
: String riderId = tokenProvider.getRiderIdFromToken(token);
-> JWT의 subject 값인 riderId를 꺼낸다.
: UserDetails userDetails = riderService.loadUserByUsername(riderId);
-> Spring Security는 인증된 사용자를 UserDetails 객체로 관리한다.
-> riderService에서 이를 UserDetails 객체로 만들어 반환하는 메서드를 만들어줬다.
: UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
-> 사용자 인증 객체를 생성한다. (= Spring Security에서 이 사용자는 인증됐다! 라는 것을 표현하는 객체)
-> 두 번째 파라미터는 비밀번호지만 JWT 기반 인증이기 때문에 null, 세 번째는 권한 정보이다.
: SecurityContextHolder.getContext().setAuthentication(authentication);
-> 이 코드가 가장 중요하다!
-> Spring Security Context에 인증 정보를 저장하는 코드인데,
-> 이걸 실행해야 컨트롤러에서 @AuthenticationPrincipla 같은 걸로 유저 정보를 가져올 수 있다.
: filterChain.doFilter(request, response);
-> 인증 처리를 마친 후 요청을 다음 필터로 넘긴다.
SecurityConfig
얘는 Spring Security가 애플리케이션을 어떻게 보호할지, 인증/인가/세션/필터 정책을 어떻게 적용할지를 설정하는 클래스이다.
코드로 하나씩 이해해보자면,
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final RiderService riderService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/signup").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(
new JwtAuthenticationFilter(tokenProvider, riderService),
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
}
: @EnableWebSecurity
-> Spring Security를 애플리케이션에서 적용하겠다는 의미이다.
: SecurityFilterChain ...
-> Spring Security는 6부터 WebSecurityConfigurerAdapter를 쓰지 않는다.
-> 대신에 SecurityFIlterChain Bean을 명시적으로 등록해야지 Security 설정이 작동한다.
: .csrf()
-> CSRF 비활성화
: .sessionManagement()
-> 세션을 사용하지 않고 JWT 방식으로 인증 관리한다.
: .authorizeHttpRequests()
-> 요청별 접근 권한 설정한다.
-> permitAll() = 누구나 접근 가능
-> authenticated() = 인증된 사용자만 접근 가능
: .addFilterBefore(
new JwtAuthenticationFilter(tokenProvider, riderService),
UsernamePasswordAuthenticationFilter.class
)
-> Spring Security는 요청이 들어오게 되면 여러 개의 Security Filter를 거친다.
-> 여기서 코드는 UsernamePasswordAuthenticationFilter 필터 전에, JwtAuthenticationFilter를 실행시킨다.
-> 이로 인해, 토큰이 유효한지 확인하고 사용자 정보를 SecurityContext에 저장하게 된다.
: @Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
-> 비밀번호를 평문으로 저장하면 안되기 때문에, 비밀번호 암호화를 설정한다.
여기까지 코드를 작성했다면, 이제 JWT 인증 시스템의 보안 기반은 완전 갖춘 상태이다! 다음 글에서부터 회원가입, 로그인 관련 로직을 쭉 작성해봐야겠다.