[1년 후 마실가실] Spring Security, JWT 공부
1년 전 진행했던 마실가실 프로젝트를 🛠️리팩토링하며 정리한 내용입니다.
Java Web Token, JWT..
마실가실에 JWT가 적용되어 있지만, 제가 담당한 게 아니라 잘 모르고 있었습니다.
이제 UI를 보면서 Controller를 수정하고 있는데, 마침 회원 가입의 마지막 장식을 JWT와 함께 해보고자 이 글을 작성했습니다.
Spring Security
스프링 기반 애플리케이션의 보안(인증과 권한)을 담당하는 프레임워크
*인증 절차를 거친 후 → 인가 절차를 진행
*인증과 인가를 위해 Principal을 아이디로, Credential을 비밀번호로 사용하는 Credential 기반의 인증 방식 사용
1. Http Request 수신
사용자가 로그인 정보와 함께 인증 요청
2/3. AuthenticationFilter 동작
아이디와 비밀번호의 유효성 검사 실시 후 인증용 객체인 UsernamePasswordAuthenticationToken 반환
→ FIlter를 통해 AuthenticationToken을 AuthenticationManager로 전달
4. AuthenticationManager 동작
* Authentication Manager는 List형태로 Provider들을 갖고 있음
Token을 처리할 수 있는 Authentication Provider 선택 → 실제 인증을 할 AuthenticationProvider에게 인증용 객체를 다시 전달
5. AuthenticationProvider
실제 데이터베이스에서 사용자 인증 정보를 가져오는 UserDetailsService에 사용자 정보 전달
6. UserDetails를 이용해 User객체에 대한 정보 탐색
loadUserByUsername()을 호출 → DB에 있는 사용자의 정보를 UserDetails 형으로 가져옴
* 만약 사용자가 존재하지 않으면 예외 발생
User 객체의 정보들을 UserDetails가 UserDetailsService(LoginService)로 전달
7. AuthenticaitonProvider들은 UserDetails를 넘겨받고 사용자 정보를 비교
* DB에서 가져온 이용자 정보와 화면에서 입력한 로그인 정보를 비교
→ 일치하면 Authentication 참조를 리턴 / 일치 하지 않으면 예외 호출
8/9. AuthenticationFilter에 Authentication 객체 반환
10. SecurityContext에 인증 객체를 설정
Authentication 객체를 Security Context에 저장
Spring Security, JWT 적용 과정은 다음과 같이 진화했습니다.
1. 초기 환경 설정
2. JWT WeakKeyException 오류 수정
3. 보안을 위한 환경 변수 설정
1. 초기 환경 설정
- build.gradle
// security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-config'
// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
- User.java
@Entity
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User extends BaseEntity {
@Id @GeneratedValue
@Column(name = "user_id")
private Integer id;
@Column(nullable = false, columnDefinition="char(1)")
private String status;
@Enumerated(EnumType.STRING)
private LoginType loginType;
@Column(nullable = false, unique = true, length = 50)
private String email;
@Column(nullable = false, unique = true, columnDefinition="char(11)")
private String phone;
@Column(length = 30)
private String nickname;
@Column(length = 30)
private String password;
private String imagePath;
}
- CustomUserDetailsService.java
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
// UserDetailsService: Spring Security에서 유저 정보를 가져오는 Interface
// 기본 Override Method: loadUserByUsername
// loadUserByUsername: 유저 정보를 가져와 UserDetails로 return
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email).orElseThrow(()->
new BusinessException(NOT_FOUND_MEMBER));
//user의 role을 스프링시큐리티의 GrantedAuthority로 바꿔준다. 여러개의 role을 가질수 있으므로 Set
Set<GrantedAuthority> authorities = Set.of(SecurityUtils.convertToAuthority(user.getRole().name()));
return UserPrinciple.builder()
.email(user.getEmail())
.password(user.getPassword())
.user(user)
.build();
}
}
- UserPrinciple.java
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserPrinciple implements UserDetails {
private String email;
transient private String password; // 직렬화 과정에서 제외
transient private User user; // 직렬화 과정에서 제외
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null; // 계정의 권한 목록
}
@Override
public String getUsername() {
return email; // 계정의 고유한 값 리턴
}
@Override
public String getPassword() {
return password; // 계정의 비밀번호 리턴
}
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return true;}
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return true; }
}
- SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.cors(httpSecurityCorsConfigurer -> corsConfigurationSource())
.authorizeRequests((auth) -> auth
.requestMatchers("/api/v2/users/me").permitAll())
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* CSRF(Cross Site Request Forgery): 사이트간 위조 요청
* csrf(): HTTP 요청에 대해 CSRF 토큰을 생성하고, 이 토큰을 쿠키에 저장
* 모든 POST, PUT, DELETE 요청에 대해 CSRF 토큰이 함께 전송되는지 검증
*
* REST API를 이용한 서버라면, session 기반 인증과는 다르게 stateless(= 서버에 인증 정보를 보관하지 않음)
* 사용자가 로그인 상태를 유지하고 있는 동안 다른 사이트에서 악의적인 요청을 보내는 것을 방지하는 데 유용한 CSRF 사용 필요성이 낮음
* 대신, Client는 권한이 필요한 요청을 하기 위해서는 요청에 필요한 인증 정보를(OAuth2, jwt토큰 등)을 포함시켜야 함
*
* AbstractHttpConfigurer::disable
* AbstractHttpConfigurer 클래스 안에 disable 메서드를 참조
* */
/**
* CORS(Cross-Origin Resource Sharing): 웹 페이지가 자신과 다른 출처의 리소스에 접근할 때 발생하는 보안 정책
* httpSecurityCorsConfigurer를 corsConfigurationSource()로 정의
* */
/**
* authorizeRequests: 특정 URL 경로에 대한 접근 권한을 설정
* requestMatchers("...").permitAll()은 "..." 엔드포인트에 대한 모든 요청을 허용
* 즉, 인증되지 않은 사용자도 이 경로에 접근할 수 있음
* */
/**
* addFilterBefore: JWT 인증 필터 추가
* JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가
* */
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET","POST","PUT","DELETE"));
configuration.setAllowedHeaders(Arrays.asList("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
- JwtTokenProvider
@Slf4j
@Component // Spring이 이 클래스를 자동으로 스캔하고 빈으로 등록
@RequiredArgsConstructor
public class JwtTokenProvider {
private static final String AUTHORITIES_KEY = "roles";
private static final String BEARER_TYPE = "Bearer";
@Value("${jwt.secretKey}")
private String JWT_SECRET;
private final UserRepository userRepository;
// 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
public TokenInfo generateToken(UserPrinciple auth) {
Date now = new Date();
Date expiration = new Date(now.getTime() + Duration.ofMinutes(1).toMillis());
String authorites = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
Key secretKey = Keys.hmacShaKeyFor(JWT_SECRET.getBytes(StandardCharsets.UTF_8));
// Access Token 생성
String accessToken = Jwts.builder()
.setSubject(auth.getEmail())
.claim(AUTHORITIES_KEY, authorites)
.setExpiration(expiration)
.signWith(secretKey)
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(expiration)
.signWith(secretKey)
.compact();
return TokenInfo.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(ServletRequest request) {
//유저 정보 부분만 가져옴
Claims claims = parseClaims(request);
if(claims == null)
throw new BusinessException(NOT_FOUND_MEMBER);
if(claims.getSubject() == null)
throw new BusinessException(NOT_FOUND_MEMBER);
if (claims.get(AUTHORITIES_KEY) == null)
throw new BusinessException(NOT_FOUND_AUTHORITY);
// 클레임에서 권한 정보 가져오기
Set<GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY)
.toString()
.split(","))
.map(SecurityUtils::convertToAuthority) // 스트림의 각 요소를 다른 형태로 변환하는 데 사용
.collect(Collectors.toSet());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails userDetails = UserPrinciple.builder()
.email(claims.getSubject())
.authorities(authorities)
.build();
return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
}
/**
* UsernamePasswordAuthenticationToken
* userDetails: 사용자의 정보(예: 이메일, 권한)를 담고 있는 객체
* null: 이 위치에 보통은 비밀번호나 인증 토큰이 들어가지만, 여기서는 이미 인증이 된 상태이므로 필요하지 않아 null로 설정
* authorities: 사용자가 가지고 있는 권한 목록
* */
// 토큰 정보를 검증하는 메서드
public boolean isValidToken(ServletRequest request) {
Key secretKey = Keys.hmacShaKeyFor(JWT_SECRET.getBytes(StandardCharsets.UTF_8));
String accessToken = SecurityUtils.resolveToken((HttpServletRequest) request);
try {
Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(accessToken);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
System.out.println("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
System.out.println("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
System.out.println("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalStateException e) {
System.out.println("JWT 토큰이 잘못되었습니다.");
}
return false;
}
// 리퀘스트의 토큰에서 암호풀어 Claims 가져오기
private Claims parseClaims(ServletRequest request) {
String accessToken = SecurityUtils.resolveToken((HttpServletRequest) request);
if(accessToken == null)
return null;
Key secretKey = Keys.hmacShaKeyFor(JWT_SECRET.getBytes(StandardCharsets.UTF_8));
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(accessToken)
.getBody();
}
}
- SecurityUtils.java
public class SecurityUtils {
//Token 값을 가져오는데 필요한 값들
public static final String AUTH_HEADER = "authorization";
public static final String AUTH_TOKEN_TYPE = "Bearer";
public static final String AUTH_TOKEN_PREFIX = AUTH_TOKEN_TYPE + " "; // 한 칸 띄우는것을 넣어줘야함
// Request Header에서 토큰 정보 추출
public static String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTH_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(AUTH_TOKEN_TYPE)) {
return bearerToken.substring(7);
}
return null;
}
}
- JwtAuthentificationFilter
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
// 요청이 들어올 때마다 JWT 토큰을 검사하고 인증 정보를 설정
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final JwtTokenProvider jwtTokenProvider;
// JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext에 저장하는 역할 수행
@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
// 1. Request Header에서 JWT 토큰 추출
String accessToken = SecurityUtils.resolveToken(request);
// 2. 토큰 유효성 검사
// 토큰이 유효할 경우, 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
if (accessToken != null && jwtTokenProvider.isValidToken(request)) {
Authentication authentication = jwtTokenProvider.getAuthentication(request);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
// 현재 필터가 요청을 처리한 후, 필터 체인의 다음 필터로 요청과 응답을 전달
}
}
2. WeakKeyException 오류 수정
The specified key byte array is 96 bits which is not secure enough for any JWT HMAC-HA algorithm.
The JWT JWA Specification (RFC 7518, Section 3.2) states that keys used with HS256 MUST have a size >= 256 bits (the key size must be greater than or equal to the hash output size).
JWT(JSON Web Token)를 생성하거나 검증할 때 사용하는 서명 키의 크기가 HS256 알고리즘을 사용하기에 충분하지 않아서 발생한 것입니다.
HS256 알고리즘을 사용할 때는 최소 256비트 이상의 키가 필요합니다.
하지만 현재 사용 중인 키는 96비트로, 이는 충분히 안전하지 않다고 경고하고 있습니다.
- JwtTokenProvider
@Component
public class JwtTokenProvider {
private final Key secretKey;
public JwtTokenProvider() {
this.secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
}
public String createToken(String subject){
Date now = new Date();
Date expiration = new Date(now.getTime() + Duration.ofDays(1).toMillis());
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer("test")
.setIssuedAt(now)
.setExpiration(expiration)
.setSubject(subject)
.signWith(secretKey)
.compact();
}
public Claims parseJwtToken(String token) {
token = bearerRemove(token);
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
}
private String bearerRemove(String token) {
return token.substring("Bearer ".length());
}
}
SecretKey를 알고리즘에 맞게 생성해서 사용하면 됩니다.
+ 추가적으로, signWith(io.jsonwebtoken.SignatureAlgorithm, java.lang.String)'가 deprecated 되어 String값을 넣는 것이 아닌 Key값을 생성하고 서명을 진행하는 것으로 수정되었습니다.
하지만, 문제는 해당 방식으로 사용할 경우 애플리케이션이 시작될 때마다 새로운 키를 생성하기 때문에 토큰이 서버를 재시작할 때마다 유효하지 않게 될 수 있습니다.
운영 환경에서는 일반적으로 고정된 키를 사용하여 여러 서버 인스턴스 간에 일관된 JWT 검증이 가능하도록 해야 합니다.
분산된 시스템이나 로드 밸런싱된 환경에서는 동일한 비밀 키를 사용하여 여러 서버 인스턴스 간에 JWT 검증을 수행해야 합니다. 이 방식은 각 인스턴스마다 다른 키를 생성하게 되므로, 서로 다른 서버에서 생성된 JWT가 검증되지 않는 문제가 발생할 수 있습니다.
따라서, 환경 변수나 키 관리 도구를 사용해 키를 일관되게 관리하는 것이 좋습니다.
3. 환경 변수를 활용한 비밀 키 저장
- application.yml
jwt:
secretKey: ...
이 때, scretKey는 git bash나 리눅스에서 아래 명령어를 쳐서 나온 걸 사용할 수 있습니다.
openssl rand -hex 64
생성자 부분을 수정해줍니다.
private final Key secretKey;
public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey) {
this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
🙋♀️
본 포스트는 공부 목적으로 작성하였습니다.
보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.
📑
참고 자료