
현재 Gateway패턴으로 시스템을 구현해보고 SCDF Stream 배포도 샘플 코드이지만 구현해보았다.
본격적인 기능 구현 전에 시스템을 탄탄하게 하고 싶어 로그인 부분을 보완하기로 하였다.
user-service에서 spring-security를 활용한 로그인 기능이 있는데 로그인 요청 시 다음과 같은 절차를 따른다.
기본적인 Spring Security 로직을 수행하며 successfulAuthentication 실행 시 JWT를 발급하여 redis와 응답 헤더에 저장한다.(JWT 간단히 정리해봄)
이후 사용자의 api 호출 시, Gateway에서는 요청 헤더의 토큰값을 Redis에 저장된 토큰과 비교하여 api 라우팅 처리 여부를 결정한다.
JWT와 Redis를 활용한 이유는 MSA 구조에서 User-service가 인증/인가 역할을 수행하고 Gateway는 API 요청에 대한 권한 검증을 User-service에 의존하지 않고 수행할 수 있기 때문이다.
[코드]
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
log.info("[SecurityLoginFilter] Start Login");
//클라이언트 요청에서 username, password 추출
String username = obtainUsername(request);
String password = obtainPassword(request);
//스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 함
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);
//token에 담은 검증을 위한 AuthenticationManager로 전달
return authenticationManager.authenticate(authToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException {
log.info("[SecurityLoginFilter] Login Success");
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
// 토큰 발급
String access = jwtUtil.createAccessToken(username, role);
String refresh = jwtUtil.createRefreshToken(username, role);
log.info("[SecurityLoginFilter] Create Token Complete");
// 리프레시 토큰 저장
tokenService.saveRefresh(username, refresh);
log.info("[SecurityLoginFilter] Save Token Complete");
// 응답 반환
responseUtil.addHeader(response, jwtProperty.getAccessKey(), jwtProperty.getPrefix() + access);
responseUtil.addCookie(response, jwtProperty.getRefreshKey(), refresh, jwtProperty.getRefreshAgeMS());
responseUtil.setResponse(
response,
HttpStatus.OK,
ApiResponse.success());
log.info("[SecurityLoginFilter] End Login Process");
}
이 정도만 해도 괜찮은 로직 같다.
하지만 조금 더 보완하고 싶었던 이유는 토큰 하나만 있으면 모든 API를 활용할 수 있다는 점이 보안상 취약하다고 느껴졌기 때문이다. 이에 대해 기본적으론 accessToken과 refreshToken 두개의 토큰을 발급하고 그 외에 여러가지 고민을 해보았다. accessToken과 refreshToken을 활용하는 로직은 다음과 같다.
Authorization: Bearer <token> 형식으로 저장이를 통해 클라이언트는 편리하게 사용자에게 기능을 제공할 수 있고 refreshToken이 지속적으로 갱신되기 때문에 무한 자동 로그인이 가능하다.
무한 자동 로그인? 편리한 기능이고 기본적으로 제공되는 브라우저 기능 등으로 보편적인 보안문제들은 방어가 되겠지만 그럼에도 불구하고 여전히 토큰 두개를 모두 손에 넣는다면 시스템에 마음껏 접근할 수 있다고 생각하니 여러가지 보안을 강화할 수 있는 방안을 고민하지 않을 수 없었다.
IP 검증
JWT Claim 또는 저장소를 활용하여 로그인 시 IP를 저장하도록 하고 API 호출 시 요청 IP와 비교하도록 하여 강화한다.
=> Claim에 포함하게 되면 IP를 알아낼 수 있기 떄문에 Redis 등 저장소에 저장하는 것이 좋겠다고 생각하였다. 하지만 어떻게 해서든 IP를 알아내서 우회 접근하는 경우엔? 다른 보안 강화 정책이 필요할 것이다.
토큰 블랙리스트
accessToken을 매 요청 시 재발급하고 한번 사용 된 accessToken은 블랙리스트에 저장한다.
=> 요청 시 블랙리스트를 조회하여 검증하면 토큰을 탈취 당해도 문제가 되지 않는다. 하지만 복잡성 증가와 성능 저하의 문제가 있다. 개인정보 등 보안이 중요한 정보를 사용하는 시스템일 경우 성능을 조금 포기하더라고 보안을 강화할 수 있는 좋은 방법이기 때문에 성능 문제에 대한 테스트를 충분히 거쳐 적용하면 좋을 것 같다.
MFA
많은 사이트들에서 적용하고 있는 방법으로 휴대폰 인증번호 인증, OTP 등의 방법으로 사용자의 개인 디바이스를 통한 인증 과정을 추가하여 보안을 강화하는 방법이다.
=> 확실하고 강력한 방법이라고 생각하여 추후 적용해보면 좋을 것 같다.
토큰 암호화
JWT는 기본적으로 Base64로 인코딩 되어 있는데 이는 누구나 쉽게 디코딩할 수 있다. 그렇기 때문에 한번 더 암호화된 토큰을 사용하면 토큰의 내용을 누구나 쉽게 볼 수 없기 때문에 보안을 강화 할 수 있다.
=> 토큰에 중요 정보가 포함되어 있다면 적용하는 것이 좋겠다.
정확한 출처를 기반으로 정리한 것이 아닌 개인적인 지식과 실무 경험을 통해 방안을 고민해보며 ChatGPT 질문으로 정리해본 내용이다. 정리하면서 여러가지 보안 이슈에 대해 알 수 있었고 왜 보안 전문가가 따로 있는지 이해하게 되는 시간이었다. 하지만 보안 전문가에 의존하는 것이 아닌 개발자로서 이러한 보안 이슈들을 생각하며 개발한다면 더 좋은 개발자가 될 수 있을 것 같다.
이제 User-service를 마무리 짓고 다음 기능을 추가해야하기 때문에 남은 기능 구현 및 리팩토링(사용자 리스트 조회 시 검색 기능, 사용자 삭제 API 호출 시 소프트 삭제 및 삭제 Batch, 사용자 조회 시 Active/Inactive 상태 확인 등)을 진행하려고 한다.
하나의 도메인을 가지고 확장성과 유지보수성, 통일성 등을 깊게 생각하며 개발하다보니 단순한 CRUD의 반복이라 여겨졌던 일들이 다르게 느껴지기 시작했다. 하나의 마이크로 서비스를 만들기 이전에 기반이 되는 환경을 만드는 일이 필요하고 조금 더 편리한 환경을 만들기 위한 기술적 고민과 적용이 필요한 것을 느꼈다. 이는 곧 앞으로의 시스템 안정성에 직접적인 연관이 있다고도 느껴져 설계의 중요성도 알 수 있었던 경험이었다.