Spring Security 설정하기
Spring Security 설정하기
implementation 'org.springframework.boot:spring-boot-starter-security'
XML
복사
이와 같이 Spring Security에 대한 의존성을 추가하고,
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/h2-console/**").permitAll()
.requestMatchers("/user-service/**").permitAll())
.headers(header -> header.frameOptions(FrameOptionsConfig::disable))
.build();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Java
복사
이와 같이 Spring Security에서 사용할 설정에 대해 정의하는 Configuration 클래스를 등록해준다. Security 설정을 조금 더 자세히 보자면,
•
csrf(AbstractHttpConfigurer::disable)
CSRF 공격에 대한 protection 기능을 끄는 설정이다. CSRF protection을 끄는 이유는 서버가 REST API 서버이고, 인증 정보로 JWT 토큰을 사용할 것이라서 CSRF protection 기능이 굳이 필요없다.
CSRF
Cross-Site Request Forgery의 약자로, 사이트 간 요청 위조를 말한다. 웹 애플리케이션 특성상 생성된 요청이 사용자의 동의를 받았는지 알 수 없기 때문에, 공격자의 요청이 사용자의 요청인 것처럼 속여 공격하는 방식이다. 보통 데이터의 값을 변경하는 요청에 사용되며, 자금 송금이나 로그인 정보 변경 등의 민감한 요청을 위조하여 이메일이나 웹 사이트에 하이퍼링크로 심어놓고 사용자가 해당 링크를 클릭하여 요청이 전송되는 형태이다.
CSRF 공격을 방지하는 방법은 CSRF 토큰을 통해 요청이 사용자가 전송한 것이 맞는지 확인하거나 재인증을 요구하는 등의 방법이 있다.
XSS
Cross Site Scripting의 약자로, CSS가 Cascading Style Sheets의 약자로 이미 존재하기 때문에 XSS라 한다. 게시판이나 메일 등에 javascript와 같은 스크립트 코트를 삽입해, 개발자가 고려하지 않은 기능이 작동하게 하는 공격 방식이다. 대부분의 웹 해킹 공격과는 다르게 사용자를 대상으로 하여, 쿠키나 세션 ID를 탈취하거나 시스템 관리자 권한을 획득하거나 악성 코드를 다운로드, 거짓 페이지 노출 등의 공격을 한다.
XSS 공격을 방지하는 방법은 모든 입력값에 대해 필터링을 통한 검증을 수행하거나, ORM 등의 기술을 사용하면 ORM 내부적으로 XSS 공격에 대해 방지하도록 처리 되어있다.
•
requestMatchers(”…”).permitAll()
requetMatchers는 인자로 넘기는 표현식에 매칭되는 uri에서 온 요청에 대해 인가 설정을 구성하는데 사용된다. 위 예시에서는 h2 데이터베이스 상태를 보기 위한 h2-console과 user-service로 시작되는 요청들의 모든 uri에 대해 허용한다는 의미이다.
•
header.frameOptions(FrameOptionsConfig::disable)
Spring Security에서는 X-Frame-Options Click jacking 공격을 막기 위해 기본적으로 X-Frame-Options이 기본적으로 deny로 되어있다.
때문에 이처럼 h2 데이터베이스의 console의 frame이나 iFrame을 띄우는 형태의 프레임 표시 요청을 거부하여 보이지 않게된다.
위의 FrameOption을 disable 하는 것은 이를 해결하기 위해 프레임 표시 요청을 거부하는 것이 아니라 처리하도록 설정하는 것이지만, 그로 인해 공격에 취약점을 가지게 된다.
X-Frame-Options Click jacking
frame이나 iframe 등으로 다른 서버에 위치한 페이지를 삽입하여 공격하는 방식이다. 링크를 눌렀을 때 의도했던 것과 다른 동작을 하기 때문에 Click jacking이라 부르고, 웹 페이지를 공격에 필요한 형태로 조작하기 때문에 User Interface redress 공격이라고도 부른다.
동작 확인
이와 같이 user-service로 시작되는 uri에 대해서는 Gateway를 통한 요청이나 직접 포트에 요청을 보내도 잘 처리가 되지만,
그 외의 요청에 대해서는 404 Not Found가 뜨는 것이 아니라 403 Forbidden으로 자동으로 요청을 거부하게 된다.
로그인 기능 구현하기
BCrypt 암호화
사용자의 암호를 데이터베이스에 그대로 저장하는 것은 보안상 취약하기 때문에, bcryt로 암호화를 한 후에 저장하게 된다.
// User Service
@Autowired
private final BCryptPasswordEncoder passwordEncoder;
...
public void createUser(UserRequest userRequest) {
...
String encrytedPassword = passwordEncoder.encode(userDto.getPassword());
userEntity.setEncryptedPassword(encrytedPassword);
userRepository.save(userEntity);
}
Java
복사
위의 SecurityConfig에서 빈으로 등록한 BCryptPasswordEncoder를 의존성 주입받아 이와 같이 사용하면 된다.
이처럼 서로 다른 3명의 유저를 동일한 password로 저장하였지만,
실제 데이터베이스에는 랜덤 salt 값으로 bcrypt 암호화하여 서로 다르게 저장된다.
회원 로그인 구현하기
@Data
public class RequestLogin {
@NotNull(message = "Email cannot be null")
@Size(min = 2, message = "Email cannot be less than two characters")
@Email
private String email;
@NotNull(message = "password cannot be null")
@Size(min = 8, message = "Email must be equal or greater than 8 characters")
private String password;
}
Java
복사
이와 같이 사용자가 로그인을 위해 전달하는 데이터를 받을 DTO에 Validation을 추가하여 작성해둔다.
이후 username과 password를 받아서 로그인 기능을 처리하는 필터를 추가해야하는데, 우리는 로그인 성공 시 JWT 토큰을 발급하고 실패 시 에러를 던지도록 하는 커스텀 필터를 만들기 위해, 아래와 같이 UsernamePasswordAuthenticationFilter 클래스를 상속받아 필요한 메서드를 재정의하여 직접 구현한다.
@RequiredArgsConstructor
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
...
}
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult) {
...
}
}
Java
복사
인증 시도 시 검증 과정과 인증 성공 이후 JWT 토큰 발급을 위해 attemptAuthentication 메서드와 successfulAuthentication 메서드를 재정의하였다.
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
try {
RequestLogin creds = objectMapper.readValue(request.getInputStream(), RequestLogin.class);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
creds.getEmail(),
creds.getPassword(),
new ArrayList<>()
)
);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
Java
복사
인증 시도 시 검증 로직은 위와 같다. 먼저 사용자가 입력한 이메일과 비밀번호를 HttpServletRequest로 꺼내어 ObjectMapper를 통해 아까 만들어둔 LoginRequet DTO로 변환한다. 이후 DTO에 저장되어 있는 이메일과 비밀번호를 통해 AuthenticationToken을 생성하고, 필터에 등록되어 있는 AuthenticationManager에게 검증을 요청한다.
AuthenticationToken을 생성하는 것은 우리가 알고 있는 Email과 Password를 Spring Security에서 사용할 수 있는 형태로 바꾸는 작업이다. 토큰 생성 시 세 번째 인자로 넘기는 빈 리스트는 권한에 대한 리스트이다.
이와 같이 인증을 요청하게 되면, 여기서 생성한 토큰을 이후에 등록할 AuthenticationFilter와 ProviderManager에 전달하여 인증을 수행하게 된다.
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult) {
String email = authResult.getName();
UserDto userDetails = userService.getUserDetailsByEmail(email);
String token = Jwts.builder()
.setSubject(userDetails.getUserId())
.setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getExpirationTime()))
.signWith(SignatureAlgorithm.HS512, jwtProperties.getSecret())
.compact();
response.addHeader("access_token", token);
response.addHeader("userId", userDetails.getUserId());
}
Java
복사
인증 성공 시에는 인증 결과에서 이메일을 꺼내어 이메일 정보로 유저를 조회한다. 맞는 유저를 찾은 후 유저의 ID와 유효 시간을 담은 JWT 토큰을 생성하여 응답의 헤더에 추가하여 클라이언트에 보낸다. 여기서는 구현하지 않고 postman으로 테스트 해보겠지만, 클라이언트에서는 받은 JWT 토큰을 통해 이후 요청 시 요청 헤더에 토큰 정보를 담아서 같이 요청을 보내게 된다.
UserService에 Security 기능 구현하기
위와 같이 인증 필터를 만들었다고 해서 필터를 추가하면 바로 인증이 되지 않는다. 인증을 위해 UserService에 Security에서 동작할 수 있는 기능을 추가해주어야 한다. 위 필터의 인증 로직은 email과 password를 통해 토큰을 생성하여 넘기고, 해당 토큰을 AuthenticationManager가 받아 인증을 수행하게 된다. 이 AuthenticationManager가 사용할 메서드를 추가해주는 과정이다.
AuthenticationManager는 인증 시 내부적으로 토큰에서 username을 꺼내어 기준이 되는 User를 찾고, 찾은 User에서 password를 비교하여 일치 여부를 확인한다.
public interface UserService extends UserDetailsService {
...
}
@Service
public class UserServiceImpl implements UserService {
...
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity userEntity = userRepository.findByEmail(username);
if (userEntity == null) {
throw new UsernameNotFoundException(username);
}
return User.builder()
.username(userEntity.getEmail())
.password(userEntity.getEncryptedPassword())
.build();
}
}
Java
복사
먼저 Security에서 UserService를 통해 User를 찾을 수 있도록 하기 위해, UserService 인터페이스가 UserDetailsService를 상속하도록 한다. 그리고 UserService의 구현체에 loadUserByUsername 메서드를 재정의해준다.
이 loadUserByUsername 메서드는 username(여기에서는 email)을 받아 Security에서 사용하는 UserDetails 객체를 반환하는 메서드이므로, 우리의 데이터베이스에서 email로 유저를 검색하고 해당 유저의 username(email)과 BCrypt 암호화된 비밀번호로 UserDetails 객체를 생성하여 반환하도록 구현한다.
이와 같이 작성해두면 AuthenticationManager는 토큰에서 username을 꺼내 userService의 loadUserByUsername 메서드로 UserDetails 정보를 확인한다. 이 때 우리가 미리 넘겨준 BCryptEncoder를 통해 토큰의 비밀번호를 암호화하고 UserDetails랑 비교하여 일치 여부를 통해 인증을 승인하거나 거절한다.
인증 필터 등록하기
이전에 SecurityFilterChain을 통해서 특정 uri를 가진 요청만 받도록 필터링한 적이 있다. 이를 특정 IP에서 보내는 요청만 받도록 수정하고, 아까 만들었던 인증 필터를 추가해보자.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
public static final IpAddressMatcher ipAddressMatcher = new IpAddressMatcher("192.168.0.4");
private AuthorizationDecision hasIpAddress(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
return new AuthorizationDecision(ipAddressMatcher.matches(object.getRequest()));
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/**").access(this::hasIpAddress))
.addFilter(getAuthenticationFilter())
.headers(header -> header.frameOptions(FrameOptionsConfig::disable))
.build();
}
@Bean
public AuthenticationFilter getAuthenticationFilter() {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userService);
authenticationProvider.setPasswordEncoder(bCryptPasswordEncoder);
AuthenticationManager authenticationManager = new ProviderManager(authenticationProvider);
AuthenticationFilter authenticationFilter = new AuthenticationFilter(userService, jwtProperties);
authenticationFilter.setAuthenticationManager(authenticationManager);
return authenticationFilter;
}
}
Java
복사
먼저 이처럼 IpAddressMatcher에 미리 통과시킬 IP 주소를 저장해두고, IP 매칭 검사를 수행하는 메서드를 하나 추가해준다. 이후 들어오는 모든 요청에 대해 검사를 수행하도록 requestMatchers(”/**”)로 추가해준다.
이후 아까 만들었던 인증 필터를 추가해줘야 하는데, Security 내에서 UserDetails 검색과 암호화를 처리하기 위해 authenticationProvider를 새로 생성하여 loadUserByUsername 메서드를 만들어둔 userService와 암호화 할 BCyrptEncoder를 넣어준다. 이렇게 설정해놓은 Provider로 Manager를 생성한 후, 아까 만들어둔 인증 필터를 생성하면서 필터의 AuthenticationManager로 등록한다.
이와 같이 등록하고 나면, 192.168.0.4의 IP 주소에서 오는 요청들에 대해 필터링 되고, ~/login uri로 POST 요청을 보내면 메세지 body의 email과 password를 통해 아까 등록해둔 인증 필터가 동작하여 성공 시 응답으로 JWT 토큰을 헤더에 넣어 보낸다.
이는 Spring Security에서 지원하는 기능으로, 우리가 따로 login API를 만들지 않아도 해당 uri로 요청을 보내면 우리가 만든 인증 필터의 attemptAutentication 메서드가 호출된다. 이후 Spring Security 내부적으로 loadUserByUsername 메서드를 통한 유저 검색 → BCryptEncoder를 통한 암호 비교 → 성공 시 successfulAuthentication 메서드 호출을 통해 JWT 토큰 생성 순으로 이루어진다.
Gateway에 필터 추가하기
User 마이크로 서비스에 ~/login API로 인증할 수 있는 기능을 추가했으니, Gateway에서 인증이 필요한 부분과 필요 없는 부분을 나누어 필터를 적용해야한다.
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/login
- Method=POST
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/users
- Method=POST
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/**
- Method=GET
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
- AuthorizationHeaderFilter
YAML
복사
이처럼 login 시도에 대한 요청이나 회원 가입에 대한 요청은 별도의 Filter 없이 요청을 보내고, 그 외의 모든 User 마이크로 서비스의 기능들은 JWT 토큰을 뜯어보고 인증 여부를 확인하는 별도의 AuthorizationHeaderFilter를 거치도록 만들 것이다.
여기서 RewritePath 설정은 ~/user-service/** 요청이 들어오면, Gateway가 user-service에 요청을 전달할 때는 ~/**처럼 중간의 user-serivce 부분을 제거하여 경로를 다시 설정하는 옵션이다.
@Component
@Slf4j
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
private final JwtProperties jwtProperties;
public static class Config {
}
@Autowired
public AuthorizationHeaderFilter(JwtProperties jwtProperties) {
super(Config.class);
this.jwtProperties = jwtProperties;
}
@Override
public GatewayFilter apply(Config config) {
return ((exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
return onError(exchange, "no authorization header", HttpStatus.UNAUTHORIZED);
}
String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
String jwt = authorizationHeader.replace("Bearer ", "");
if (!isJwtValid(jwt)) {
return onError(exchange, "JWT token is not valid", HttpStatus.UNAUTHORIZED);
}
return chain.filter(exchange);
});
}
private boolean isJwtValid(String jwt) {
String subject = null;
try {
subject = Jwts.parser().setSigningKey(jwtProperties.getSecret())
.parseClaimsJws(jwt).getBody().getSubject();
} catch (Exception e) {
return false;
}
return !StringUtil.isNullOrEmpty(subject);
}
private Mono<Void> onError(ServerWebExchange exchange, String message, HttpStatus httpStatus) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
log.error(message);
return response.setComplete();
}
}
Java
복사
Gateway에서 JWT 토큰으로 수행하는 인증은 위와 같이 요청에 JWT 토큰이 들어있는지 확인 후, JWT 토큰을 열어 parser를 통해 내용물을 파싱하여 이루어진다. 그 과정에서 expiration의 만료 여부나 signature를 통해 위변조 여부를 내부적으로 검증하게 된다.
위 로직에서는 내용물이 null이거나 빈 문자열이 아니면 인증을 통과하도록 구성하였지만, userId와 같은 정보를 같이 담고 userId의 일치 여부를 확인하는 등의 별도의 검증 로직을 추가해주면 좋다.
JWT 인증 과정
동작 확인하기
먼저 회원 가입을 시도한다.
이처럼 별도의 인증 없이 잘 생성되어 응답이 오는 것을 확인할 수 있다.
회원 가입 때 입력한 email과 password를 통해 로그인을 시도하면,
email과 password가 맞다면 이처럼 응답 헤더에 JWT 토큰이 담겨서 오게 된다.
유저 전체 목록을 조회하는 API를 JWT 토큰 없이 요청을 보내면,
이처럼 401 Unauthorized가 발생한다. 하지만 아까 받은 JWT를 Authorization에 Bearer Token으로 담아서 요청을 보내게 되면,
이와 같이 JWT 토큰으로 Gateway 인증을 통과하여 요청을 잘 수행한다.