MSA에서는 각 서비스가 독립적으로 배포되고 통신하기 때문에 보안이 매우 중요합니다. 따라서 데이터 보호, 인증 및 권한 부여, 통신 암호화 등을 통해 시스템의 보안성을 확보해야 합니다.
OAuth2는 사용자의 비밀번호를 직접 공유하지 않고도 제3자 애플리케이션이 사용자 자원에 접근할 수 있도록 해주는 인가(Authorization) 프레임워크입니다.
예를 들어 웹 서핑을 하다 보면 Google과 Naver 등의 외부 소셜 계정을 기반으로 간편히 회원가입 및 로그인할 수 있는 웹 어플리케이션을 쉽게 찾아볼 수 있습니다.
리소스 소유자(Resource Owner)
접근에 인증이 필요한 리소스(Resource)에 대해서 접근하고자 하는 Entity 입니다. 만약 유저가 이를 이용하고 싶다고 한다면 end-user가 될 수도 있고 만약 다른 서버가 사용을 원한다면 리소스 소유자는 해당 서버가 될 수도 있습니다. 즉, 이는 리소스를 사용하고자 요청을 보내는 주체입니다.
인증 서버(Authorization Server)
리소스에 대한 접근 권한 등을 관리하고 있는 서버입니다. 리소스 서버에 리소스 소유자가 클라이언트를 이용하여 접근할 수 있도록 Access Token을 만들어주는 서버이기도 하며 Grant Type을 관리하고 인증 정보를 관리하기도 합니다.
리소스 서버(Resource Server)
리소스가 존재하고 있는 서버입니다. 이 서버는 리소스를 가지고 있고 해당 리소스를 접근 권한이 있는 사용자에게만 접근할 수 있도록 허용합니다. 대표적으로 JWT를 Access Token으로 이용한다면 리소스 서버는 이를 verify하여 인증과 인과를 확인하여 접근 여부를 판단합니다. 간단히 말해서 정보를 가지고 있는 API 서버라고 볼 수 있습니다.
클라이언트(Client)
리소스 소유자를 대신하여 리소스 서버에 리소스를 요청하는 어플리케이션입니다. 웹, 모바일 앱, 데스크 탑 어플리케이션이라고 봐주시면 됩니다.
참고로 인증 서버와 리소스 서버는 구현에 따라 동일한 서버가 될 수도 있고 나뉘어질수도 있습니다.

위의 이미지는 OAuth 2.0에서 4가지의 Role의 추상화된 상호작용을 flow로 나타낸 것입니다. 순서는 위에서 아래의 순서를 가집니다.
클라이언트는 리소스 소유자에게 권한얻기 위해서 인증을 요청합니다. 권한 부여는 리소스 소유자가 직접 할수도 있습니다. 하지만 대체적으로 로그인 등과 같이 인증 서버에 위임하여 간접적으로 요청하는것이 일반적입니다.
인증이 완료되면 Client는 authorization grant중 한가지와 이에 해당하는 정보들을 받습니다. (authorization grant의 종류는 아래에서 추가적으로 알아보겠습니다.) 권한 부여 유형은 각 서비스마다 다를 수 있습니다.
2번에서 얻은 정보와 Client가 기존에 가지고 있던 정보를 가지고 인증 서버(Authorization Server)에 Access Token을 요청합니다.
인증 서버는 클라이언트가 전달한 정보 및 Authorization Granth의 유효성을 검증합니다. 검증이 정상적으로 완료되면 Access Token을 발급해줍니다.
클라이언트는 Access Token을 이용하여 리소스 서버에 리소스에 접근한다는 요청을 합니다.
적절한 Access Token인지 Resource Server에서 검증한 후 검증이 완료되었다면 서버에서 리소스를 반환합니다.
위의 Flow를 보았을 때 실제로 리소스 소유자가 본인을 인증하는 것은 1번밖에 없습니다. 나머지는 시스템끼리 주고 받는 flow입니다. 1번의 인증 이후에 실제로 리소스에 접근하기 위해서 필요한것은 Access Token입니다. 결과적으로 리소스 접근에 대해서 유저의 Access Token이 만약 탈취되더라도 인증 정보는 무사하기 때문에 Access Token만 만료 시키면 안전성을 유지할 수 있습니다.
Authorization Grant란 Access Token을 얻는 방법입니다. OAuth2는 여러 방법으로 Access Token을 얻는 방법을 제공하고 있습니다. 먼저 간단하게 목록을 보겠습니다.
가장 안전하고 일반적으로 사용하는 방식인 Authorization Code에 대해 자세히 알아보겠습니다.
클라이언트와 리소스 소유자 사이에 인증 서버를 두고 access_token를 얻는 flow를 가집니다. 클라이언트는 인증 서버를 통해서 웹 사이트가 열리고 이곳에서 리소스 오너라는 것을 인증합니다. 그 이후 code를 받고 이를 access_token으로 교환하는데 이러한 인증 flow에는 클라이언트가 개입하는 부분이 없는 Authorization Grant 입니다.
이러한 Authorization Code grant type은 웹과 모바일 앱에서 주로 사용됩니다. 해당 type이 다른 grant type과 크게 다른 점은 클라이언트에서 인증하는 것이 아니라 인증 서버를 통해서 이동된 웹 사이트를 통해 유저가 인증하고 이 정보가 사용된다는 점에 있습니다.

