
사이트간 요청 위조 → 공격자가 사용자의 요청을 위조하여 공격하는 방법
CSRF 성공 조건
1. 사용자가 이미 보안이 취약한 서버에 로그인 되어있는 상태이어야 한다.
2. 쿠키 기반의 서버 세션 정보를 획득할 수 있어야 한다.
3. 공격자는 서버를 공격하기 위해서 요청 방법에 대해 미리 파악하고 있어야 한다.
Token 인증 방식을 주로 활용하는 REST API 서버에 CSRF 공격을 성공하기는 어렵다.
build.gradle 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
SecurityConfig 설정 컴포넌트 생성
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.cors(withDefaults());
// CSRF 보안 disable
http.csrf(AbstractHttpConfigurer::disable);
// REST API 는 무상태성 이다. -> session : STATELESS
http.sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 모든 요청에 대해서 인증정보가 필요하다.
http.authorizeHttpRequests((authorizeRequests) ->
authorizeRequests
.anyRequest().authenticated()
);
return http.build();
}
// CORS 허용
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOriginPattern("*");
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// 모든 API Endpoint 에 동일한 configuration 적용
source.registerCorsConfiguration("/**", configuration);
return source;
}
}/user/login (서버 로그인 API)으로 사용자 ID/PW 정보를 요청 본문에 넣어서 요청한다.@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
// JWT 관련 처리를 수행하는 Util 클래스
private final JwtProvider jwtProvider;
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.cors(withDefaults());
http.csrf(AbstractHttpConfigurer::disable);
http.sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 로그인 요청에 대해서는 인증 권한이 필요 없음
http.authorizeHttpRequests((authorizeRequests) ->
authorizeRequests
.requestMatchers("/user/login").permitAll()
.anyRequest().authenticated()
);
// JwtAuthFilter 는 UsernamePasswordAuthenticationFilter 이전에 위치한다.
http.addFilterBefore(new JwtAuthFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {...}
}@Component
public class JwtProvider {
// secretKey 를 활용한 Key 생성
private final Key key;
public JwtProvider(@Value("${jwt.secret.key}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
/**
* email, authorityList 을 가지고 AccessToken, RefreshToken 을 생성
*/
public TokenInfoResponse generateToken(String email, List<String> authorityList) {...}
/**
* JWT 생성
*/
public String generateJWT(String subject, List<String> authorityList, String type, Date issuedAt, long expireTime) {...}
/**
* Jwt 토큰을 복호화
*/
private Claims parseClaims(String token) {...}
/**
* JWT 토큰을 복호화하여 토큰에 들어있는 정보를 추출하여 Authentication 생성
*/
public Authentication getAuthentication(String jwtToken) {...}
/**
* JWT 검증 수행
*/
public boolean validateToken(String token) {...}
/**
* JWT 타입 검증
*/
public void validateTokenType(String token, String tokenType) {...}
/**
* JWT 잔여 유효기간
*/
public Long getExpiration(String token) {...}
/**
* JWT 타입 추출
*/
public String getType(String token) {...}
/**
* Request Header 에서 토큰 정보 추출
*/
public String resolveToken(HttpServletRequest request) {...}
}
@RequiredArgsConstructor
public class JwtAuthFilter extends GenericFilterBean {
private final JwtProvider jwtProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 요청 헤더에 있는 JWT 를 추출한다.
String token = jwtProvider.resolveToken((HttpServletRequest) request);
// JWT 존재 시, JWT 검증 수행
if (token != null && jwtProvider.validateToken(token)) {
// JWT가 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
request.setAttribute("resolvedToken", token);
}
// 다음 필터로 넘어감
chain.doFilter(request, response);
}
}
@Component
public class JwtProvider {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_TYPE = "Bearer";
// secretKey 를 활용한 Key 생성
private final Key key;
public JwtProvider(@Value("${jwt.secret.key}") String secretKey) {...}
...
/**
* Request Header 에서 토큰 정보 추출
*/
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
try {
return bearerToken.substring(7);
} catch (StringIndexOutOfBoundsException e) {
throw new CustomCommonException(UserErrorCode.MISSING_JWT);
}
}
return null;
}
}@RequiredArgsConstructor
@RequestMapping("/user")
@RestController
public class UserAuthAPI {
private final UserAuthService userAuthService;
@PostMapping("/login")
public ResponseEntity<?> loginBasic(HttpServletRequest httpServletRequest, @RequestBody @Valid LoginRequest request) {
// 로그인 요청 검증 수행 -> ID/PW 인증
Authentication authentication = userAuthService.authenticateBasic(request);
// 로그인 비즈니스 로직 수행 (JWT 생성 및 반환)
return ResponseEntity.ok(userAuthService.login(httpServletRequest, authentication));
}
}
@RequiredArgsConstructor
@Service
public class UserAuthService {
private final AuthenticationManager authenticationManager;
public Authentication authenticateBasic(LoginRequest request) {
// 로그인 요청 데이터(ID/PW)를 갖고 인증정보가 없는 Authentication 생성
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(request.email, request.password);
// AuthenticationManager 를 통해 ID/PW 인증 수행
Authentication authentication = authenticationManager.authenticate(authenticationToken);
return authentication;
}
}
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
private final UserEntityRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity user = userRepository.findFirstByEmail(username).orElseThrow(() -> new UsernameNotFoundException(UserErrorCode.INVALID_CREDENTIALS.getMessage()));
return createUserDetails(user);
}
// 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 리턴
private UserDetails createUserDetails(UserEntity user) {
return new User(user.getEmail(), user.getPassword(), user.getAuthorities());
}
}@RequiredArgsConstructor
@Service
public class UserAuthService {
private final JwtProvider jwtProvider;
private final AuthenticationManager authenticationManager;
private final RefreshTokenRedisRepository refreshTokenRedisRepository;
public TokenInfoResponse login(HttpServletRequest httpServletRequest, Authentication authentication) {
// 1. Authentication 으로부터 권한 목록을 추출한다.
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
List<String> authorityList = authorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
// 2. Authentication 정보 (ID, 권한 목록)을 활용하여 JWT (AccessToken, RefreshToken)을 생성한다.
TokenInfoResponse response = jwtProvider.generateToken(authentication.getName(), authorityList);
// 3. RefreshToken 을 Redis 에 저장
refreshTokenRedisRepository.save(RefreshToken.builder()
.id(authentication.getName())
.ip(NetworkUtil.getClientIp(httpServletRequest))
.authorityList(response.authorityList())
.refreshToken(response.refreshToken())
.build());
return response;
}
}
@Component
public class JwtProvider {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_TYPE = "Bearer";
public static final String TYPE_ACCESS = "access";
public static final String TYPE_REFRESH = "refresh";
public static final long ACCESS_TOKEN_EXPIRE_TIME = 60 * 60 * 1000L; //60분 -> 1000L -> 1초 for java
public static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000L; //7일
private final Key key;
public JwtProvider(@Value("${jwt.secret.key}") String secretKey) {...}
...
/**
* email, authorityList 을 가지고 AccessToken, RefreshToken 을 생성
*/
public TokenInfoResponse generateToken(String email, List<String> authorityList) {
Date now = new Date();
// Access JWT Token 생성
String accessToken = generateJWT(email, authorityList, TYPE_ACCESS, now, ACCESS_TOKEN_EXPIRE_TIME);
// Refresh JWT Token 생성
String refreshToken = generateJWT(email, authorityList, TYPE_REFRESH, now, REFRESH_TOKEN_EXPIRE_TIME);
return TokenInfoResponse.builder()
.authorityList(authorityList)
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpirationTime(ACCESS_TOKEN_EXPIRE_TIME)
.refreshToken(refreshToken)
.refreshTokenExpirationTime(REFRESH_TOKEN_EXPIRE_TIME)
.build();
}
/**
* JWT 생성
*/
public String generateJWT(String subject, List<String> authorityList, String type, Date issuedAt, long expireTime) {
return Jwts.builder()
.setSubject(subject)
.claim(AUTHORITY_KEY, authorityList)
.claim(TYPE_KEY, type)
.setIssuedAt(issuedAt)
.setExpiration(new Date(issuedAt.getTime() + expireTime)) //토큰 만료 시간 설정
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
...
}{
"authorityList": [
"ROLE_FACTORY"
],
"grantType": "Bearer",
"accessToken": "{AccessToken JWT}",
"accessTokenExpirationTime": 3600000, // 60분
"refreshToken": "{RefreshToken JWT}",
"refreshTokenExpirationTime": 604800000 // 7일
}