가수면
Spring Security와 JWT 본문
Spring Security는 Spring Security Filter Chain를 통해 모든 요청을 인터셉트하게 된다.
때문에 Spring Security Filter Chain을 프로젝트에 맞게 커스텀 설정해서 사용해야한다.
본 글에서는 jjwt를 이용해 JWT를 다루는 방법을 정리한다.
Spring Security Filter 설정
Spring Security가 업데이트되면서 기존 WebSecurityConfigurerAdapter 클래스를 extends 하고 configure를 오버라이드하던 방식에서 SecurityFilterChain를 사용하는 방식으로 바뀜
@Configuration
@RequiredArgsConstructor
public class SecurityConfiguration {
private final UserRepository userRepository;
@Value("${jwt.secret-key}") // 시크릿 키 환경 변수 이름
private String secretKey;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(
auth -> auth
// 서버 상태 확인을 위한 루트 url에 인증 예외 처리
.requestMatchers(
"/"
).permitAll()
// 토큰 없이 접근 가능한 url에 인증 예외 처리
.requestMatchers(
HttpMethod.POST,
"/user/signup",
"/user/login"
).permitAll()
.anyRequest()
.authenticated())
// JWT 토큰 사용 시 서버 상태를 사용하지 않으므로 세션 비활성
.sessionManagement(
session ->
session.sessionCreationPolicy(
SessionCreationPolicy.STATELESS))
// JWT 토큰 사용 시 csrf에 대한 대비가 어느정도 되므로 csrf 비활성
.csrf(csrf -> csrf.disable())
// 헤더의 토큰 인증에 대해 필터링하도록 설정
.addFilterBefore(new JwtTokenFilter(userRepository, secretKey), UsernamePasswordAuthenticationFilter.class)
// 인증 에러 시 에러 상태 및 응답 설정
.exceptionHandling((exceptionHandling) ->
exceptionHandling
.accessDeniedPage("/errors/access-denied")
.authenticationEntryPoint(new CustomAuthenticationEntryPoint()));
return http.build();
}
// 회원가입 시 비밀번호 암호화하기 위한 메소드
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
에러를 어떻게 처리할 것인지에 대한 클래스 생성
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setContentType("application/json");
response.setStatus(에러 상태 코드);
response.getWriter().write(응답 모양);
}
}
JWT 토큰 생성 및 인증
jjwt가 버전업되면서 메소드들이 많이 바뀌었다.
Deprecated된 목록들은 아래 문서들을 참고해 교체하였다.
https://javadoc.io/doc/io.jsonwebtoken/jjwt-api/latest/deprecated-list.html
https://github.com/jwtk/jjwt?tab=readme-ov-file#jwt-read
아래 예시에서는 토큰을 생성할 때 아이디와 비밀번호를 payload로 설정해 생성하고, 인증 시 토큰을 파싱해 아이디와 비밀번호가 db에 있는 정보인지, 토큰이 만료가 되었는지를 확인한다.
그리고 해당 작업은 Spring Security Filter Chain에서 설정했던 예외 경로를 제외한 모든 url에서
.addFilterBefore(new JwtTokenFilter(userRepository, secretKey), UsernamePasswordAuthenticationFilter.class)
위 코드 라인으로 인해 헤더의 토큰을 검증하는 절차를 거치게 되는 예시가 되겠다.
@Slf4j
public class JwtTokenUtils {
// JWT 토큰 파싱해 얻은 정보를 DB 데이터와 비교 (토큰 인증 로직)
public static User validatedUser(UserRepository userRepository, String username, String password) {
User user = userRepository.findByUsername(username).orElseThrow(() ->
new TodoExceptionHandler(ErrorCode.USER_NOT_FOUND, String.format("%s is not founded", username)));
if (!password.equals(user.getPassword())) {
throw new TodoExceptionHandler(ErrorCode.INVALID_INFO, "Invalid password");
}
return user;
}
// 파싱한 토큰의 정보 얻기 위한 로직 (토큰 인증 로직)
public static String getUsername(String token, String key) {
return extractClaims(token, key).get("username", String.class);
}
public static String getPassword(String token, String key) {
return extractClaims(token, key).get("password", String.class);
}
public static boolean isExpired(String token, String key) {
Date expiredDate = extractClaims(token, key).getExpiration();
return expiredDate.before(new Date());
}
// JWT 토큰 파싱 로직 (토큰 인증 로직)
public static Claims extractClaims(String token, String key) {
return Jwts.parser().verifyWith((SecretKey) getKey(key))
.build().parseSignedClaims(token).getPayload();
}
// JWT 토큰 생성
public static String generateToken(String username, String encodedPassword, String key, long expiredTimeMs) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", username);
claims.put("password", encodedPassword);
long nowMillis = System.currentTimeMillis();
return Jwts.builder()
.claims(claims)
.issuedAt(new Date(nowMillis))
.expiration(new Date(nowMillis + expiredTimeMs))
.signWith(getKey(key))
.compact();
}
// JWT 토큰 생성에 필요한 시크릿 키 생성 (key는 256bit 이상이어야 한다.)
private static Key getKey(String key) {
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
}
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter { // 모든 요청에 대해 필터링하므로 OncePerRequestFilter를 extends 함
private final UserRepository userRepository;
private final String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
// 헤더 토큰 유무에 대한 유효성 검사
if(header == null || !header.startsWith("Bearer ")) {
log.error("Header is null or invalid");
filterChain.doFilter(request, response);
return;
}
try {
final String token = header.split(" ")[1].trim();
// 토큰 만료에 대한 유효성 검사
if(JwtTokenUtils.isExpired(token, secretKey)) {
log.error("Token is expired");
filterChain.doFilter(request, response);
return;
}
String username = JwtTokenUtils.getUsername(token, secretKey);
String password = JwtTokenUtils.getPassword(token, secretKey);
// 토큰에 들어있는 아이디와 비밀번호가 db에 있는지 확인
User user = JwtTokenUtils.validatedUser(userRepository, username, password);
// 인증된 사용자 정보를 Spring Security 컨텍스트에 저장 (인자 = 아이디, 비밀번호, 권한)
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
user, null, List.of(new SimpleGrantedAuthority(user.getRole().toString())));
// 인증 요청의 세부 정보를 저장. 인증 객체에 HTTP 요청과 관련된 추가적인 컨텍스트를 제공
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 인증된 사용자 정보를 Spring Security 컨텍스트에 저장. 이후 요청에 대해서는 인증을 요구하지만, 실제 인증 로직이 실행되지 않게 함
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (RuntimeException error) {
log.error("Error occurs while validating, {}", error.toString());
filterChain.doFilter(request, response);
return;
}
filterChain.doFilter(request, response);
}
}
UserDetails 설정
사용자의 인증과 권한 부여 과정에서 필요한 정보를 UserDto에서 추출할 수 있도록 구현해줘야함.
불필요한 설정들도 오버라이드하기 위해선 필요
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserDto implements UserDetails { // UserDetails의 설정들 오버라이드
...
// 유저 권한
@Override
@JsonIgnore
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(role.toString()));
}
// 삭제되지만 않았으면 가능하도록
@Override
@JsonIgnore
public boolean isAccountNonExpired() {
return removedAt == null;
}
@Override
@JsonIgnore
public boolean isAccountNonLocked() {
return removedAt == null;
}
@Override
@JsonIgnore
public boolean isCredentialsNonExpired() {
return removedAt == null;
}
@Override
@JsonIgnore
public boolean isEnabled() {
return removedAt == null;
}
}
CSRF
CSRF 토큰 얻는 법
Spring Security는 CSRF 공격에 대비해 POST, PUT 요청 등에 CSRF 토큰이 필요하도록 자동 설정이 되어있음.
그렇기에 요청을 주고받을 수 있도록 CSRF 토큰을 얻어야 한다.
@GetMapping("/csrf-token")
public CsrfToken retrieveCsrfToken(HttpServletRequest request) {
return (CsrfToken) request.getAttribute("_csrf");
}
이렇게 얻은 토큰은 헤더에 X-CSRF-TOKEN로 실어서 보내면 된다.
SameSite 속성 설정
쿠키가 다른 사이트에서 시작된 요청에는 전송되지 않도록 하여 CSRF 공격에 대비할 수 있음
// application.properties
server.servlet.session.cookie.same-site=strict
Spring Security Filter 기타 설정들
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class BasicAuthenticationSecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// OPTIONS 요청을 제외(인증API에 필요)한 모든 요청에 인증 필요
http.authorizeHttpRequests(
auth -> auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll().anyRequest().authenticated()
);
// 팝업으로 뜨도록 설정
http.httpBasic(withDefaults());
// HTML 프레임 사용 해제 옵션 해제
http.headers(headers -> headers.frameOptions(frameOptionsConfig-> frameOptionsConfig.disable()));
// Jwt를 위한 OAuth2 리소스 서버 설정
http.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
// 소셜 로그인을 위한 OAuth2 설정
http.oauth2Login(withDefaults());
return http.build();
}
}
메소드 보안 설정
아래 방법들 모두 중복 적용 가능
1. @PreAuthorize, @PostAuthorize어노테이션 사용 (권장)
1) 설정 클래스에 @EnableMethodSecurity 어노테이션 추가
@Configuration
@EnableMethodSecurity
public class BasicAuthenticationSecurityConfiguration {
2) 메소드에 접근 조건 추가
@GetMapping("/users/{username}/todos")
@PreAuthorize("hasRole('USER') and #username == authentication.name")
@PostAuthorize("returnObject.username == 'yhhnnmm'")
public Todo retrieveTodosForSpecificUser(@PathVariable String username) {
return TODOS_LIST.get(0);
}
2. JSR-250 어노테이션 사용
1) @EnableMethodSecurity 어노테이션에 설정 추가
@Configuration
@EnableMethodSecurity(jsr250Enabled = true)
public class BasicAuthenticationSecurityConfiguration {
2) 메소드에 접근 조건 추가
@GetMapping("/users/{username}/todos")
@RolesAllowed({"ADMIN", "USER"})
3. @Secured 어노테이션 사용
1) @EnableMethodSecurity 어노테이션에 설정 추가
@Configuration
@EnableMethodSecurity(securedEnabled = true)
public class BasicAuthenticationSecurityConfiguration {
2) 메소드에 접근 조건 추가
@GetMapping("/users/{username}/todos")
@Secured({"ROLE_ADMIN", "ROLE_USER"})
OAuth2를 이용한 JWT
JWT 토큰이 유효한지 디코딩하는 작업
1. RSA키 쌍 만들기
2. RSA 키 객체 만들기
3. JWKSource (JSON Web Key source) 만들기
4. 디코딩을 위한 RSA 공개키 만들기
JWT 인코딩 메소드 만들기
// Spring Security 설정 클래스
// 1. RSA키 쌍 만들기
@Bean
public KeyPair keyPair() {
try {
var keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
// 2. RSA 키 객체 만들기
@Bean
public RSAKey rsaKey(KeyPair keyPair) {
return new RSAKey
.Builder((RSAPublicKey)keyPair.getPublic())
.privateKey(keyPair.getPrivate())
.keyID(UUID.randomUUID().toString())
.build();
}
// 3. JWKSource (JSON Web Key source) 만들기
@Bean
public JWKSource<SecurityContext> jwkSource(RSAKey rsaKey) {
var jwkSet = new JWKSet(rsaKey);
return (jwkSelector, context) -> jwkSelector.select(jwkSet);
}
// 4. 디코딩을 위한 RSA 공개키 만들기
@Bean
public JwtDecoder jwtDecoder(RSAKey rsaKey) throws JOSEException {
return NimbusJwtDecoder
.withPublicKey(rsaKey.toRSAPublicKey())
.build();
}
// JWT 인코딩 메소드 만들기
@Bean
public JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) {
return new NimbusJwtEncoder(jwkSource);
}
JWT 토큰화하기
@RestController
public class JwtAuthenticationResource {
private JwtEncoder jwtEncoder;
public JwtAuthenticationResource(JwtEncoder jwtEncoder) {
this.jwtEncoder = jwtEncoder;
}
@PostMapping("/authenticate")
public JwtRespose authenticate(Authentication authentication) {
return new JwtRespose(createToken(authentication));
}
private String createToken(Authentication authentication) {
var claims = JwtClaimsSet.builder()
.issuer("self")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(60 * 30)) // 만료시간 설정
.subject(authentication.getName())
.claim("scope", createScope(authentication))
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims))
.getTokenValue();
}
private String createScope(Authentication authentication) {
return authentication.getAuthorities().stream()
.map(a -> a.getAuthority())
.collect(Collectors.joining(" "));
}
}
record JwtRespose(String token) {}
'Java' 카테고리의 다른 글
Junit5 (0) | 2024.02.17 |
---|---|
스프링 부트 서버에 https 설정 (0) | 2024.01.23 |
[Spring Boot] 심화 (0) | 2023.12.28 |
HATEOAS (1) | 2023.12.26 |
Swagger (0) | 2023.12.26 |