위 flow를 4단계로 구분하면 아래와 같습니다.
클라이언트 어플리케이션은 Browser를 열고 사용자는 OAuth Server로 접속합니다.
사용자는 App의 요청을 승인하도록 새롭게 뜬 prompt 창을 보게 되고 로그인, 승락 또는 거절을 할 수 있습니다.
승낙하게 되면 클라이언트 어플리케이션으로 redirection 되고 authorization code를 받습니다.
클라이언트 어플리케이션은 이를 access token과 바꾸고 유저에게 반환합니다.
각 단계에서 주고 받는 정보는 아래와 같습니다.
1번에서 클라이언트가 인증 서버에 요청하는 내용입니다. 이는 아래와 같습니다.
https://{{인증 서버 주소}}/auth
?response_type=code
&client_id=29352915982374239857
&redirect_uri=https%3A%2F%2Fexample-app.com%2Fcallback
&scope=create+delete
&state=xcoiv98y2kd22vusuye3kch
유저가 인증 서버에서 본인 인증을 완료하고 나면, 인증 서버는 redirect_uri로 사이트를 이동시킵니다. 이때 아래의 파라미터를 함께 전달한다.
https://{{클라이언트 주소}}/redirect
?code=g0ZGZmNjVmOWIjNTk2NTk4ZTYyZGI3
&state=xcoiv98y2kd22vusuye3kch
이제 클라이언트는 위에서 받은 code를 이용하여 access token을 얻습니다.
아래의 파라미터로 access_token에 요청합니다. 이 request는 POST method를 이용합니다.
이런 과정을 거쳐 결과적으로 클라이언트는 access_token을 얻게 되며 리소스 서버는 이를 검증하고 정상적인 token이면 유저에게 해당 리소스에 대한 접근을 허락해줍니다. 인증 과정의 마지막 결과로 얻게 되는 response는 아래와 같습니다.
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache
{
"access_token":"MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3",
"token_type":"bearer",
"expires_in":3600,
"refresh_token":"IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk",
"scope":"create delete"
}
JWT의 특징은 다음과 같습니다.
1. 자가 포함: 토큰 자체에 모든 정보를 포함하고 있어 별도의 상태 저장이 필요 없습니다.
2. 간결성: 짧고 간결한 문자열로, URL, 헤더 등에 쉽게 포함될 수 있습니다.
3. 서명 및 암호화: 데이터의 무결성과 인증을 보장합니다.
❗저번에 진행했던 API Gateway부분 코드에서 이어서 진행하겠습니다.
게이트웨이의 Pre 필터에서 JWT 인증을 진행해보겠습니다.여기에 Auth Service 를 생성하여 로그인 기능을 아주 간단하게 구현하겠습니다.
클라우드 게이트웨이에 Pre 필터를 하나 더 생성하여 로그인을 체크 하겠습니다.위 이미지의 점선만 실행하여 확인하겠습니다.
PostMan 또는 크롬 익스텐션 중 Talend API Tester를 설치하여 진행해보겠습니다.
로그인을 담당하는 서비스 어플리케이션을 생성합니다. 로그인을 진행하면 토큰을 발급받고 이 토큰을 사용하여 Gateway를 호출 합니다.
start.spring.io 를 사용하여 프로젝트를 생성합니다.

