개요

이번 프로젝트는 MSA 구조에 대해서 공부하는 것뿐만 아니라 여러 최적화도 적용해보기로 했기 때문에 서버측에 부담이 덜 갈 수 있도록 JWT를 이용하기로 했다. 일단 액세스 토큰만 구현하고 리프레시 토큰은 나중에 구현하기로 했다. 생각한 구조는 위와 같다.
- 클라이언트에서 서버로 요청을 보낸다.
- 게이트웨이에서 로그인 서버로 라우팅 (그림에서 서비스 디스커버리 등은 표시 생략했다.)
- 로그인 서비스에서 OAuth2로 네이버, 카카오, 구글 로그인을 할 수 있도록 함 (자세한 과정 또한 표시 생략했다.)
- 로그인 과정에서 유저가 이미 회원가입을 한 상태인지 확인함 / 이미 유저가 있으면 해당 정보를 가지고 옴
- 로그인이 완료되면 게이트웨이로 보냄
- 홈화면으로 리다이렉트함
실제로 구현한 내용은 다음과 같다.
1. 클라이언트에서 서버 요청 보냄
그냥 브라우저에서 실행하면 된다.
2. 게이트웨이에서 로그인 서버로 라우팅
spring:
cloud:
routes:
# Login-Service
- id: login-service
uri: lb://LOGIN-SERVICE
predicates:
- Path=/login, /oauth2/**
- Method=GET
filters:
- RemoveRequestHeader=Cookie
위와 같은 내용을 gateway-service의 application.yaml에 추가한다. 코드 칸에 작성하니까 들여쓰기 정도가 이상하게 표시되는거 같은데 잘 맞춰줘야 제대로 작동한다.

user-service에도 필요한 부분을 작성해 준다.
3 ~ 6. 로그인 서비스에서 OAuth2
@RequiredArgsConstructor
public class CustomOAuth2User implements OAuth2User {
private final OAuth2Response oAuth2Response;
private final RequestRegisterUser user;
@Override
public Map<String, Object> getAttributes() {
return null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Set.of(new SimpleGrantedAuthority(user.getStatus().name()));
}
@Override
public String getName() {
return oAuth2Response.getName();
}
public String getUserId() {
return user.getUserId();
}
public String getProviderId() {
return oAuth2Response.getProviderId();
}
}
OAuth2 Authentication 객체에서 사용할 클래스이다. OAuth2User를 구현하고, 추가로 필요한 정보를 반환하는 Getter를 만들어 주었다.
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserClient userClient;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2Response response = switch (registrationId) {
case "naver" -> new NaverResponse(oAuth2User.getAttributes());
case "kakao" -> new KakaoResponse(oAuth2User.getAttributes()); // 카카오는 권한이 없어서 이름 대신 닉네임으로 설정
case "google" -> new GoogleResponse(oAuth2User.getAttributes());
default -> null;
};
if (response == null) throw new OAuth2AuthenticationException("로그인 실패");
RequestRegisterUser user = null;
ResponseCheckUser result = userClient.isUserAlreadyRegistered(new RequestCheckUser(response.getProviderId()));
if (result.getUserId() != null) { // 기존 회원일 때
user = RequestRegisterUser.builder()
.userId(result.getUserId())
.providerId(result.getProviderId())
.status(result.getStatus())
.build();
} else { // 신규 회원일 때
String oAuth2UserId = UUID.randomUUID().toString();
Provider provider = Provider.NONE;
if (response.getProvider().contains("naver")) {
provider = Provider.NAVER;
} else if (response.getProvider().contains("kakao")) {
provider = Provider.KAKAO;
} else if (response.getProvider().contains("google")) {
provider = Provider.GOOGLE;
}
user = RequestRegisterUser.builder()
.userId(oAuth2UserId)
.provider(provider)
.providerId(response.getProviderId())
.status(UserStatus.USER)
.build();
userClient.register(user);
}
return new CustomOAuth2User(response, user);
}
}
로그인하는데 사용할 OAuth2UserService이다. 네이버, 카카오, 구글이 각각 로그인 API의 응답 방식이 다르므로 통일된 인터페이스를 하나 만들어 놓고 구현은 각 플랫폼마다 다르게 했다.
기존 회원인지, 신규 회원인지에 따라 user에 들어가는 값이 다르다. 특히 신규 생성일 때는 FeignClient로 user-service에서 해당 유저 정보를 가져온다.
@FeignClient("user-service")
public interface UserClient {
@PostMapping("/users/check")
ResponseCheckUser isUserAlreadyRegistered(RequestCheckUser requestCheckUser);
@PostMapping("/users")
ResponseRegisterUser register(RequestRegisterUser requestRegisterUser);
}
이렇게 인터페이스만 만들어주면 마치 내 프로젝트에서 정의한 메서드처럼 사용할 수 있다. 참고로 @EnableFeignClients 어노테이션을 붙이는걸 잊지 말자.
spring:
security: # OAuth2 설정
oauth2:
client:
registration: #registration
naver:
client-name: naver
redirect-uri: http://localhost:8080/login/oauth2/code/naver
authorization-grant-type: authorization_code
scope:
- name
- email
kakao:
client-name: kakao
redirect-uri: http://localhost:8080/login/oauth2/code/kakao
authorization-grant-type: authorization_code
client-authentication-method: client_secret_post
scope:
- profile_nickname
- account_email
google:
client-name: google
redirect-uri: http://localhost:8080/login/oauth2/code/google
authorization-grant-type: authorization_code
scope:
- profile
- email
provider: #provider
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: response
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
OAuth2를 사용할 때 필요한 인증 공급자 정보를 작성해야 한다. 자바 코드로 작성할 수도 있지만 application.yaml에 작성하는게 편하니 여기에 작성했다.
spring:
security: # OAuth2 설정
oauth2:
client:
registration: #registration
naver:
client-id:
client-secret:
kakao:
client-id:
client-secret:
google:
client-id:
client-secret:
secrets.yaml 파일을 따로 만들어서 민감한 정보가 있는 부분을 포함시켰다. 개발자 센터에서 발급받은 OpenAPI id나 키를 넣으면 된다.
spring:
config:
import:
- classpath:secrets.yaml
application.yaml에서 secrets.yaml을 포함시키려면 위와 같이 작성하면 된다.
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class WebSecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final CustomCorsConfig customCorsConfig;
private final LoginSuccessHandler loginSuccessHandler;
@Bean
public WebSecurityCustomizer configure() {
return web -> web.ignoring()
.requestMatchers("/img/**", "/css/**", "/js/**", "/assets/**", "/error"); // 정적 자원은 필터 무시
}
// 특정 Http 요청에 대한 보안 설정
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
// CSRF 테스트를 위한 비활성화
.csrf(AbstractHttpConfigurer::disable)
// CORS 허용 커스텀 설정
.cors(c -> c
.configurationSource(customCorsConfig)
)
.addFilterBefore(afterLoginFilter(), OAuth2LoginAuthenticationFilter.class)
// httpBasic 비활성화
.httpBasic(AbstractHttpConfigurer::disable)
// formLogin 비활성화
.formLogin(FormLoginConfigurer::disable)
// 세션 비활성화
// .sessionManagement(session -> session
// .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// )
// OAuth2 클라이언트 설정
.oauth2Login(authConfig -> authConfig
.userInfoEndpoint(endpointConfig -> endpointConfig
.userService(customOAuth2UserService)
)
.loginPage("/login") // 커스텀 로그인 페이지 설정
//.defaultSuccessUrl("/", true)
.successHandler(loginSuccessHandler)
)
// 어느 경로를 인증받지 않고 사용할 수 있는지 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers(antMatcher("/login/**"), antMatcher("/oauth2/**"), antMatcher("/signup")
, antMatcher("/users"), antMatcher("/users/id-check"))
.permitAll()
.anyRequest()
.authenticated()
)
.build();
}
@Bean
public AfterLoginFilter afterLoginFilter() {
return new AfterLoginFilter();
}
}
예전 프로젝트에 있던걸 가져와서 수정하려니 필요없는 부분이 좀 보인다. 나중에 정리를 좀 해야겠다. 특히 afterLoginFilter는 MSA로 변경하면서 필요없는 레거시 코드가 되었다. 이 부분은 이제 필요없다.
이전에 설정했던 customOAuth2UserService를 등록한다.
@Slf4j
@Component
@RequiredArgsConstructor
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final Environment environment;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws ServletException, IOException {
String userId = ((CustomOAuth2User) authentication.getPrincipal()).getUserId();
Key secretKey = new SecretKeySpec(Base64.getEncoder().encode(environment.getProperty("token.secret").getBytes()),
Jwts.SIG.HS256.key().build().getAlgorithm());
String token = Jwts.builder()
.subject(userId)
.expiration(new Date(System.currentTimeMillis() +
Long.parseLong(environment.getProperty("token.expiration_time"))))
.signWith(secretKey)
.compact();
Cookie cookie = getCookie("Auth", token);
response.addCookie(cookie);
response.sendRedirect("http://localhost:8000/users/good");
}
private static Cookie getCookie(String name, String token) {
Cookie cookie = new Cookie(name, token);
cookie.setPath("/");
cookie.setHttpOnly(true);
return cookie;
}
}
로그인에 성공했을 때 처리되는 코드가 있는 핸들러 클래스이다. 로그인에 성공하면 JWT를 발급한다. application.yaml에 시크릿 값과 만료 시간을 설정해 두어 가져와 사용할 수 있고, JWT를 만든 다음에는 쿠키에 저장하고, 특정 경로로 리다이렉트 시킨다. 나는 테스트용 경로로 설정했다.
JWT 검증하기
OAuth2로 로그인하고, JWT 발급까지 마쳤으니 이제 검증하는 부분을 살펴보자.
@Slf4j
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
private final Environment environment;
@Autowired
public AuthorizationHeaderFilter(Environment environment) {
super(Config.class);
this.environment = environment;
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
MultiValueMap<String, HttpCookie> cookies = request.getCookies();
if (cookies.containsKey("Auth")) {
String jwt = cookies.getFirst("Auth").getValue();
if (!isJwtValid(jwt)) {
log.debug("토큰이 유효하지 않음");
return onError(exchange, "Token is not valid", HttpStatus.UNAUTHORIZED);
}
} else {
log.debug("쿠키 없음");
return onError(exchange, "Token is not valid", HttpStatus.UNAUTHORIZED);
}
return chain.filter(exchange);
};
}
private boolean isJwtValid(String jwt) {
boolean returnValue = true;
String subject = null;
byte[] secretKeyBytes = Base64.getEncoder().encode(environment.getProperty("token.secret").getBytes());
SecretKey secretKey = new SecretKeySpec(secretKeyBytes, Jwts.SIG.HS256.key().build().getAlgorithm());
try {
JwtParser jwtParser = Jwts.parser()
.verifyWith(secretKey)
.build();
subject = jwtParser.parseSignedClaims(jwt).getPayload().getSubject();
log.info("subject: " + subject);
} catch (Exception ex) {
returnValue = false;
}
if (subject == null || subject.isEmpty()) {
returnValue = false;
}
return returnValue;
}
private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
ServerHttpResponse response = exchange.getResponse();
response.getHeaders().setLocation(URI.create("http://localhost:8000/login"));
response.setStatusCode(HttpStatus.FOUND);
log.debug(err);
return response.setComplete();
}
public static class Config {
}
}
gateway-service의 커스텀 필터 부분이다. 작성한 후에 application.yaml에 가서 인증이 필요하다고 생각하는 곳에 추가하면 된다.
클라이언트에서 요청이 오면 쿠키에서 JWT가 있는지 확인하고 검증한다. 없거나 검증에 실패하면 login-service의 로그인 페이지로 이동시키고, 검증에 성공하면 그대로 통과시킨다.
gateway-service는 톰캣이 아니라 Netty로 실행되기 때문에 코드 작성법도 다르다. WebFlux로 코드 작성법이 다르므로 생소하기 때문에 나도 많이 찾아보면서 작성했다.
테스트


로그인하지 않고 JWT를 검증하는 경로로 접속하면 로그인 페이지로 이동된다.

로그인하고 브라우저의 쿠키를 확인해보면 잘 저장된 것을 볼 수 있다. 이제 다시 처음의 페이지로 접속해보면...

이제 접속이 되는 것을 확인할 수 있다!
404는 컨트롤러에 해당 경로에 맞는 처리를 만들지 않아서 뜨는거니 무시해도 된다.
이렇게 1차적으로 로그인 구현에 성공했다. 다음에는 로그아웃 기능을 추가할 예정이다.
그리고 나중에 여유가 된다면 로그인 부분을 더 보완해야겠다.
'공부 > Spring Cloud' 카테고리의 다른 글
| [Spring Cloud][6] Store 마이크로 서비스 구현하기 1 (0) | 2024.05.06 |
|---|---|
| [Spring Cloud][5] JWT, OAuth2 로그아웃 구현하기 (0) | 2024.05.04 |
| [Spring Cloud][3] User 마이크로 서비스 구현하기 1 (1) | 2024.04.28 |
| [Spring Cloud] Kafka cluster와 H2 포트 충돌 해결하기 (0) | 2024.04.08 |
| [Spring Cloud] log4j:ERROR Could not read configuration file 해결하기 (0) | 2024.04.05 |