build.gradle 파일의 디펜던시를 아래와 같이 수정합니다. ( jwt 추가 )
dependencies {
implementation 'io.jsonwebtoken:jjwt:0.12.6'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testImplementation 'io.projectreactor:reactor-test'
}
application.yml
spring:
application:
name: auth-service
eureka:
client:
service-url:
defaultZone: http://localhost:19090/eureka/
service:
jwt:
access-expiration: 3600000
secret-key: "401b09eab3c013d4ca54922bb802bec8fd5318192b0a75f201d8b3727429080fb337591abd3e44453b954555b7a0812e1081c39b740293f765eae731f5a65ed1"
server:
port: 19095
AuthConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class AuthConfig {
// SecurityFilterChain 빈을 정의합니다. 이 메서드는 Spring Security의 보안 필터 체인을 구성합니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// CSRF 보호를 비활성화합니다. CSRF 보호는 주로 브라우저 클라이언트를 대상으로 하는 공격을 방지하기 위해 사용됩니다.
.csrf(csrf -> csrf.disable())
// 요청에 대한 접근 권한을 설정합니다.
.authorizeRequests(authorize -> authorize
// /auth/signIn 경로에 대한 접근을 허용합니다. 이 경로는 인증 없이 접근할 수 있습니다.
.requestMatchers("/auth/signIn").permitAll()
// 그 외의 모든 요청은 인증이 필요합니다.
.anyRequest().authenticated()
)
// 세션 관리 정책을 정의합니다. 여기서는 세션을 사용하지 않도록 STATELESS로 설정합니다.
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
// 설정된 보안 필터 체인을 반환합니다.
return http.build();
}
}
AuthService.java
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.util.Date;
@Service
public class AuthService {
@Value("${spring.application.name}")
private String issuer;
@Value("${service.jwt.access-expiration}")
private Long accessExpiration;
private final SecretKey secretKey;
/**
* AuthService 생성자.
* Base64 URL 인코딩된 비밀 키를 디코딩하여 HMAC-SHA 알고리즘에 적합한 SecretKey 객체를 생성합니다.
*
* @param secretKey Base64 URL 인코딩된 비밀 키
*/
public AuthService(@Value("${service.jwt.secret-key}") String secretKey) {
this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
}
/**
* 사용자 ID를 받아 JWT 액세스 토큰을 생성합니다.
*
* @param user_id 사용자 ID
* @return 생성된 JWT 액세스 토큰
*/
public String createAccessToken(String user_id) {
return Jwts.builder()
// 사용자 ID를 클레임으로 설정
.claim("user_id", user_id)
.claim("role", "ADMIN")
// JWT 발행자를 설정
.issuer(issuer)
// JWT 발행 시간을 현재 시간으로 설정
.issuedAt(new Date(System.currentTimeMillis()))
// JWT 만료 시간을 설정
.expiration(new Date(System.currentTimeMillis() + accessExpiration))
// SecretKey를 사용하여 HMAC-SHA512 알고리즘으로 서명
.signWith(secretKey, io.jsonwebtoken.SignatureAlgorithm.HS512)
// JWT 문자열로 컴팩트하게 변환
.compact();
}
}
AuthController.java
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
/**
* 사용자 ID를 받아 JWT 액세스 토큰을 생성하여 응답합니다.
*
* @param user_id 사용자 ID
* @return JWT 액세스 토큰을 포함한 AuthResponse 객체를 반환합니다.
*/
@GetMapping("/auth/signIn")
public ResponseEntity<?> createAuthenticationToken(@RequestParam String user_id){
return ResponseEntity.ok(new AuthResponse(authService.createAccessToken(user_id)));
}
/**
* JWT 액세스 토큰을 포함하는 응답 객체입니다.
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
static class AuthResponse {
private String access_token;
}
}
기존 게이트웨이 코드에 JWT인증 및 auth-service 라우팅 정보를 추가합니다.
build.gradle 파일에 필요한 의존성을 추가합니다. ( jwt 추가 )
dependencies {
implementation 'io.jsonwebtoken:jjwt:0.12.6'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
application.yml 을 다음과 같이 수정합니다.
server:
port: 19091 # 게이트웨이 서비스가 실행될 포트 번호
spring:
main:
web-application-type: reactive # Spring 애플리케이션이 리액티브 웹 애플리케이션으로 설정됨
application:
name: gateway-service # 애플리케이션 이름을 'gateway-service'로 설정
cloud:
gateway:
routes: # Spring Cloud Gateway의 라우팅 설정
- id: order-service # 라우트 식별자
uri: lb://order-service # 'order-service'라는 이름으로 로드 밸런싱된 서비스로 라우팅
predicates:
- Path=/order/** # /order/** 경로로 들어오는 요청을 이 라우트로 처리
- id: product-service # 라우트 식별자
uri: lb://product-service # 'product-service'라는 이름으로 로드 밸런싱된 서비스로 라우팅
predicates:
- Path=/product/** # /product/** 경로로 들어오는 요청을 이 라우트로 처리
- id: auth-service # 라우트 식별자
uri: lb://auth-service # 'auth-service'라는 이름으로 로드 밸런싱된 서비스로 라우팅
predicates:
- Path=/auth/signIn # /auth/signIn 경로로 들어오는 요청을 이 라우트로 처리
discovery:
locator:
enabled: true # 서비스 디스커버리를 통해 동적으로 라우트를 생성하도록 설정
eureka:
client:
service-url:
defaultZone: http://localhost:19090/eureka/ # Eureka 서버의 URL을 지정
service:
jwt:
secret-key: "401b09eab3c013d4ca54922bb802bec8fd5318192b0a75f201d8b3727429080fb337591abd3e44453b954555b7a0812e1081c39b740293f765eae731f5a65ed1"
LocalJwtAuthenticationFilter.java
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import javax.crypto.SecretKey;
@Slf4j
@Component
public class LocalJwtAuthenticationFilter implements GlobalFilter {
@Value("${service.jwt.secret-key}")
private String secretKey;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
if (path.equals("/auth/signIn")) {
return chain.filter(exchange); // /signIn 경로는 필터를 적용하지 않음
}
String token = extractToken(exchange);
if (token == null || !validateToken(token)) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}
private String extractToken(ServerWebExchange exchange) {
String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return null;
}
private boolean validateToken(String token) {
try {
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
Jws<Claims> claimsJws = Jwts.parser()
.verifyWith(key)
.build().parseSignedClaims(token);
log.info("#####payload :: " + claimsJws.getPayload().toString());
// 추가적인 검증 로직 (예: 토큰 만료 여부 확인 등)을 여기에 추가할 수 있습니다.
return true;
} catch (Exception e) {
return false;
}
}
}
유레카 서버 ⇒ 게이트웨이⇒ 인증 ⇒ 상품 순으로 어플리케이션을 실행합니다.


