Compare commits
+7
-2
@@ -4,10 +4,15 @@
|
|||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
backend/target/
|
backend/target/
|
||||||
**/node_modules/
|
|
||||||
frontend/dist/
|
|
||||||
build/
|
build/
|
||||||
e2e/node_modules/
|
e2e/node_modules/
|
||||||
google-e2e/node_modules/
|
google-e2e/node_modules/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
|
||||||
|
bff/target
|
||||||
|
token-mediator/target
|
||||||
|
|
||||||
|
# lab cloud-init contains a console password; keep the filled copy local
|
||||||
|
deploy/lab/cloud-init/kc-lab.yaml
|
||||||
|
deploy/lab/cloud-init/kc-lab-*.yaml
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 295ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 125ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 95ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 91ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 88ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
- generic [ref=f7e3]:
|
||||||
|
- link "본문으로 건너뛰기" [ref=f7e4] [cursor=pointer]:
|
||||||
|
- /url: "#main-content"
|
||||||
|
- banner [ref=f7e5]:
|
||||||
|
- generic [ref=f7e6]:
|
||||||
|
- link "TechLog 홈" [ref=f7e8] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- text: TechLog
|
||||||
|
- generic [ref=f7e9]:
|
||||||
|
- button "TechLog 검색 열기" [ref=f7e11] [cursor=pointer]: 검색
|
||||||
|
- group [ref=f7e12]:
|
||||||
|
- generic "메뉴" [ref=f7e13] [cursor=pointer]
|
||||||
|
- generic [ref=f7e14]:
|
||||||
|
- paragraph [ref=f7e15]: 화면을 준비하고 있습니다.
|
||||||
|
- generic [ref=f7e16]: TechLog 로딩 중
|
||||||
|
- contentinfo [ref=f7e17]:
|
||||||
|
- generic [ref=f7e18]:
|
||||||
|
- generic [ref=f7e19]:
|
||||||
|
- paragraph [ref=f7e20]: 동현
|
||||||
|
- paragraph [ref=f7e21]: 문제를 재현하고 검증해 실제 운영에 적용할 수 있는 형태로 정리합니다.
|
||||||
|
- generic [ref=f7e22]:
|
||||||
|
- link "프로필" [ref=f7e23] [cursor=pointer]:
|
||||||
|
- /url: /profile
|
||||||
|
- link "변경 기록" [ref=f7e24] [cursor=pointer]:
|
||||||
|
- /url: /releases
|
||||||
@@ -15,6 +15,11 @@ Keycloak을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은
|
|||||||
- AP3: Backend-for-Frontend (BFF)
|
- AP3: Backend-for-Frontend (BFF)
|
||||||
- AP4: Edge forward-auth
|
- AP4: Edge forward-auth
|
||||||
|
|
||||||
|
세션 저장소·refresh token 경쟁·장애 복구는 네 패턴을 가로지르는 별도 축으로
|
||||||
|
`develop-keycloak-session-store` 브랜치에서 진행합니다. 계획과 진행 상황은
|
||||||
|
[`docs/session-store-lab-roadmap.md`](docs/session-store-lab-roadmap.md)에
|
||||||
|
있습니다.
|
||||||
|
|
||||||
현재 `develop`의 공통 baseline은 Keycloak, PostgreSQL, Spring Boot API,
|
현재 `develop`의 공통 baseline은 Keycloak, PostgreSQL, Spring Boot API,
|
||||||
nginx를 Docker Compose로 실행하는 토대입니다. 패턴별 구현은 이 baseline
|
nginx를 Docker Compose로 실행하는 토대입니다. 패턴별 구현은 이 baseline
|
||||||
위에서 별도 브랜치로 진행합니다.
|
위에서 별도 브랜치로 진행합니다.
|
||||||
@@ -103,39 +108,3 @@ Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다.
|
|||||||
|
|
||||||
runtime export에는 실제 client secret과 credential hash가 포함될 수 있어
|
runtime export에는 실제 client secret과 credential hash가 포함될 수 있어
|
||||||
gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다.
|
gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다.
|
||||||
|
|
||||||
## AP1: SPA Direct + Resource Server
|
|
||||||
|
|
||||||
`develop-keycloak-pattern1`은 vanilla JavaScript SPA가 `spa-public` client로
|
|
||||||
Authorization Code + PKCE S256 로그인을 수행하는 패턴입니다. access/refresh
|
|
||||||
token은 명시적인 in-memory store에만 보관되므로 새로고침하면 사라집니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/verify-pattern1.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
브라우저에서 `http://localhost:8088`을 열어 로그인한 뒤 보호 API를 호출할 수
|
|
||||||
있습니다. SPA는 `http://localhost:8081/api/me`를 직접 호출하며 Spring
|
|
||||||
Resource Server가 Bearer JWT를 검증합니다.
|
|
||||||
|
|
||||||
Keycloak의 dedicated audience mapper는 `spa-public` access token에
|
|
||||||
`keycloak-pattern-api`를 추가합니다. Spring은 signature, `iss`, `exp`뿐
|
|
||||||
아니라 이 `aud`도 검사합니다. `verify-pattern1.sh`는 같은 정상 토큰을
|
|
||||||
`deliberately-wrong-audience`를 기대하는 진단 인스턴스에도 제출해 `401`을
|
|
||||||
확인합니다.
|
|
||||||
|
|
||||||
Keycloak은 `KC_HOSTNAME=http://localhost:8080`을 기준으로 token의 `iss`를
|
|
||||||
발급합니다. 정상 Resource Server는 이 외부 issuer 문자열을 검증하되 JWKS는
|
|
||||||
Docker 내부의 `http://keycloak:8080`에서 가져옵니다. 진단 인스턴스는 일부러
|
|
||||||
`http://wrong-issuer.invalid`를 기대하도록 구성되어, 서명과 audience가
|
|
||||||
정상이더라도 issuer mismatch로 `401`을 반환합니다.
|
|
||||||
|
|
||||||
token 저장 위치와 XSS 범위는
|
|
||||||
[`docs/ap1-token-storage.md`](docs/ap1-token-storage.md)에 정리했습니다.
|
|
||||||
E2E는 Web Storage token이 0개임과 동시에 실행 중 fetch hook이 Bearer
|
|
||||||
header를 관찰할 수 있음을 재현합니다.
|
|
||||||
|
|
||||||
refresh rotation, 소비된 refresh token 재사용, RP-Initiated Logout,
|
|
||||||
revocation과 stateless JWT의 차이는
|
|
||||||
[`docs/ap1-refresh-logout.md`](docs/ap1-refresh-logout.md)에 정리했으며 같은
|
|
||||||
E2E에서 실제 Keycloak 26.7.0 동작을 검증합니다.
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.example.keycloakpattern;
|
package com.example.keycloakpattern;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
@@ -9,6 +11,8 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api")
|
@RequestMapping("/api")
|
||||||
public class ApiController {
|
public class ApiController {
|
||||||
@@ -18,6 +22,38 @@ public class ApiController {
|
|||||||
return Map.of("status", "ok", "service", "keycloak-pattern-api");
|
return Map.of("status", "ok", "service", "keycloak-pattern-api");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reflects what actually reached the application after the proxy chain.
|
||||||
|
*
|
||||||
|
* <p>The reverse proxy contract is defined in {@code docs/reverse-proxy-headers.md}
|
||||||
|
* for a single nginx hop. The lab runs {@code nginx -> Traefik -> pod}, so this
|
||||||
|
* endpoint exists to measure the two-hop result instead of assuming it.
|
||||||
|
*
|
||||||
|
* <p>{@code scheme}, {@code secure} and {@code requestUrl} are the values Keycloak
|
||||||
|
* uses to build the {@code iss} claim and redirect URLs. If forwarded headers are
|
||||||
|
* lost or rewritten, the mismatch shows up here first.
|
||||||
|
*/
|
||||||
|
@GetMapping("/echo")
|
||||||
|
public Map<String, Object> echo(HttpServletRequest request) {
|
||||||
|
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||||
|
for (String name : Collections.list(request.getHeaderNames())) {
|
||||||
|
headers.put(name.toLowerCase(), Collections.list(request.getHeaders(name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("headers", headers);
|
||||||
|
response.put("remoteAddr", request.getRemoteAddr());
|
||||||
|
// Pod IP. Identifies which replica answered, which is what makes the
|
||||||
|
// host nginx upstream distribution and the sticky-session switch observable.
|
||||||
|
response.put("localAddr", request.getLocalAddr());
|
||||||
|
response.put("scheme", request.getScheme());
|
||||||
|
response.put("secure", request.isSecure());
|
||||||
|
response.put("serverName", request.getServerName());
|
||||||
|
response.put("serverPort", request.getServerPort());
|
||||||
|
response.put("requestUrl", request.getRequestURL().toString());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
public Map<String, Object> currentUser(@AuthenticationPrincipal Jwt jwt) {
|
public Map<String, Object> currentUser(@AuthenticationPrincipal Jwt jwt) {
|
||||||
Map<String, Object> response = new LinkedHashMap<>();
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
@@ -27,9 +63,4 @@ public class ApiController {
|
|||||||
response.put("audience", jwt.getAudience());
|
response.put("audience", jwt.getAudience());
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/admin")
|
|
||||||
public Map<String, String> adminEndpoint() {
|
|
||||||
return Map.of("status", "ok", "authorization", "admin-role");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package com.example.keycloakpattern;
|
|
||||||
|
|
||||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
|
||||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
|
||||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
|
||||||
|
|
||||||
final class AudienceValidator implements OAuth2TokenValidator<Jwt> {
|
|
||||||
|
|
||||||
private static final OAuth2Error MISSING_AUDIENCE = new OAuth2Error(
|
|
||||||
"invalid_token",
|
|
||||||
"The required resource audience is missing",
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
private final String expectedAudience;
|
|
||||||
|
|
||||||
AudienceValidator(String expectedAudience) {
|
|
||||||
this.expectedAudience = expectedAudience;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
|
||||||
if (jwt.getAudience().contains(expectedAudience)) {
|
|
||||||
return OAuth2TokenValidatorResult.success();
|
|
||||||
}
|
|
||||||
return OAuth2TokenValidatorResult.failure(MISSING_AUDIENCE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package com.example.keycloakpattern;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
|
||||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
|
||||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
|
||||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
public class JwtDecoderConfig {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
JwtDecoder jwtDecoder(
|
|
||||||
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer,
|
|
||||||
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
|
|
||||||
@Value("${security.expected-audience}") String expectedAudience
|
|
||||||
) {
|
|
||||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
|
|
||||||
OAuth2TokenValidator<Jwt> issuerAndTimestamp =
|
|
||||||
JwtValidators.createDefaultWithIssuer(issuer);
|
|
||||||
OAuth2TokenValidator<Jwt> audience = new AudienceValidator(expectedAudience);
|
|
||||||
decoder.setJwtValidator(
|
|
||||||
new DelegatingOAuth2TokenValidator<>(issuerAndTimestamp, audience)
|
|
||||||
);
|
|
||||||
return decoder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package com.example.keycloakpattern;
|
|
||||||
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import org.springframework.core.convert.converter.Converter;
|
|
||||||
import org.springframework.security.core.GrantedAuthority;
|
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
|
||||||
|
|
||||||
final class KeycloakRealmRoleConverter
|
|
||||||
implements Converter<Jwt, Collection<GrantedAuthority>> {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Collection<GrantedAuthority> convert(Jwt jwt) {
|
|
||||||
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
|
|
||||||
if (realmAccess == null || !(realmAccess.get("roles") instanceof Collection<?> roles)) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
return roles.stream()
|
|
||||||
.filter(String.class::isInstance)
|
|
||||||
.map(String.class::cast)
|
|
||||||
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
|
|
||||||
.map(GrantedAuthority.class::cast)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
package com.example.keycloakpattern;
|
package com.example.keycloakpattern;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.Customizer;
|
import org.springframework.security.config.Customizer;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
|
||||||
import org.springframework.web.cors.CorsConfigurationSource;
|
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
@@ -19,40 +13,16 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
|
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
|
||||||
return http
|
return http
|
||||||
.cors(Customizer.withDefaults())
|
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
.sessionManagement(session ->
|
.sessionManagement(session ->
|
||||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(authorize -> authorize
|
.authorizeHttpRequests(authorize -> authorize
|
||||||
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public")
|
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public",
|
||||||
|
"/api/echo")
|
||||||
.permitAll()
|
.permitAll()
|
||||||
.requestMatchers("/api/admin")
|
|
||||||
.hasRole("admin-role")
|
|
||||||
.anyRequest()
|
.anyRequest()
|
||||||
.authenticated())
|
.authenticated())
|
||||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
|
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
|
||||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private JwtAuthenticationConverter jwtAuthenticationConverter() {
|
|
||||||
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
|
|
||||||
converter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());
|
|
||||||
return converter;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
CorsConfigurationSource corsConfigurationSource() {
|
|
||||||
CorsConfiguration configuration = new CorsConfiguration();
|
|
||||||
configuration.setAllowedOrigins(List.of(
|
|
||||||
"http://localhost:8088",
|
|
||||||
"http://127.0.0.1:8088"
|
|
||||||
));
|
|
||||||
configuration.setAllowedMethods(List.of("GET", "OPTIONS"));
|
|
||||||
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
|
||||||
|
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
|
||||||
source.registerCorsConfiguration("/api/**", configuration);
|
|
||||||
return source;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
server:
|
server:
|
||||||
port: ${SERVER_PORT:8081}
|
port: ${SERVER_PORT:8081}
|
||||||
|
# Spring ignores X-Forwarded-* unless this is set, so scheme/secure/requestUrl
|
||||||
|
# report the raw connection by default. Keycloak has the same opt-in as
|
||||||
|
# KC_PROXY_HEADERS. Flipping this to "native" is what the two-hop measurement
|
||||||
|
# compares against.
|
||||||
|
forward-headers-strategy: ${SERVER_FORWARD_HEADERS_STRATEGY:none}
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: keycloak-pattern-api
|
name: keycloak-pattern-api
|
||||||
|
jackson:
|
||||||
|
serialization:
|
||||||
|
# /api/echo is read by humans and captured as evidence screenshots, so the
|
||||||
|
# response is indented rather than relying on a browser's JSON viewer.
|
||||||
|
indent-output: true
|
||||||
security:
|
security:
|
||||||
oauth2:
|
oauth2:
|
||||||
resourceserver:
|
resourceserver:
|
||||||
@@ -11,9 +21,6 @@ spring:
|
|||||||
issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:http://localhost:8080/realms/keycloak-patterns}
|
issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:http://localhost:8080/realms/keycloak-patterns}
|
||||||
jwk-set-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI:http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/certs}
|
jwk-set-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI:http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/certs}
|
||||||
|
|
||||||
security:
|
|
||||||
expected-audience: ${SECURITY_EXPECTED_AUDIENCE:keycloak-pattern-api}
|
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoint:
|
endpoint:
|
||||||
health:
|
health:
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@@ -26,6 +25,18 @@ class ApiSecurityTest {
|
|||||||
.andExpect(jsonPath("$.status").value("ok"));
|
.andExpect(jsonPath("$.status").value("ok"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void echoEndpointReflectsForwardedHeadersWithoutAuthentication() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/echo")
|
||||||
|
.header("X-Forwarded-Proto", "https")
|
||||||
|
.header("X-Forwarded-Host", "app1.example.test"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.headers['x-forwarded-proto'][0]").value("https"))
|
||||||
|
.andExpect(jsonPath("$.headers['x-forwarded-host'][0]").value("app1.example.test"))
|
||||||
|
.andExpect(jsonPath("$.requestUrl").exists())
|
||||||
|
.andExpect(jsonPath("$.remoteAddr").exists());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void protectedEndpointRejectsAnonymousRequests() throws Exception {
|
void protectedEndpointRejectsAnonymousRequests() throws Exception {
|
||||||
mockMvc.perform(get("/api/me"))
|
mockMvc.perform(get("/api/me"))
|
||||||
@@ -41,19 +52,4 @@ class ApiSecurityTest {
|
|||||||
.andExpect(jsonPath("$.subject").value("test-subject"))
|
.andExpect(jsonPath("$.subject").value("test-subject"))
|
||||||
.andExpect(jsonPath("$.username").value("regular-user"));
|
.andExpect(jsonPath("$.username").value("regular-user"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void regularUserCannotCallAdminEndpoint() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/admin").with(jwt()
|
|
||||||
.authorities(new SimpleGrantedAuthority("ROLE_user-role"))))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void adminRoleCanCallAdminEndpoint() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/admin").with(jwt()
|
|
||||||
.authorities(new SimpleGrantedAuthority("ROLE_admin-role"))))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.authorization").value("admin-role"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
package com.example.keycloakpattern;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
|
||||||
|
|
||||||
class AudienceValidatorTest {
|
|
||||||
|
|
||||||
private final AudienceValidator validator =
|
|
||||||
new AudienceValidator("keycloak-pattern-api");
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void acceptsRequiredAudience() {
|
|
||||||
OAuth2TokenValidatorResult result = validator.validate(jwtWithAudience(
|
|
||||||
List.of("account", "keycloak-pattern-api")
|
|
||||||
));
|
|
||||||
|
|
||||||
assertThat(result.hasErrors()).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectsForeignAudience() {
|
|
||||||
OAuth2TokenValidatorResult result = validator.validate(jwtWithAudience(
|
|
||||||
List.of("another-resource")
|
|
||||||
));
|
|
||||||
|
|
||||||
assertThat(result.hasErrors()).isTrue();
|
|
||||||
assertThat(result.getErrors())
|
|
||||||
.extracting(error -> error.getErrorCode())
|
|
||||||
.containsExactly("invalid_token");
|
|
||||||
}
|
|
||||||
|
|
||||||
private Jwt jwtWithAudience(List<String> audience) {
|
|
||||||
Instant now = Instant.now();
|
|
||||||
return new Jwt(
|
|
||||||
"test-token",
|
|
||||||
now,
|
|
||||||
now.plusSeconds(300),
|
|
||||||
Map.of("alg", "none"),
|
|
||||||
Map.of("sub", "test-subject", "aud", audience)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package com.example.keycloakpattern;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
|
||||||
|
|
||||||
class KeycloakRealmRoleConverterTest {
|
|
||||||
|
|
||||||
private final KeycloakRealmRoleConverter converter =
|
|
||||||
new KeycloakRealmRoleConverter();
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void mapsRealmRolesWithExactlyOneRolePrefix() {
|
|
||||||
Jwt jwt = new Jwt(
|
|
||||||
"token",
|
|
||||||
Instant.now(),
|
|
||||||
Instant.now().plusSeconds(60),
|
|
||||||
Map.of("alg", "none"),
|
|
||||||
Map.of("sub", "subject", "realm_access", Map.of(
|
|
||||||
"roles", List.of("admin-role", "user-role")
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(converter.convert(jwt))
|
|
||||||
.extracting("authority")
|
|
||||||
.containsExactly("ROLE_admin-role", "ROLE_user-role");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void missingRealmAccessProducesNoAuthorities() {
|
|
||||||
Jwt jwt = new Jwt(
|
|
||||||
"token",
|
|
||||||
Instant.now(),
|
|
||||||
Instant.now().plusSeconds(60),
|
|
||||||
Map.of("alg", "none"),
|
|
||||||
Map.of("sub", "subject")
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(converter.convert(jwt)).isEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Session store lab
|
||||||
|
|
||||||
|
세션 저장소·refresh token 경쟁·장애 복구를 검증하는 2노드 k3s 실험대.
|
||||||
|
네 인증 패턴(AP1~AP4)을 가로지르는 공통층이므로 별도 축으로 관리한다.
|
||||||
|
|
||||||
|
이 문서는 **절차**만 담는다.
|
||||||
|
|
||||||
|
| 문서 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/session-store-lab-roadmap.md`](../../docs/session-store-lab-roadmap.md) | 이 축의 계획과 진행 상황 |
|
||||||
|
| [`docs/session-lab-concepts.md`](../../docs/session-lab-concepts.md) | 등장 개념 전체 |
|
||||||
|
| [`docs/session-lab-operations.md`](../../docs/session-lab-operations.md) | 관측 도구 · 자주 쓰는 명령 · 훈련 |
|
||||||
|
| [`docs/two-hop-proxy-header-contract.md`](../../docs/two-hop-proxy-header-contract.md) | 첫 실험 결과 |
|
||||||
|
|
||||||
|
## 토폴로지
|
||||||
|
|
||||||
|
```
|
||||||
|
브라우저 / SSH (tailnet)
|
||||||
|
│ https://{auth,app1,app2}.hyeonworks.com → 100.83.212.4
|
||||||
|
▼
|
||||||
|
lab host ── nginx :443 TLS 종료 · X-Forwarded-* 주입
|
||||||
|
│ nginx :80 301 → https
|
||||||
|
│
|
||||||
|
│ virbr0 192.168.122.0/24 (libvirt NAT)
|
||||||
|
├──▶ kc-lab-1 .11 k3s server Traefik :80
|
||||||
|
└──▶ kc-lab-2 .12 k3s agent Traefik :80
|
||||||
|
└──▶ Pod
|
||||||
|
```
|
||||||
|
|
||||||
|
`nginx → Traefik` **2홉**이 운영 구조와 같다는 점이 이 배치의 핵심이다.
|
||||||
|
L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx는 바깥세상과의
|
||||||
|
접점(TLS·인증서·헤더)을, Traefik은 클러스터 내부의 동적 라우팅을 맡는다.
|
||||||
|
|
||||||
|
## 구성 요소
|
||||||
|
|
||||||
|
| 경로 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| `cloud-init/kc-lab.yaml.example` | 게스트 부트스트랩 템플릿 |
|
||||||
|
| `host/nginx-keycloak-lab.conf` | lab host의 `sites-available/keycloak-lab` |
|
||||||
|
| `k8s/echo.yaml` | 2홉 헤더 계약 측정용 워크로드 |
|
||||||
|
| `scripts/rebuild-seed.sh` | cloud-init 시드 ISO 재생성 + 풀 업로드 |
|
||||||
|
| `scripts/build-and-import.sh` | 이미지 빌드 → 각 노드 containerd 반입 |
|
||||||
|
| `scripts/measure-proxy-headers.sh` | 헤더 계약 실측 |
|
||||||
|
| `scripts/verify-lab.sh` | 인프라 상태 점검 |
|
||||||
|
|
||||||
|
## 상태 점검
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy/lab/scripts/verify-lab.sh # lab host 에서
|
||||||
|
```
|
||||||
|
|
||||||
|
**`404`가 성공 신호다.** TLS가 종료되고 Traefik까지 도달했으나 매칭되는
|
||||||
|
Ingress 규칙이 없다는 뜻이다. `502`나 연결 거부면 체인이 끊긴 것이다.
|
||||||
|
|
||||||
|
## 첫 실험 — 2홉 헤더 계약
|
||||||
|
|
||||||
|
[`docs/reverse-proxy-headers.md`](../../docs/reverse-proxy-headers.md)의 계약은
|
||||||
|
nginx **1홉**을 가정하고 쓰였다. 실제 배치는 2홉이므로, nginx가 세팅한
|
||||||
|
`X-Forwarded-*`를 Traefik이 그대로 넘기는지 덮어쓰는지 **측정해서 확인한다.**
|
||||||
|
|
||||||
|
이 결론이 뒤의 모든 실험에 깔린다. Keycloak의 `iss` 클레임, redirect URL,
|
||||||
|
쿠키 도메인 검증이 전부 이 헤더에 의존하기 때문이다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 워크스테이션: 이미지 빌드 후 두 노드에 반입
|
||||||
|
./deploy/lab/scripts/build-and-import.sh
|
||||||
|
|
||||||
|
# lab host: 배포
|
||||||
|
kubectl apply -f deploy/lab/k8s/echo.yaml
|
||||||
|
kubectl -n header-lab rollout status deployment/echo
|
||||||
|
|
||||||
|
# 어디서든: 실측
|
||||||
|
./deploy/lab/scripts/measure-proxy-headers.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
관측 대상은 넷이다.
|
||||||
|
|
||||||
|
1. `X-Forwarded-For` — Traefik이 **덧붙이는가 덮어쓰는가**
|
||||||
|
2. `X-Forwarded-Proto` / `-Host` / `-Port` — 그대로 전달되는가
|
||||||
|
3. **위조 내성** — 클라이언트가 직접 넣은 `X-Forwarded-*`가 앱까지 도달하는가
|
||||||
|
4. `scheme` / `secure` / `requestUrl` — Keycloak이 URL을 만들 때 쓰는 값
|
||||||
|
|
||||||
|
3번이 신뢰 경계의 핵심이다. 이 헤더들은 누구나 위조할 수 있는 평범한 HTTP
|
||||||
|
헤더이므로, 신뢰 경계에 선 프록시가 **반드시 덮어써야** 한다.
|
||||||
|
|
||||||
|
## 이미지 배포 경로
|
||||||
|
|
||||||
|
k3s는 containerd를 쓰고 이 실험대에는 레지스트리가 없다.
|
||||||
|
|
||||||
|
```
|
||||||
|
워크스테이션 docker build → docker save
|
||||||
|
│ ssh (lab host 경유)
|
||||||
|
▼
|
||||||
|
게스트 sudo k3s ctr images import
|
||||||
|
매니페스트 imagePullPolicy: Never
|
||||||
|
```
|
||||||
|
|
||||||
|
**두 노드 모두에 반입해야 한다.** 스케줄러가 어느 노드에 배치할지 모른다.
|
||||||
|
Keycloak·PostgreSQL·Redis는 공식 이미지를 그대로 당겨오므로 이 경로가
|
||||||
|
필요한 것은 자체 빌드 이미지뿐이다.
|
||||||
|
|
||||||
|
**lab host에 Docker를 설치하지 않는다.** k3s의 containerd와 이미지 저장소가
|
||||||
|
갈려서 `docker build`한 이미지를 k3s가 보지 못하게 된다.
|
||||||
|
|
||||||
|
## 게스트 재생성
|
||||||
|
|
||||||
|
파괴적 실험 후 초기화하는 경로다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh destroy kc-lab-1
|
||||||
|
virsh undefine kc-lab-1 # --remove-all-storage 는 시드 ISO 까지 지운다
|
||||||
|
virsh vol-delete --pool default kc-lab-1.qcow2
|
||||||
|
|
||||||
|
./deploy/lab/scripts/rebuild-seed.sh 1 # user-data 를 고쳤을 때만
|
||||||
|
|
||||||
|
virt-install --name kc-lab-1 --memory 3584 --vcpus 2 \
|
||||||
|
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||||
|
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
|
||||||
|
--network network=default,mac=52:54:00:aa:bb:11 \
|
||||||
|
--import --os-variant debian12 --noautoconsole
|
||||||
|
```
|
||||||
|
|
||||||
|
시드는 **virtio 디스크**로 붙인다. `virt-install --cloud-init`은 시드를 SATA
|
||||||
|
CD-ROM으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리
|
||||||
|
하드웨어 드라이버를 제외해서 **AHCI 장치를 보지 못한다.** 그러면 cloud-init이
|
||||||
|
데이터소스를 찾지 못하고 아무 오류도 남기지 않은 채 종료한다. 증상은
|
||||||
|
hostname이 `localhost`로 남고 SSH가 `Permission denied (publickey)`로 거부되는
|
||||||
|
것뿐이다.
|
||||||
|
|
||||||
|
게스트에 들어갈 수 없을 때는 화면을 직접 뜬다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
|
||||||
|
```
|
||||||
|
|
||||||
|
`localhost login:`이면 cloud-init 미실행, `kc-lab-1 login:`이면 실행된 것이다.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#cloud-config
|
||||||
|
# Template for both lab guests. scripts/rebuild-seed.sh substitutes __NODE__
|
||||||
|
# and bakes this into a CIDATA seed image.
|
||||||
|
#
|
||||||
|
# Copy to kc-lab.yaml and fill the two placeholders. The real file is ignored by
|
||||||
|
# git because plain_text_passwd is a credential, however disposable.
|
||||||
|
#
|
||||||
|
# Indentation is spaces only. YAML forbids tabs, and cloud-init fails silently
|
||||||
|
# on a parse error: the guest boots as "localhost" with no user and no way in.
|
||||||
|
hostname: kc-lab-__NODE__
|
||||||
|
fqdn: kc-lab-__NODE__
|
||||||
|
manage_etc_hosts: true
|
||||||
|
|
||||||
|
users:
|
||||||
|
- name: donghyeon
|
||||||
|
groups: [sudo]
|
||||||
|
shell: /bin/bash
|
||||||
|
# NOPASSWD is required: the k3s installer and the fault-injection scripts
|
||||||
|
# run non-interactively and would block on a password prompt.
|
||||||
|
sudo: ['ALL=(ALL) NOPASSWD:ALL']
|
||||||
|
# Console-only escape hatch. Without it, a cloud-init failure leaves a guest
|
||||||
|
# that cannot be logged into at all, so its own failure log is unreadable.
|
||||||
|
# ssh_pwauth stays false, so this never widens SSH exposure.
|
||||||
|
lock_passwd: false
|
||||||
|
plain_text_passwd: CHANGE_ME
|
||||||
|
ssh_authorized_keys:
|
||||||
|
# Lab host key: needed because automation runs from the lab host, where
|
||||||
|
# agent forwarding is not available.
|
||||||
|
- CHANGE_ME_LAB_HOST_PUBLIC_KEY
|
||||||
|
# Workstation key: lets ProxyJump reach the guest directly.
|
||||||
|
- CHANGE_ME_WORKSTATION_PUBLIC_KEY
|
||||||
|
|
||||||
|
ssh_pwauth: false
|
||||||
|
package_update: true
|
||||||
|
packages:
|
||||||
|
- curl
|
||||||
|
- nftables
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Lab entry point. Deployed on the lab host as
|
||||||
|
# /etc/nginx/sites-available/keycloak-lab
|
||||||
|
# and symlinked from sites-enabled/.
|
||||||
|
#
|
||||||
|
# Arch does not ship the Debian sites-available convention, so nginx.conf needs
|
||||||
|
# include /etc/nginx/sites-enabled/*;
|
||||||
|
# inside its http { } block before this file has any effect.
|
||||||
|
#
|
||||||
|
# This is the outer of two L7 hops. It terminates TLS and hands plain HTTP to
|
||||||
|
# the Traefik instance running on each k3s node.
|
||||||
|
|
||||||
|
upstream k3s_traefik {
|
||||||
|
# Sticky-session switch. Keycloak recommends affinity on AUTH_SESSION_ID;
|
||||||
|
# ip_hash is the cheap stand-in for a single-browser lab. Leaving it off is
|
||||||
|
# the interesting case: Infinispan still routes correctly, only slower.
|
||||||
|
# ip_hash;
|
||||||
|
server 192.168.122.11:80;
|
||||||
|
server 192.168.122.12:80;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl default_server;
|
||||||
|
http2 on;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# fullchain.pem, never cert.pem: omitting the intermediates passes on
|
||||||
|
# desktop browsers and fails on mobile and curl.
|
||||||
|
ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://k3s_traefik;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header X-Forwarded-Port 443;
|
||||||
|
|
||||||
|
# $remote_addr, not $proxy_add_x_forwarded_for. This is the trust
|
||||||
|
# boundary: a client-supplied X-Forwarded-For must be discarded, not
|
||||||
|
# extended, or nothing downstream can rely on the value.
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Restrict who may reach the echo pods.
|
||||||
|
#
|
||||||
|
# Traefik is configured to trust X-Forwarded-* from the whole pod CIDR, and the
|
||||||
|
# app's Tomcat valve trusts every private range by default. Both are IP-range
|
||||||
|
# decisions, so any pod in the cluster can forge those headers by talking to the
|
||||||
|
# Service directly and bypassing Traefik entirely. Measured, not hypothetical:
|
||||||
|
#
|
||||||
|
# kubectl -n header-lab run t --rm -i --restart=Never --image=curlimages/curl -- \
|
||||||
|
# curl -s http://echo:8081/api/echo -H 'X-Forwarded-Host: evil.example.com'
|
||||||
|
# → serverName evil.example.com, remoteAddr 1.2.3.4
|
||||||
|
#
|
||||||
|
# A NetworkPolicy closes that path. It selects by label rather than IP, so it
|
||||||
|
# survives pod restarts and rescheduling — unlike the trustedIPs list, which
|
||||||
|
# could not name Traefik because its IP changes.
|
||||||
|
#
|
||||||
|
# "Trusting forwarded headers" and "guaranteeing a proxy sits in front" are a
|
||||||
|
# pair. Doing only the first leaves this hole.
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: echo-allow-traefik-only
|
||||||
|
namespace: header-lab
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: echo
|
||||||
|
policyTypes:
|
||||||
|
- Ingress
|
||||||
|
ingress:
|
||||||
|
# The proxy itself. namespaceSelector and podSelector in one list item are
|
||||||
|
# ANDed, so this is "traefik pods in kube-system" and nothing else.
|
||||||
|
- from:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: kube-system
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: traefik
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 8081
|
||||||
|
|
||||||
|
# kubelet readiness/liveness probes originate from the node, not from a pod,
|
||||||
|
# so they need their own rule. Without it the probes fail and the pods are
|
||||||
|
# restarted in a loop.
|
||||||
|
#
|
||||||
|
# The probe's source address is the node's flannel bridge (cni0), which
|
||||||
|
# holds the first address of that node's /24:
|
||||||
|
# kc-lab-1 10.42.0.1 kc-lab-2 10.42.1.1
|
||||||
|
# Listing them as /32 keeps this rule from re-admitting arbitrary pods,
|
||||||
|
# which a broader 10.42.0.0/16 block would do and would undo the policy.
|
||||||
|
#
|
||||||
|
# Adding a node means adding its gateway here. Verify with:
|
||||||
|
# kubectl get nodes -o jsonpath='{range .items[*]}{.spec.podCIDR}{"\n"}{end}'
|
||||||
|
- from:
|
||||||
|
- ipBlock:
|
||||||
|
cidr: 10.42.0.1/32
|
||||||
|
- ipBlock:
|
||||||
|
cidr: 10.42.1.1/32
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 8081
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Header echo workload for the two-hop proxy contract measurement.
|
||||||
|
#
|
||||||
|
# browser -> host nginx (TLS termination) -> Traefik -> this pod
|
||||||
|
#
|
||||||
|
# The image is built from backend/ and imported straight into each node's
|
||||||
|
# containerd, so imagePullPolicy must stay Never. See scripts/build-and-import.sh.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: header-lab
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: echo
|
||||||
|
namespace: header-lab
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: echo
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: echo
|
||||||
|
spec:
|
||||||
|
# One replica per node so the sticky-session switch on the host nginx
|
||||||
|
# upstream has something observable to route between.
|
||||||
|
topologySpreadConstraints:
|
||||||
|
- maxSkew: 1
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
whenUnsatisfiable: ScheduleAnyway
|
||||||
|
labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: echo
|
||||||
|
containers:
|
||||||
|
- name: echo
|
||||||
|
image: keycloak-pattern-api:lab
|
||||||
|
imagePullPolicy: Never
|
||||||
|
ports:
|
||||||
|
- containerPort: 8081
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: SERVER_PORT
|
||||||
|
value: "8081"
|
||||||
|
# "none" makes the app report the raw connection, so scheme/secure/
|
||||||
|
# requestUrl show what arrives without any forwarded-header handling.
|
||||||
|
# Set to "native" and redeploy to see the same request interpreted
|
||||||
|
# with X-Forwarded-* honoured. Keycloak's KC_PROXY_HEADERS is the
|
||||||
|
# same opt-in, which is why measuring both sides matters here.
|
||||||
|
- name: SERVER_FORWARD_HEADERS_STRATEGY
|
||||||
|
value: "native"
|
||||||
|
# The JVM sizes its heap from the container limit, not the host.
|
||||||
|
- name: JAVA_TOOL_OPTIONS
|
||||||
|
value: "-XX:MaxRAMPercentage=70"
|
||||||
|
# /api/echo is permitAll, so the JWT decoder is never exercised.
|
||||||
|
# These stay pointed at the future Keycloak service name.
|
||||||
|
- name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI
|
||||||
|
value: "https://auth.hyeonworks.com/realms/keycloak-patterns"
|
||||||
|
- name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI
|
||||||
|
value: "https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs"
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health/readiness
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 5
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health/liveness
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 45
|
||||||
|
periodSeconds: 15
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 320Mi
|
||||||
|
cpu: 100m
|
||||||
|
limits:
|
||||||
|
memory: 512Mi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: echo
|
||||||
|
namespace: header-lab
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: echo
|
||||||
|
ports:
|
||||||
|
- port: 8081
|
||||||
|
targetPort: http
|
||||||
|
name: http
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: echo
|
||||||
|
namespace: header-lab
|
||||||
|
spec:
|
||||||
|
# k3s ships Traefik as the default ingress controller. Keeping it is what
|
||||||
|
# makes this lab a faithful two-hop replica.
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: app1.hyeonworks.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /api
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: echo
|
||||||
|
port:
|
||||||
|
number: 8081
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Make Traefik trust the X-Forwarded-* headers that the host nginx sets.
|
||||||
|
#
|
||||||
|
# Without this, Traefik rewrites every forwarded header from its own connection,
|
||||||
|
# which is plain HTTP on port 80. The application then sees scheme=http even
|
||||||
|
# though the browser connected over TLS. See docs/two-hop-proxy-header-contract.md.
|
||||||
|
#
|
||||||
|
# k3s installs Traefik through its bundled HelmChart, so values are overridden
|
||||||
|
# with a HelmChartConfig rather than by editing the deployment. k3s reconciles
|
||||||
|
# the chart and recreates the Traefik pod.
|
||||||
|
#
|
||||||
|
# kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
|
||||||
|
# kubectl -n kube-system rollout status deploy/traefik --timeout=180s
|
||||||
|
apiVersion: helm.cattle.io/v1
|
||||||
|
kind: HelmChartConfig
|
||||||
|
metadata:
|
||||||
|
name: traefik
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
valuesContent: |-
|
||||||
|
ports:
|
||||||
|
web:
|
||||||
|
forwardedHeaders:
|
||||||
|
# Requests arriving from these sources keep their existing
|
||||||
|
# X-Forwarded-* values instead of having them rewritten.
|
||||||
|
#
|
||||||
|
# 10.42.0.0/16 is the pod CIDR. It is required because the traefik
|
||||||
|
# Service uses externalTrafficPolicy: Cluster, so svclb SNATs the
|
||||||
|
# traffic and Traefik sees a pod-network address rather than the
|
||||||
|
# host nginx address.
|
||||||
|
#
|
||||||
|
# The node/host range is deliberately absent. Because svclb SNATs,
|
||||||
|
# the host nginx address never reaches Traefik — measured, not assumed.
|
||||||
|
# Trusting a range that cannot appear only widens the surface.
|
||||||
|
#
|
||||||
|
# Trusting the whole pod CIDR still means any pod in the cluster could
|
||||||
|
# forge these headers, which is why echo-network-policy.yaml restricts
|
||||||
|
# who may reach the application at all.
|
||||||
|
trustedIPs:
|
||||||
|
- 10.42.0.0/16
|
||||||
|
websecure:
|
||||||
|
forwardedHeaders:
|
||||||
|
trustedIPs:
|
||||||
|
- 10.42.0.0/16
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the API image on this workstation and import it into each lab node's
|
||||||
|
# containerd.
|
||||||
|
#
|
||||||
|
# k3s does not run Docker and the lab has no registry, so images are shipped as
|
||||||
|
# a stream: docker save -> ssh through the lab host -> k3s ctr images import.
|
||||||
|
# Every node needs its own copy because the scheduler may place the pod anywhere.
|
||||||
|
#
|
||||||
|
# ./deploy/lab/scripts/build-and-import.sh
|
||||||
|
# IMAGE=keycloak-pattern-api:lab NODES="kc-lab-1" ./deploy/lab/scripts/build-and-import.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE="${IMAGE:-keycloak-pattern-api:lab}"
|
||||||
|
NODES="${NODES:-kc-lab-1 kc-lab-2}"
|
||||||
|
LAB_HOST="${LAB_HOST:-test-server}"
|
||||||
|
CONTEXT="${CONTEXT:-backend}"
|
||||||
|
|
||||||
|
repo_root="$(git rev-parse --show-toplevel)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
echo "==> building ${IMAGE} from ${CONTEXT}/"
|
||||||
|
docker build -t "$IMAGE" "$CONTEXT"
|
||||||
|
|
||||||
|
for node in $NODES; do
|
||||||
|
echo "==> importing into ${node}"
|
||||||
|
# Nested ssh: the workstation cannot reach the guests directly because they
|
||||||
|
# sit behind the lab host's libvirt NAT. The lab host's ~/.ssh/config holds
|
||||||
|
# the kc-lab-* aliases.
|
||||||
|
docker save "$IMAGE" \
|
||||||
|
| ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images import -'"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> verifying"
|
||||||
|
for node in $NODES; do
|
||||||
|
printf ' %-10s ' "$node"
|
||||||
|
ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images ls -q'" \
|
||||||
|
| grep -c "$IMAGE" \
|
||||||
|
| xargs -I{} echo "{} match(es)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "next: kubectl rollout restart -n header-lab deployment/echo"
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Measure what the nginx -> Traefik chain actually delivers to the application.
|
||||||
|
#
|
||||||
|
# docs/reverse-proxy-headers.md documents a single-hop nginx contract. The lab
|
||||||
|
# runs two hops, so the forwarded headers are measured rather than assumed.
|
||||||
|
# Run from anywhere that can resolve the lab hostnames.
|
||||||
|
#
|
||||||
|
# ./deploy/lab/scripts/measure-proxy-headers.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="${HOST:-app1.hyeonworks.com}"
|
||||||
|
URL="https://${HOST}/api/echo"
|
||||||
|
|
||||||
|
jqf() {
|
||||||
|
if command -v jq >/dev/null 2>&1; then jq "$@"; else python3 -m json.tool; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== 1. baseline: what the app sees for a normal request ==="
|
||||||
|
curl -s "$URL" | jqf '{
|
||||||
|
scheme, secure, serverName, serverPort, requestUrl, remoteAddr,
|
||||||
|
forwarded: .headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip" or . == "forwarded"))
|
||||||
|
}' 2>/dev/null || curl -s "$URL"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 2. spoof test: client sends its own X-Forwarded-* ==="
|
||||||
|
echo " a trusted boundary must overwrite these, not append to them"
|
||||||
|
curl -s "$URL" \
|
||||||
|
-H 'X-Forwarded-For: 1.2.3.4' \
|
||||||
|
-H 'X-Forwarded-Proto: http' \
|
||||||
|
-H 'X-Forwarded-Host: evil.example.com' \
|
||||||
|
-H 'X-Real-IP: 1.2.3.4' \
|
||||||
|
| jqf '.headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip"))' 2>/dev/null
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 3. which pod answered (host nginx upstream distribution) ==="
|
||||||
|
for _ in 1 2 3 4; do
|
||||||
|
curl -s "$URL" | jqf -r '.headers["x-forwarded-server"] // "n/a"' 2>/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 4. plain HTTP is redirected, not proxied ==="
|
||||||
|
curl -s -o /dev/null -w ' http -> %{http_code} %{redirect_url}\n' "http://${HOST}/api/echo"
|
||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rebuild a guest's cloud-init seed image and publish it into the libvirt pool.
|
||||||
|
# Run on the lab host.
|
||||||
|
#
|
||||||
|
# ./rebuild-seed.sh 1
|
||||||
|
#
|
||||||
|
# The same content lives in three places: the source YAML, the ISO, and the
|
||||||
|
# uploaded pool volume. Editing the YAML alone changes nothing, which is why
|
||||||
|
# this is a script and not a set of remembered commands.
|
||||||
|
#
|
||||||
|
# A rebuilt seed only takes effect on a freshly created VM. cloud-init runs its
|
||||||
|
# per-instance modules once per instance-id, so an existing guest ignores it.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
N="${1:?usage: rebuild-seed.sh <1|2>}"
|
||||||
|
CLOUD_DIR="${CLOUD_DIR:-$HOME/workspace/cloud}"
|
||||||
|
POOL="${POOL:-default}"
|
||||||
|
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||||
|
|
||||||
|
cd "$CLOUD_DIR"
|
||||||
|
src="kc-lab-${N}.yaml"
|
||||||
|
iso="seed-kc-lab-${N}.iso"
|
||||||
|
meta="meta-kc-lab-${N}"
|
||||||
|
|
||||||
|
[ -f "$src" ] || { echo "missing $CLOUD_DIR/$src" >&2; exit 1; }
|
||||||
|
|
||||||
|
# A fresh instance-id makes cloud-init treat the guest as new and re-run the
|
||||||
|
# per-instance modules.
|
||||||
|
printf 'instance-id: kc-lab-%s-%s\nlocal-hostname: kc-lab-%s\n' \
|
||||||
|
"$N" "$(date +%s)" "$N" > "$meta"
|
||||||
|
|
||||||
|
# NoCloud looks for a volume labelled cidata holding files named exactly
|
||||||
|
# user-data and meta-data. -graft-points renames them inside the image so no
|
||||||
|
# staging directory is needed.
|
||||||
|
xorrisofs -quiet -output "$iso" -volid CIDATA -joliet -rock -graft-points \
|
||||||
|
"/user-data=${src}" "/meta-data=${meta}"
|
||||||
|
|
||||||
|
size="$(stat -c%s "$iso")"
|
||||||
|
virsh vol-delete --pool "$POOL" "$iso" >/dev/null 2>&1 || true
|
||||||
|
virsh vol-create-as "$POOL" "$iso" "$size" --format raw >/dev/null
|
||||||
|
virsh vol-upload --pool "$POOL" "$iso" "$iso"
|
||||||
|
|
||||||
|
echo "$iso published to pool '$POOL' ($size bytes)"
|
||||||
|
echo "attach it as a virtio disk, not a SATA cdrom:"
|
||||||
|
echo " --disk vol=${POOL}/${iso},device=disk,bus=virtio,readonly=on"
|
||||||
|
echo "Debian genericcloud images carry no AHCI driver, so a SATA cdrom is invisible"
|
||||||
|
echo "to the guest and cloud-init fails with no error anywhere."
|
||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Confirm the lab infrastructure is intact. Run on the lab host.
|
||||||
|
#
|
||||||
|
# A 404 from the HTTPS entry point is the success signal: TLS terminated and the
|
||||||
|
# request reached Traefik, which simply had no matching ingress rule. A 502 or a
|
||||||
|
# refused connection means the chain is broken somewhere.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
||||||
|
HOSTS="${HOSTS:-auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com}"
|
||||||
|
NODE_IPS="${NODE_IPS:-192.168.122.11 192.168.122.12}"
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
check() { # description, expected, actual
|
||||||
|
if [ "$2" = "$3" ]; then printf ' ok %-34s %s\n' "$1" "$3"
|
||||||
|
else printf ' FAIL %-34s got %s, want %s\n' "$1" "$3" "$2"; fail=1; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== guests =="
|
||||||
|
for name in kc-lab-1 kc-lab-2; do
|
||||||
|
check "$name" running "$(virsh domstate "$name" 2>/dev/null || echo absent)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "== k3s =="
|
||||||
|
ready="$(kubectl get nodes --no-headers 2>/dev/null | grep -c ' Ready ')"
|
||||||
|
check "nodes Ready" 2 "$ready"
|
||||||
|
lb="$(kubectl -n kube-system get svc traefik \
|
||||||
|
-o jsonpath='{.status.loadBalancer.ingress[*].ip}' 2>/dev/null | wc -w)"
|
||||||
|
check "traefik node IPs" 2 "$lb"
|
||||||
|
|
||||||
|
echo "== host nginx =="
|
||||||
|
check "service" active "$(systemctl is-active nginx)"
|
||||||
|
check "cert renew timer" active "$(systemctl is-active certbot-renew.timer)"
|
||||||
|
for ip in $NODE_IPS; do
|
||||||
|
check "traefik $ip" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://${ip}/")"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "== public entry point =="
|
||||||
|
for h in $HOSTS; do
|
||||||
|
check "https://$h" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "https://${h}/")"
|
||||||
|
check "tls verify $h" 0 "$(curl -s -o /dev/null -w '%{ssl_verify_result}' --max-time 8 "https://${h}/")"
|
||||||
|
done
|
||||||
|
check "http redirect" 301 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "http://${HOSTS%% *}/")"
|
||||||
|
|
||||||
|
echo
|
||||||
|
[ "$fail" -eq 0 ] && echo "lab is healthy" || echo "lab has failures"
|
||||||
|
exit "$fail"
|
||||||
@@ -88,58 +88,6 @@ services:
|
|||||||
- keycloak-net
|
- keycloak-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
app-wrong-audience:
|
|
||||||
profiles:
|
|
||||||
- diagnostics
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
environment:
|
|
||||||
SERVER_PORT: "8081"
|
|
||||||
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://localhost:8080/realms/keycloak-patterns
|
|
||||||
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
|
|
||||||
SECURITY_EXPECTED_AUDIENCE: deliberately-wrong-audience
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:18081:8081"
|
|
||||||
depends_on:
|
|
||||||
keycloak:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- CMD-SHELL
|
|
||||||
- wget -q -O - http://127.0.0.1:8081/actuator/health | grep -q '"status":"UP"'
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
start_period: 20s
|
|
||||||
networks:
|
|
||||||
- keycloak-net
|
|
||||||
|
|
||||||
app-wrong-issuer:
|
|
||||||
profiles:
|
|
||||||
- diagnostics
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
environment:
|
|
||||||
SERVER_PORT: "8081"
|
|
||||||
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://wrong-issuer.invalid/realms/keycloak-patterns
|
|
||||||
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
|
|
||||||
SECURITY_EXPECTED_AUDIENCE: keycloak-pattern-api
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:18082:8081"
|
|
||||||
depends_on:
|
|
||||||
keycloak:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- CMD-SHELL
|
|
||||||
- wget -q -O - http://127.0.0.1:8081/actuator/health | grep -q '"status":"UP"'
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
start_period: 20s
|
|
||||||
networks:
|
|
||||||
- keycloak-net
|
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# Account linking UX for the SPA
|
|
||||||
|
|
||||||
두 흐름을 구분한다.
|
|
||||||
|
|
||||||
- 로그인 도중 email collision: Keycloak의 안전한 First Broker Login flow가
|
|
||||||
기존 계정 인증을 요구한다.
|
|
||||||
- 로그인한 사용자가 설정 화면에서 “Google 연결”: Client-Initiated Account
|
|
||||||
Linking URL을 만들어 Keycloak로 redirect한다.
|
|
||||||
|
|
||||||
`createAccountLinkUrl`은 현재 token의 `session_state`, `azp`(issued-for),
|
|
||||||
provider와 nonce를 SHA-256 서명 재료로 사용한다. SPA는 연결 성공 후
|
|
||||||
Account Console 또는 별도 backend read model을 통해 연결 상태를 새로
|
|
||||||
조회해야 하며 email만 보고 “연결됨”을 표시하면 안 된다.
|
|
||||||
|
|
||||||
Unlink는 사용자가 다른 로그인 수단을 갖고 있는지 먼저 안내하고, Keycloak이
|
|
||||||
마지막 federated identity 제거를 거부하면 해당 오류를 그대로 성공처럼
|
|
||||||
처리하지 않는다. production First Broker Login에는 자동 기존-user linking을
|
|
||||||
넣지 않는다.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# AP1 refresh rotation and logout
|
|
||||||
|
|
||||||
Realm 실행 profile:
|
|
||||||
|
|
||||||
- Access Token Lifespan: 300초
|
|
||||||
- Revoke Refresh Token: 활성화
|
|
||||||
- Refresh Token Max Reuse: 0
|
|
||||||
|
|
||||||
`e2e/pattern1.mjs`는 token 원문을 출력하지 않고 다음 순서를 실행한다.
|
|
||||||
|
|
||||||
1. browser Authorization Code + PKCE 로그인으로 AT₁/RT₁/ID Token을 받는다.
|
|
||||||
2. `signoutRedirect()`가 `id_token_hint`를 포함한 Keycloak logout endpoint를
|
|
||||||
호출하는지 확인한다.
|
|
||||||
3. logout 이후 새 authorization 요청에서 로그인 화면이 다시 필요한지
|
|
||||||
확인한다.
|
|
||||||
4. 새 RT₁으로 refresh하여 AT₂/RT₂를 받고 RT₂가 RT₁과 다른지 확인한다.
|
|
||||||
5. 이미 소비된 RT₁을 재사용해 성공하지 않는지 확인한다.
|
|
||||||
6. RT₁ 재사용 뒤 RT₂와 realm session 상태가 어떤 결과를 내는지 status로
|
|
||||||
기록한다. 이 결과를 사전에 family invalidation이라고 단정하지 않는다.
|
|
||||||
7. refresh token을 revoke한 뒤 같은 refresh token의 재사용은 실패하지만,
|
|
||||||
이미 발급된 self-contained access JWT는 `exp` 전까지 Resource Server에서
|
|
||||||
계속 `200`인 stateless 함정을 확인한다.
|
|
||||||
|
|
||||||
logout은 브라우저 SSO session을 종료하는 흐름이고 token revocation은 특정
|
|
||||||
token grant를 폐기하는 흐름이다. 둘은 목적과 endpoint가 다르다.
|
|
||||||
|
|
||||||
즉시 access 차단이 필요한 시스템이라면 짧은 access token TTL 외에
|
|
||||||
introspection, reference token 또는 별도 deny-list 같은 stateful 검증을
|
|
||||||
검토해야 한다. 이 AP1 구현은 JWT의 stateless 검증 특성을 의도적으로
|
|
||||||
유지한다.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# AP1 token storage trade-off
|
|
||||||
|
|
||||||
AP1에서는 `access_token`, `refresh_token`, `id_token`을
|
|
||||||
`oidc-client-ts`의 명시적인 `InMemoryWebStorage`에만 보관한다.
|
|
||||||
`localStorage`와 `sessionStorage`에는 OAuth token을 저장하지 않는다.
|
|
||||||
|
|
||||||
full-page authorization redirect를 생존해야 하는 일회성 transaction
|
|
||||||
state와 PKCE verifier만 `sessionStorage`를 사용한다. callback 성공 후
|
|
||||||
라이브러리가 해당 transaction state를 제거한다.
|
|
||||||
|
|
||||||
| 저장 위치 | reload 생존 | JavaScript 접근 | AP1 선택 |
|
|
||||||
|---|---:|---:|---:|
|
|
||||||
| 메모리 | 아니요 | 실행 중 가능 | 사용 |
|
|
||||||
| `sessionStorage` | 같은 탭에서 가능 | 가능 | token 저장 금지 |
|
|
||||||
| `localStorage` | 예 | 가능 | token 저장 금지 |
|
|
||||||
| HttpOnly cookie | 가능 | raw token 접근 불가 | AP2/AP3의 서버 소유 경계 |
|
|
||||||
|
|
||||||
메모리 저장은 XSS를 제거하지 않는다. 악성 스크립트가 실행 중 `fetch`를
|
|
||||||
후킹하면 SPA가 붙이는 `Authorization: Bearer ...` 헤더를 관찰할 수 있다.
|
|
||||||
다만 persistent storage를 사용하지 않으므로 reload 이후 탈취 가능한 token
|
|
||||||
복사본이 남지 않는다.
|
|
||||||
|
|
||||||
`e2e/pattern1.mjs`는 다음 두 조건을 동시에 검증한다.
|
|
||||||
|
|
||||||
1. access token이 Web Storage 어디에도 존재하지 않는다.
|
|
||||||
2. 실행 중 fetch hook은 Bearer token을 관찰할 수 있다.
|
|
||||||
|
|
||||||
따라서 결론은 “메모리면 XSS에 안전”이 아니라 “영속 탈취 범위를 줄이지만
|
|
||||||
실행 중 XSS에는 여전히 노출”이다.
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
수집 시각: 2026-09-03 15:01:30 KST
|
||||||
|
대상: https://app1.hyeonworks.com/api/echo
|
||||||
|
|
||||||
|
=== [1] 호스트 nginx 가 주입하는 헤더 ===
|
||||||
|
3: server 192.168.122.11:80;
|
||||||
|
4: server 192.168.122.12:80;
|
||||||
|
8: listen 80 default_server;
|
||||||
|
14: listen 443 ssl default_server;
|
||||||
|
26: proxy_set_header Host $host;
|
||||||
|
27: proxy_set_header X-Forwarded-Host $host;
|
||||||
|
28: proxy_set_header X-Forwarded-Proto http;
|
||||||
|
29: proxy_set_header X-Forwarded-Port 80;
|
||||||
|
30: proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
31: proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
||||||
|
=== [2] Traefik entryPoint 인자 (forwardedHeaders 부재 확인) ===
|
||||||
|
["--entryPoints.metrics.address=:9100/tcp"
|
||||||
|
"--entryPoints.traefik.address=:8080/tcp"
|
||||||
|
"--entryPoints.web.address=:8000/tcp"
|
||||||
|
"--entryPoints.websecure.address=:8443/tcp"
|
||||||
|
"--metrics.prometheus.entrypoint=metrics"
|
||||||
|
"--entryPoints.websecure.http.tls=true"
|
||||||
|
→ forwardedHeaders.trustedIPs 인자가 없음 = 기본값(신뢰 안 함)
|
||||||
|
|
||||||
|
=== [3] Traefik 파드 수와 위치 ===
|
||||||
|
traefik-59b7647586-ftwf8 10.42.0.8 kc-lab-1
|
||||||
|
|
||||||
|
=== [4] traefik Service externalTrafficPolicy ===
|
||||||
|
Cluster
|
||||||
|
→ Cluster = svclb 가 SNAT 하여 클라이언트 IP 소실
|
||||||
|
|
||||||
|
=== [5] 앱 파드의 스위치 상태 ===
|
||||||
|
SERVER_PORT=8081
|
||||||
|
SERVER_FORWARD_HEADERS_STRATEGY=none
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
수집 시각: 2026-09-03 15:02:27 KST
|
||||||
|
|
||||||
|
=== [A] 정상 경로 — 브라우저와 같은 요청 ===
|
||||||
|
명령: curl -s https://app1.hyeonworks.com/api/echo
|
||||||
|
x-forwarded-proto http
|
||||||
|
x-forwarded-port 80
|
||||||
|
x-forwarded-for 10.42.0.1
|
||||||
|
x-forwarded-host app1.hyeonworks.com
|
||||||
|
x-real-ip 10.42.0.1
|
||||||
|
x-forwarded-server traefik-59b7647586-ftwf8
|
||||||
|
--- 앱이 해석한 값
|
||||||
|
scheme http
|
||||||
|
secure False
|
||||||
|
serverName app1.hyeonworks.com
|
||||||
|
serverPort 80
|
||||||
|
remoteAddr 10.42.0.8
|
||||||
|
localAddr 10.42.1.3
|
||||||
|
requestUrl http://app1.hyeonworks.com/api/echo
|
||||||
|
|
||||||
|
=== [B] 대조 실험 1 — nginx 우회, 헤더 없이 Traefik 직접 ===
|
||||||
|
명령: curl http://192.168.122.11/api/echo -H 'Host: app1.hyeonworks.com' (test-server 에서)
|
||||||
|
x-forwarded-proto http
|
||||||
|
x-forwarded-port 80
|
||||||
|
x-forwarded-for 10.42.0.1
|
||||||
|
x-forwarded-host app1.hyeonworks.com
|
||||||
|
x-real-ip 10.42.0.1
|
||||||
|
x-forwarded-server traefik-59b7647586-ftwf8
|
||||||
|
--- 앱이 해석한 값
|
||||||
|
scheme http
|
||||||
|
secure False
|
||||||
|
serverName app1.hyeonworks.com
|
||||||
|
serverPort 80
|
||||||
|
remoteAddr 10.42.0.8
|
||||||
|
localAddr 10.42.0.9
|
||||||
|
requestUrl http://app1.hyeonworks.com/api/echo
|
||||||
|
|
||||||
|
=== [C] 대조 실험 2 — nginx 우회, 올바른 헤더를 명시해서 ===
|
||||||
|
명령: 위와 동일 + -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Port: 443' -H 'X-Forwarded-For: 203.0.113.7'
|
||||||
|
x-forwarded-proto http
|
||||||
|
x-forwarded-port 80
|
||||||
|
x-forwarded-for 10.42.0.1
|
||||||
|
x-forwarded-host app1.hyeonworks.com
|
||||||
|
x-real-ip 10.42.0.1
|
||||||
|
x-forwarded-server traefik-59b7647586-ftwf8
|
||||||
|
--- 앱이 해석한 값
|
||||||
|
scheme http
|
||||||
|
secure False
|
||||||
|
serverName app1.hyeonworks.com
|
||||||
|
serverPort 80
|
||||||
|
remoteAddr 10.42.0.8
|
||||||
|
localAddr 10.42.1.3
|
||||||
|
requestUrl http://app1.hyeonworks.com/api/echo
|
||||||
|
|
||||||
|
★ [C] 에서 https/443/203.0.113.7 을 명시했음에도 http/80/10.42.0.1 이 도달했다.
|
||||||
|
→ Traefik 이 들어온 X-Forwarded-* 를 신뢰하지 않고 재작성한다는 독립적 증거.
|
||||||
|
|
||||||
|
=== [D] 위조 테스트 — 클라이언트가 직접 헤더 주입 ===
|
||||||
|
명령: curl https://app1.hyeonworks.com/api/echo -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
|
||||||
|
x-forwarded-proto http
|
||||||
|
x-forwarded-port 80
|
||||||
|
x-forwarded-for 10.42.1.0
|
||||||
|
x-forwarded-host app1.hyeonworks.com
|
||||||
|
x-real-ip 10.42.1.0
|
||||||
|
x-forwarded-server traefik-59b7647586-ftwf8
|
||||||
|
--- 앱이 해석한 값
|
||||||
|
scheme http
|
||||||
|
secure False
|
||||||
|
serverName app1.hyeonworks.com
|
||||||
|
serverPort 80
|
||||||
|
remoteAddr 10.42.0.8
|
||||||
|
localAddr 10.42.0.9
|
||||||
|
requestUrl http://app1.hyeonworks.com/api/echo
|
||||||
|
|
||||||
|
★ evil.example.com 과 1.2.3.4 가 도달하지 않았다 = 신뢰 경계는 작동.
|
||||||
|
|
||||||
|
=== [E] 파드 분배 8회 ===
|
||||||
|
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
||||||
|
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
||||||
|
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
||||||
|
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
||||||
|
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
||||||
|
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
||||||
|
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
||||||
|
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
||||||
|
|
||||||
|
=== [F] HTTP → HTTPS 리다이렉트 ===
|
||||||
|
status=301 location=https://app1.hyeonworks.com/api/echo
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
수집 시각: 2026-09-03 15:32:04 KST
|
||||||
|
단계: A(nginx) + B(Traefik) + C(앱) 모두 적용 후
|
||||||
|
|
||||||
|
=== [1] nginx 가 보내는 값 ===
|
||||||
|
28: proxy_set_header X-Forwarded-Proto https;
|
||||||
|
29: proxy_set_header X-Forwarded-Port 443;
|
||||||
|
30: proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
31: proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
||||||
|
=== [2] Traefik entryPoint 인자 ===
|
||||||
|
"--entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16
|
||||||
|
"--entryPoints.websecure.forwardedHeaders.trustedIPs=10.42.0.0/16
|
||||||
|
|
||||||
|
=== [3] 앱 스위치 ===
|
||||||
|
SERVER_FORWARD_HEADERS_STRATEGY=native
|
||||||
|
|
||||||
|
=== [4] 최종 측정 ===
|
||||||
|
x-forwarded-proto https
|
||||||
|
x-forwarded-port 443
|
||||||
|
x-forwarded-host app1.hyeonworks.com
|
||||||
|
x-real-ip 100.123.124.30
|
||||||
|
x-forwarded-server traefik-697889c85-g7xpp
|
||||||
|
--- 앱이 해석한 값
|
||||||
|
scheme https
|
||||||
|
secure True
|
||||||
|
serverName app1.hyeonworks.com
|
||||||
|
serverPort 443
|
||||||
|
remoteAddr 100.123.124.30
|
||||||
|
localAddr 10.42.0.10
|
||||||
|
requestUrl https://app1.hyeonworks.com/api/echo
|
||||||
|
|
||||||
|
=== [5] 위조 테스트 — 클라이언트가 http/evil/1.2.3.4 를 주입 ===
|
||||||
|
x-forwarded-proto https
|
||||||
|
x-forwarded-port 443
|
||||||
|
x-forwarded-host app1.hyeonworks.com
|
||||||
|
x-real-ip 100.123.124.30
|
||||||
|
x-forwarded-server traefik-697889c85-g7xpp
|
||||||
|
--- 앱이 해석한 값
|
||||||
|
scheme https
|
||||||
|
secure True
|
||||||
|
serverName app1.hyeonworks.com
|
||||||
|
serverPort 443
|
||||||
|
remoteAddr 100.123.124.30
|
||||||
|
localAddr 10.42.1.6
|
||||||
|
requestUrl https://app1.hyeonworks.com/api/echo
|
||||||
|
|
||||||
|
★ 주입값이 하나도 반영되지 않았다. nginx 의 $remote_addr 덮어쓰기가 방어한다.
|
||||||
|
|
||||||
|
=== [6] 파드 분배 6회 ===
|
||||||
|
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
|
||||||
|
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
|
||||||
|
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
|
||||||
|
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
|
||||||
|
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
|
||||||
|
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
수집 시각: 2026-09-03 16:16:48 KST
|
||||||
|
주제: 프록시 우회 경로 차단 (NetworkPolicy)
|
||||||
|
|
||||||
|
=== [1] 차단 전 — 클러스터 안에서 앱에 직접 요청 ===
|
||||||
|
명령: kubectl run ... -- curl http://echo:8081/api/echo \
|
||||||
|
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
|
||||||
|
|
||||||
|
scheme https
|
||||||
|
secure True
|
||||||
|
serverName evil.example.com ← 위조 성공
|
||||||
|
remoteAddr 1.2.3.4 ← 위조 성공
|
||||||
|
requestUrl https://evil.example.com/api/echo
|
||||||
|
|
||||||
|
★ Traefik 을 거치지 않으면 헤더 위조가 그대로 통한다.
|
||||||
|
trustedIPs 와 internalProxies 가 둘 다 '대역'을 믿기 때문.
|
||||||
|
|
||||||
|
=== [2] 적용한 것 ===
|
||||||
|
deploy/lab/k8s/traefik-forwarded-headers.yaml — 192.168.122.0/24 제거
|
||||||
|
"--entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16"
|
||||||
|
"--entryPoints.websecure.forwardedHeaders.trustedIPs=10.42.0.0/16"
|
||||||
|
|
||||||
|
deploy/lab/k8s/echo-network-policy.yaml — Traefik 파드에서만 8081 허용
|
||||||
|
[{"from":[{"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"kube-system"}},"podSelector":{"matchLabels":{"app.kubernetes.io/name":"traefik"}}}],"ports":[{"port":8081,"protocol":"TCP"}]},{"from":[{"ipBlock":{"cidr":"10.42.0.1/32"}},{"ipBlock":{"cidr":"10.42.1.1/32"}}],"ports":[{"port":8081,"protocol":"TCP"}]}]
|
||||||
|
|
||||||
|
=== [3] 차단 후 — 정상 경로 (계속 동작해야 함) ===
|
||||||
|
x-forwarded-proto https
|
||||||
|
x-forwarded-port 443
|
||||||
|
x-forwarded-host app1.hyeonworks.com
|
||||||
|
x-real-ip 100.123.124.30
|
||||||
|
x-forwarded-server traefik-5d6fcf895-wpfhr
|
||||||
|
--- 앱이 해석한 값
|
||||||
|
scheme https
|
||||||
|
secure True
|
||||||
|
serverName app1.hyeonworks.com
|
||||||
|
serverPort 443
|
||||||
|
remoteAddr 100.123.124.30
|
||||||
|
localAddr 10.42.0.14
|
||||||
|
requestUrl https://app1.hyeonworks.com/api/echo
|
||||||
|
|
||||||
|
=== [4] 차단 후 — 우회 시도 ===
|
||||||
|
HTTP 000 / curl exit 7
|
||||||
|
HTTP 000 / curl exit 7
|
||||||
|
|
||||||
|
★ curl exit 7 = Failed to connect. 연결 자체가 성립하지 않는다.
|
||||||
|
|
||||||
|
=== [5] 파드 건강 상태 (probe 가 차단되지 않았는지) ===
|
||||||
|
echo-54dbd94986-8jmdb 1/1 Running restarts=0
|
||||||
|
echo-54dbd94986-lfltk 1/1 Running restarts=0
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# 증거 — 2홉 프록시 헤더 계약 (수정 전 상태)
|
||||||
|
|
||||||
|
`docs/two-hop-proxy-header-contract.md`의 진단을 뒷받침하는 원자료.
|
||||||
|
**모두 수정 전 상태에서 수집**했으며, 수정 후 재수집하여 대조한다.
|
||||||
|
|
||||||
|
수집 시각: 2026-09-03 15:01~15:03 KST
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `01-environment.txt` | 수정 전 세 계층의 설정 스냅샷 |
|
||||||
|
| `02-measurements.txt` | 수정 전 측정 · 대조 실험 · 위조 테스트 · 분배 |
|
||||||
|
| `stage-a-nginx-fixed.png` | A 단계 브라우저 화면 |
|
||||||
|
| `stage-b-traefik-trusts.png` | B 단계 브라우저 화면 |
|
||||||
|
| `stage-c-resolved.png` | C 단계 브라우저 화면 |
|
||||||
|
| `04-after-fix.txt` | 수정 후 측정 · 위조 테스트 · 분배 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 확인된 문제는 둘이다
|
||||||
|
|
||||||
|
최초 진단은 "Traefik이 덮어쓴다" 하나였으나, 증거 수집 과정에서
|
||||||
|
**독립된 원인이 두 개**임이 드러났다.
|
||||||
|
|
||||||
|
### 문제 1 — nginx가 애초에 틀린 값을 보낸다
|
||||||
|
|
||||||
|
`01-environment.txt`
|
||||||
|
|
||||||
|
```
|
||||||
|
26: proxy_set_header Host $host;
|
||||||
|
27: proxy_set_header X-Forwarded-Host $host;
|
||||||
|
28: proxy_set_header X-Forwarded-Proto http; ← https 여야 한다
|
||||||
|
29: proxy_set_header X-Forwarded-Port 80; ← 443 이어야 한다
|
||||||
|
30: proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
31: proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
```
|
||||||
|
|
||||||
|
`listen 443 ssl` 서버 블록 안인데 `X-Forwarded-Proto`가 `http`다.
|
||||||
|
TLS를 종료하는 서버가 "원래 요청은 평문이었다"고 알리고 있다.
|
||||||
|
|
||||||
|
HTTP 전용으로 먼저 세운 뒤 TLS를 얹는 과정에서 **이 두 줄을 함께 바꾸지
|
||||||
|
않아 남은 값**이다. 설정 자체는 문법 오류가 없으므로 `nginx -t`도 통과하고,
|
||||||
|
**아무 경고 없이 잘못된 값이 전파된다.**
|
||||||
|
|
||||||
|
### 문제 2 — Traefik이 올바른 값이 와도 덮어쓴다
|
||||||
|
|
||||||
|
`02-measurements.txt`의 **대조 실험 [C]** 가 이를 독립적으로 증명한다.
|
||||||
|
nginx를 우회해 Traefik에 직접 요청하면서 올바른 헤더를 명시했다.
|
||||||
|
|
||||||
|
```
|
||||||
|
보낸 것 : X-Forwarded-Proto: https
|
||||||
|
X-Forwarded-Port: 443
|
||||||
|
X-Forwarded-For: 203.0.113.7
|
||||||
|
|
||||||
|
도달한 것: x-forwarded-proto http
|
||||||
|
x-forwarded-port 80
|
||||||
|
x-forwarded-for 10.42.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
**세 값 모두 재작성됐다.** Traefik entryPoint에
|
||||||
|
`forwardedHeaders.trustedIPs`가 설정되지 않아 들어온 헤더를 신뢰하지 않는다.
|
||||||
|
|
||||||
|
`01-environment.txt`의 Traefik 인자 목록에 `forwardedHeaders` 관련 항목이
|
||||||
|
하나도 없는 것이 그 근거다.
|
||||||
|
|
||||||
|
**문제 1만 고쳐서는 해결되지 않는다.** 두 원인이 직렬로 걸려 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 브라우저 증거
|
||||||
|
|
||||||
|
스크린샷은 모두 **브라우저가 `/api/echo` 응답을 렌더링한 실제 화면**이다.
|
||||||
|
앱이 정렬된 JSON을 내보내도록 `spring.jackson.serialization.indent-output`을
|
||||||
|
켜두었으므로 브라우저의 JSON 뷰어 설정과 무관하게 동일하게 읽힌다.
|
||||||
|
|
||||||
|
세 장은 **같은 요청을 세 가지 설정 상태에서** 찍은 것이다.
|
||||||
|
|
||||||
|
| 파일 | 켜진 스위치 | 화면에서 확인할 것 |
|
||||||
|
|---|---|---|
|
||||||
|
| `stage-a-nginx-fixed.png` | nginx 만 | `x-forwarded-proto: http` — Traefik 이 덮어씀 |
|
||||||
|
| `stage-b-traefik-trusts.png` | nginx + Traefik | **헤더는 `https`인데 `scheme: http`** |
|
||||||
|
| `stage-c-resolved.png` | 셋 다 | `scheme: https`, `secure: true` |
|
||||||
|
|
||||||
|
**`stage-b`가 가장 중요한 한 장이다.** `x-forwarded-proto: https`가 앱에
|
||||||
|
도착해 있는데도 `scheme: http`, `secure: false`, `requestUrl: http://...`다.
|
||||||
|
**헤더가 도착하는 것과 앱이 그것을 읽는 것은 다른 문제**임을 한 화면이
|
||||||
|
보여준다.
|
||||||
|
|
||||||
|
## 정상으로 확인된 것
|
||||||
|
|
||||||
|
증거 수집에서 **문제가 아니라고 확인된 항목**도 함께 남긴다.
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| TLS 종료 | 정상. 실인증서, `isSecureContext=true` |
|
||||||
|
| `X-Forwarded-Host` | 유지됨 — Traefik이 이것만은 덮어쓰지 않는다 |
|
||||||
|
| 위조 차단 | 클라이언트가 넣은 `evil.example.com`, `1.2.3.4`가 앱에 도달하지 않음 |
|
||||||
|
| 파드 분배 | 8회 요청이 두 파드에 정확히 번갈아 도달 |
|
||||||
|
| HTTP 리다이렉트 | `301 → https://app1.hyeonworks.com/api/echo` |
|
||||||
|
|
||||||
|
**위조가 차단되는 것은 nginx가 막아서가 아니라 Traefik이 전부 덮어쓰기
|
||||||
|
때문**이다. 문제 2를 고치면 이 방어가 nginx의 `$remote_addr` 덮어쓰기로
|
||||||
|
옮겨간다. 수정 후 재측정에서 **위조가 여전히 막히는지 반드시 확인**해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 재수집 방법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 터미널 증거
|
||||||
|
./deploy/lab/scripts/measure-proxy-headers.sh
|
||||||
|
|
||||||
|
# 개별 확인
|
||||||
|
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool
|
||||||
|
|
||||||
|
# 대조 실험 (test-server 에서, nginx 우회)
|
||||||
|
curl -s http://192.168.122.11/api/echo \
|
||||||
|
-H 'Host: app1.hyeonworks.com' \
|
||||||
|
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Port: 443' \
|
||||||
|
-H 'X-Forwarded-For: 203.0.113.7' | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 후 (2026-09-03 15:32 KST)
|
||||||
|
|
||||||
|
세 스위치를 순서대로 켜며 각 단계를 측정했다. 상세 절차는
|
||||||
|
`docs/two-hop-proxy-header-contract.md` 9~11절.
|
||||||
|
|
||||||
|
| 파일 | 단계 |
|
||||||
|
|---|---|
|
||||||
|
| `stage-a-nginx-fixed.png` | A — nginx 만 고침 |
|
||||||
|
| `stage-b-traefik-trusts.png` | B — Traefik `trustedIPs` 추가 |
|
||||||
|
| `stage-c-resolved.png` | C — 앱 `strategy=native` |
|
||||||
|
| `04-after-fix.txt` | 최종 측정 · 위조 테스트 · 분배 |
|
||||||
|
|
||||||
|
스크린샷은 브라우저가 `/api/echo` 응답을 렌더링한 **실제 화면**이다.
|
||||||
|
|
||||||
|
### 단계별 결과
|
||||||
|
|
||||||
|
| 항목 | 최초 | A | B | C |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `x-forwarded-proto` | `http` | **`http`** | `https` | `https` |
|
||||||
|
| `x-real-ip` | `10.42.1.0` | `10.42.1.0` | `100.123.124.30` | `100.123.124.30` |
|
||||||
|
| `scheme` (앱 해석) | `http` | `http` | **`http`** | **`https`** |
|
||||||
|
| `requestUrl` | `http://…` | `http://…` | `http://…` | **`https://…`** |
|
||||||
|
|
||||||
|
**A 이후 아무 변화가 없는 것**이 Traefik 덮어쓰기의 증거이고,
|
||||||
|
**B 이후 헤더는 살아났으나 앱 해석은 그대로인 것**이 2번과 3번 스위치가
|
||||||
|
다른 일을 한다는 증거다.
|
||||||
|
|
||||||
|
### 위조 차단 재확인
|
||||||
|
|
||||||
|
`04-after-fix.txt` [5]. 클라이언트가 `X-Forwarded-Proto: http`,
|
||||||
|
`X-Forwarded-Host: evil.example.com`, `X-Forwarded-For: 1.2.3.4`를 주입했으나
|
||||||
|
**하나도 반영되지 않았다.**
|
||||||
|
|
||||||
|
**방어 주체가 바뀌었다.** 수정 전에는 Traefik이 전부 덮어써서 막았고,
|
||||||
|
수정 후에는 nginx의 `$remote_addr`가 막는다. 그래서 nginx에서
|
||||||
|
`$proxy_add_x_forwarded_for`(덧붙이기)로 바꾸면 안 된다.
|
||||||
|
|
||||||
|
### 겪은 함정
|
||||||
|
|
||||||
|
`kubectl rollout status`가 완료를 알려도 **helm-controller의 Job이 차트를
|
||||||
|
업그레이드하는 동안 구 Traefik 파드가 함께 살아 있다.** 이 시점에 측정하면
|
||||||
|
옛 파드가 응답해 "고쳤는데 안 바뀌었다"고 오해하게 된다. `x-forwarded-server`
|
||||||
|
값의 파드 이름으로 어느 파드가 응답했는지 확인해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프록시 우회 차단 (2026-09-03 16:16 KST)
|
||||||
|
|
||||||
|
`05-networkpolicy.txt`
|
||||||
|
|
||||||
|
헤더 신뢰를 켠 뒤 남아 있던 구멍을 실증하고 막았다.
|
||||||
|
|
||||||
|
**차단 전** — 클러스터 안에서 Traefik을 우회해 앱에 직접 요청하면
|
||||||
|
`serverName: evil.example.com`, `remoteAddr: 1.2.3.4`로 **위조가 성립했다.**
|
||||||
|
|
||||||
|
**적용한 것**
|
||||||
|
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|---|---|
|
||||||
|
| `traefik-forwarded-headers.yaml` | `192.168.122.0/24` 제거 (SNAT 때문에 도달 불가한 대역) |
|
||||||
|
| `echo-network-policy.yaml` | Traefik 파드에서만 8081 허용 (라벨 기준) |
|
||||||
|
|
||||||
|
**차단 후**
|
||||||
|
|
||||||
|
```
|
||||||
|
정상 경로 scheme=https, remoteAddr=100.123.124.30 동작
|
||||||
|
우회 시도 HTTP 000 / curl exit 7 연결 거부
|
||||||
|
파드 상태 1/1 Running, restarts=0 probe 정상
|
||||||
|
```
|
||||||
|
|
||||||
|
`exit 7`은 curl의 "Failed to connect"다. HTTP 403이 아니라
|
||||||
|
**TCP 연결 자체가 성립하지 않았다**는 뜻이다.
|
||||||
|
|
||||||
|
`restarts=0`이 중요하다. NetworkPolicy에서 kubelet probe 경로를 빠뜨리면
|
||||||
|
probe가 실패해 파드가 재시작 루프에 빠진다. 노드의 cni0 주소
|
||||||
|
(`10.42.0.1`, `10.42.1.1`)를 `/32`로 허용해 이를 피했다.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -1,16 +0,0 @@
|
|||||||
# Google federation without an AP1 application fork
|
|
||||||
|
|
||||||
SPA는 Google SDK나 Google token endpoint를 알지 않는다. 기존 `spa-public`
|
|
||||||
client로 Keycloak authorization endpoint를 호출하고, Keycloak 로그인
|
|
||||||
화면에서 mock Google을 선택해도 callback, PKCE 교환, access token audience,
|
|
||||||
Spring API 호출은 로컬 사용자 로그인과 동일하다.
|
|
||||||
|
|
||||||
달라지는 곳은 Keycloak 앞단뿐이다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
SPA -> Keycloak -> Google/mock OIDC
|
|
||||||
SPA <- Keycloak access token <- Keycloak
|
|
||||||
```
|
|
||||||
|
|
||||||
`verify-federation-spa-zero-change.sh`는 기존 SPA 로그인 버튼에서 broker를
|
|
||||||
선택하고, 변경 없는 callback과 `/api/me`가 200인지 실제 브라우저로 검증한다.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# AP1 internal SPA direct: Google-federated profile
|
|
||||||
|
|
||||||
```text
|
|
||||||
SPA -> Keycloak -> Google
|
|
||||||
SPA <- Keycloak code/token <- Keycloak
|
|
||||||
SPA -> Spring API with Keycloak access token
|
|
||||||
```
|
|
||||||
|
|
||||||
Google은 upstream authentication만 담당한다. SPA와 Resource Server의 trust
|
|
||||||
anchor는 계속 Keycloak issuer/JWKS/audience다. 따라서 Spring이 Google
|
|
||||||
ID token을 직접 받거나 Google JWKS를 검증하지 않는다.
|
|
||||||
|
|
||||||
추가 운영 항목은 Google client secret, exact broker redirect URI, safe First
|
|
||||||
Broker Login, `sub` account key, claim mapper다. 로컬에서는 두 번째 Keycloak
|
|
||||||
realm이 Google 역할을 하므로 외부 credential 없이 같은 hop을 재현한다.
|
|
||||||
|
|
||||||
`verify-internal-spa-google-contract.sh`는 broker 설정과 기존 AP1 SPA의
|
|
||||||
zero-change federation E2E를 함께 실행한다.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# AP1 internal SPA direct: local identity profile
|
|
||||||
|
|
||||||
```text
|
|
||||||
Browser SPA --Authorization Code + PKCE--> Keycloak
|
|
||||||
Browser SPA --Bearer access token-------> Spring Resource Server
|
|
||||||
```
|
|
||||||
|
|
||||||
이 profile은 Keycloak 로컬 사용자만으로 동작한다. Google client ID/secret,
|
|
||||||
public domain, broker callback이 없어도 AP1의 login, refresh, logout,
|
|
||||||
audience/issuer 검증과 RBAC를 모두 학습할 수 있다.
|
|
||||||
|
|
||||||
`mock-google` provider가 realm에 함께 존재해도 로컬 로그인은 provider
|
|
||||||
availability에 의존하지 않는다. 실제로 federation 없는 배포를 만들 때는
|
|
||||||
해당 IdP를 disabled로 두거나 realm overlay에서 제거한다.
|
|
||||||
|
|
||||||
빠른 계약 검증은 `verify-internal-spa-no-google-contract.sh`, 실제 브라우저
|
|
||||||
흐름은 `verify-pattern1.sh`가 담당한다.
|
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# 열린 질문 커버리지 — 이 실험대로 답할 수 있는가
|
||||||
|
|
||||||
|
공개 기록(`hyeonworks.com/questions`)에 등록된 KeyCloak Patterns 열린 질문
|
||||||
|
네 개를, 이 실험대가 실제로 검증할 수 있는지 대조한 결과.
|
||||||
|
|
||||||
|
**결론 — 네 개 모두 이 실험대에서 재현 가능하다. 다만 로드맵에 빠진 항목이
|
||||||
|
있고, 순서가 한 곳 뒤집혀 있다.**
|
||||||
|
|
||||||
|
| # | 질문 | 게시 | 로드맵 커버 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Q1 | [서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가](https://hyeonworks.com/questions/server-session-pattern-multi-instance) | 2026.08.29 | **부분** |
|
||||||
|
| Q2 | [Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가](https://hyeonworks.com/questions/refresh-rotation-replica-contention) | 2026.08.26 | **부분** |
|
||||||
|
| Q3 | [BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가](https://hyeonworks.com/questions/bff-session-authorized-client-store) | 2026.08.30 | **부분** |
|
||||||
|
| Q4 | [Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가](https://hyeonworks.com/questions/edge-authorization-scope) | 2026.08.31 | **없음** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 발견한 구조적 문제
|
||||||
|
|
||||||
|
### 1. 순서가 뒤집혀 있다
|
||||||
|
|
||||||
|
Q2가 명시한다.
|
||||||
|
|
||||||
|
> 이 경쟁은 **저장소를 공유한 뒤에야 재현**되기 때문에 저장소 결정을 하고
|
||||||
|
> 나서 해당 문제를 이어서 풀어보자.
|
||||||
|
|
||||||
|
즉 **Q3(저장소 결정) → Q2(경쟁 재현)** 순서다. 그런데 로드맵은
|
||||||
|
`refresh-token-concurrency`를 `redis-app-session-store`보다 **앞**에 두었다.
|
||||||
|
|
||||||
|
**Q2를 먼저 시도하면 재현 자체가 불가능하다.** 저장소가 process-local이면
|
||||||
|
두 replica가 같은 refresh token 항목을 보지 않기 때문이다.
|
||||||
|
|
||||||
|
→ 로드맵 순서를 교정한다.
|
||||||
|
|
||||||
|
### 2. Session과 Authorized Client는 조회 키가 다르다
|
||||||
|
|
||||||
|
Q3의 핵심이며 로드맵에 이 구분이 없었다.
|
||||||
|
|
||||||
|
| 상태 | 조회 키 | 저장 위치(현재) |
|
||||||
|
|---|---|---|
|
||||||
|
| Application Session | **session ID** | 서블릿 컨테이너 in-memory |
|
||||||
|
| OAuth2AuthorizedClient | **client registration 이름 + principal name** | 자동구성 in-memory |
|
||||||
|
|
||||||
|
**`session ID`가 조회 키에 없다.** 그래서 같은 사용자가 두 브라우저에서
|
||||||
|
로그인하면 **동일한 authorized client 항목을 공유**한다.
|
||||||
|
|
||||||
|
Q1의 제약이 이를 그대로 지적한다.
|
||||||
|
|
||||||
|
> 여러 인스턴스가 같은 세션을 사용할 수 있도록 Session Store를 공유
|
||||||
|
> 저장소로 변경하는 것만으로는 **충분하지 않다.**
|
||||||
|
|
||||||
|
→ 실험을 "Redis 도입" 하나로 뭉뚱그리면 안 된다. **두 저장소를 각각 설계하고
|
||||||
|
각각 검증해야 한다.**
|
||||||
|
|
||||||
|
### 3. 이미 해결한 문제가 질문에도 있다
|
||||||
|
|
||||||
|
Q1의 제약:
|
||||||
|
|
||||||
|
> Resource Server의 8081이 host에도 열려 있어서 모든 client가 BFF만 거치도록
|
||||||
|
> **network에서 강제된 상태가 아니다.**
|
||||||
|
|
||||||
|
이는 2홉 헤더 실험에서 마주친 **프록시 우회 경로**와 같은 문제이며,
|
||||||
|
NetworkPolicy로 닫는 방법을 이미 확립했다
|
||||||
|
([`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) 11절).
|
||||||
|
|
||||||
|
→ Q1에 답할 때 그 패턴을 그대로 재사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q1. 다중 인스턴스 운영
|
||||||
|
|
||||||
|
**질문이 요구하는 검증 5단계**
|
||||||
|
|
||||||
|
| # | 검증 | 실험대 가능 | 로드맵 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 한쪽에서 로그인 후 **다른 인스턴스로 요청 시 200 유지** | 가능 | 없음 |
|
||||||
|
| 2 | 한 인스턴스 재시작 후 **같은 session cookie로 상태 유지** | 가능 | 없음 |
|
||||||
|
| 3 | 같은 사용자 두 브라우저 → **authorized client 덮어쓰는가** | 가능 | **없음** |
|
||||||
|
| 4 | 한쪽 logout 후 **다른 쪽 요청** | 가능 | 부분 (백채널 로그아웃) |
|
||||||
|
| 5 | **session 만료 ≠ token 만료** 각 경우의 응답과 화면 | 가능 | **없음** |
|
||||||
|
|
||||||
|
**실험대 준비 상태** — BFF를 2 replica로 띄우면 전부 재현된다. 호스트 nginx의
|
||||||
|
`ip_hash` 주석을 켜고 끄면 **스티키 유무 비교**까지 같은 구성에서 된다.
|
||||||
|
|
||||||
|
**추가로 필요한 것**
|
||||||
|
|
||||||
|
- BFF 이미지 (아직 `bff/` 디렉터리에 소스 없음)
|
||||||
|
- 로그아웃 전파를 관찰할 두 번째 앱 (`app2.hyeonworks.com` 이름은 확보)
|
||||||
|
|
||||||
|
**3번이 특히 중요하다.** "Redis만 붙이면 해결"이라는 착각을 깨는 항목이고,
|
||||||
|
조회 키가 다르다는 사실의 실증이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q2. Refresh Token Rotation 경쟁
|
||||||
|
|
||||||
|
**질문이 요구하는 검증 5단계**
|
||||||
|
|
||||||
|
| # | 검증 | 실험대 가능 | 로드맵 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | replica 두 대에서 **access token 만료 직후 동시 요청** | 가능 | 있음 |
|
||||||
|
| 2 | **이긴 쪽/지는 쪽 응답** 각각 기록 | 가능 | 부분 |
|
||||||
|
| 3 | 지는 쪽이 **저장된 새 token으로 재시도해 성공하는가** | 가능 | **없음** |
|
||||||
|
| 4 | **지는 쪽 사용자 화면**에 무엇이 보이는가 | 가능 | **없음** |
|
||||||
|
| 5 | **lock 유무를 같은 입력으로 비교** (실패율·지연) | 가능 | **없음** |
|
||||||
|
|
||||||
|
**5번이 결론을 내는 기준이다.**
|
||||||
|
|
||||||
|
> 실패가 사용자에게 노출되면 lock을 고르고, 노출되지 않으면 재시도로 둔다.
|
||||||
|
|
||||||
|
로드맵에 없던 항목인데, **이것이 없으면 질문에 답할 수 없다.**
|
||||||
|
|
||||||
|
**제약을 지켜야 한다**
|
||||||
|
|
||||||
|
- rotation + 재사용 0회는 **전제로 고정**한다. 바꾸지 않고 답한다
|
||||||
|
- 이미 발급된 access token은 만료 전까지 통하므로 **재현은 access token 만료
|
||||||
|
직후에 맞춰 실행**한다. 그렇지 않으면 실패가 화면에 보이지 않는다
|
||||||
|
|
||||||
|
**선행 조건** — Q3의 저장소 공유가 먼저다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q3. BFF 저장소 결정
|
||||||
|
|
||||||
|
**질문이 요구하는 검증 5단계**
|
||||||
|
|
||||||
|
| # | 검증 | 실험대 가능 | 로드맵 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 인스턴스 두 대에서 **로그인 유지와 재시작 복구** | 가능 | 부분 |
|
||||||
|
| 2 | 저장소를 열어 **refresh token이 평문인가** | 가능 | **없음** |
|
||||||
|
| 3 | **session TTL ≠ token 만료** 그 순간의 응답과 화면 | 가능 | **없음** |
|
||||||
|
| 4 | logout 뒤 **두 store에 잔여 항목이 없는가** | 가능 | 부분 |
|
||||||
|
| 5 | **저장소를 끊은 상태**에서 로그인·API 호출 오류 | 가능 | 있음 |
|
||||||
|
|
||||||
|
**로드맵에 없던 큰 항목 — 후보 비교**
|
||||||
|
|
||||||
|
질문은 "Redis로 간다"가 아니라 **"Redis와 JDBC 중 무엇이 이 접근 패턴에
|
||||||
|
맞는가"** 를 묻는다.
|
||||||
|
|
||||||
|
> 요청마다 읽는 값과 가끔 읽는 값이 섞여 있다.
|
||||||
|
|
||||||
|
이 실험대에는 PostgreSQL이 이미 있으므로 **JDBC 후보를 같은 조건에서 비교할
|
||||||
|
수 있다.** Redis만 붙이면 질문의 절반만 답하는 셈이다.
|
||||||
|
|
||||||
|
**2번(평문 확인)의 실행 방법**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n <ns> exec -it deploy/redis -- redis-cli --scan --pattern 'spring:session:*'
|
||||||
|
kubectl -n <ns> exec -it deploy/redis -- redis-cli GET <key>
|
||||||
|
```
|
||||||
|
|
||||||
|
저장소를 직접 열어 refresh token이 그대로 읽히는지 본다. 읽힌다면
|
||||||
|
암호화 설계가 필요하고, 그 key 교체 절차는 별도 과제다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q4. Edge 인가 범위 — 로드맵에 전혀 없다
|
||||||
|
|
||||||
|
이 축을 A층(Keycloak)·B층(앱 세션) 중심으로 잡으면서 **AP4의 인가 범위
|
||||||
|
질문을 빠뜨렸다.**
|
||||||
|
|
||||||
|
**질문이 요구하는 검증**
|
||||||
|
|
||||||
|
| 검증 | 실험대 가능 |
|
||||||
|
|---|---|
|
||||||
|
| role을 헤더에 담고 **다중 값 구분자·escaping** 확인 | 가능 |
|
||||||
|
| **헤더 크기 상한** 초과 시 proxy가 자르는가 요청이 거부되는가 | 가능 |
|
||||||
|
| role 변경 후 **몇 번째 요청부터 반영되는가** | 가능 |
|
||||||
|
| upstream이 헤더 존재만 보는가 값과 service identity까지 보는가 | 가능 |
|
||||||
|
|
||||||
|
**이 실험대에서 특히 잘 맞는 이유**
|
||||||
|
|
||||||
|
nginx의 헤더 처리 특성을 이미 실측했다. 질문이 지적한
|
||||||
|
|
||||||
|
> Nginx는 client가 보낸 동명 헤더를 merge하지 않고 **덮어쓴다.**
|
||||||
|
|
||||||
|
는 2홉 헤더 실험에서 `proxy_set_header X-Forwarded-For $remote_addr`로
|
||||||
|
확인한 그 동작이다. **`X-Auth-Request-*`도 같은 규칙을 따르는지**를 같은
|
||||||
|
방법으로 검증할 수 있다.
|
||||||
|
|
||||||
|
그리고 질문의 제약
|
||||||
|
|
||||||
|
> internal token 검사가 controller 한 곳에만 있다. 헤더를 늘리기 전에 이
|
||||||
|
> 검사를 **공통 경계로 옮겨야** 된다.
|
||||||
|
|
||||||
|
는 코드 변경이므로 `backend/`에서 진행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 교정된 실험 순서
|
||||||
|
|
||||||
|
기존 로드맵의 순서를 질문의 의존 관계에 맞춰 조정한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 환경 구축
|
||||||
|
✅ 2홉 프록시 헤더 계약
|
||||||
|
──────────────────────────────────────────────────────────
|
||||||
|
1. Keycloak 멀티노드 클러스터 형성 (선행 인프라)
|
||||||
|
2. persistent vs volatile 세션 (A층)
|
||||||
|
3. BFF 저장소 결정 → Q3 ★ Q2 의 선행 조건
|
||||||
|
4. 다중 인스턴스 운영 → Q1
|
||||||
|
5. Refresh Token 경쟁 → Q2 ★ 3 이후여야 재현됨
|
||||||
|
6. Edge 인가 범위 → Q4 ← 새로 추가
|
||||||
|
7. 장애 주입과 복구 (전 항목 공통)
|
||||||
|
```
|
||||||
|
|
||||||
|
**바뀐 점**
|
||||||
|
|
||||||
|
- `refresh-token-concurrency`가 `redis-app-session-store` **뒤로** 이동
|
||||||
|
- 저장소 결정이 **Redis 도입**이 아니라 **Redis vs JDBC 비교**로 확장
|
||||||
|
- **Edge 인가 범위(Q4)** 신규 추가
|
||||||
|
|
||||||
|
## 브랜치 매핑
|
||||||
|
|
||||||
|
| 실험 | 브랜치 | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| 멀티노드 클러스터 | `feature/keycloak-multinode-cluster-jdbc-ping` | 존재 |
|
||||||
|
| persistent vs volatile | `feature/keycloak-persistent-vs-volatile-sessions` | 존재 |
|
||||||
|
| BFF 저장소 (Q3) | `feature/keycloak-redis-app-session-store` | 존재 — **범위 확장 필요** |
|
||||||
|
| 다중 인스턴스 (Q1) | — | **없음** |
|
||||||
|
| refresh 경쟁 (Q2) | `feature/keycloak-refresh-token-concurrency` | 존재 |
|
||||||
|
| Edge 인가 (Q4) | — | **없음** |
|
||||||
|
| 장애 주입 | `feature/keycloak-failure-injection-recovery` | 존재 |
|
||||||
|
|
||||||
|
**두 개를 새로 만들어야 한다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop-keycloak-session-store
|
||||||
|
git checkout -b feature/keycloak-multi-instance-session-operation
|
||||||
|
git checkout -b feature/keycloak-edge-authorization-scope
|
||||||
|
```
|
||||||
|
|
||||||
|
## 공통 선행 조건 — BFF 구현은 이미 있다
|
||||||
|
|
||||||
|
세 질문(Q1·Q2·Q3)이 모두 **BFF를 2 replica로 띄우는 것**을 전제한다.
|
||||||
|
`develop-keycloak-session-store`의 `bff/`에는 빌드 산출물만 있지만,
|
||||||
|
**`develop-keycloak-pattern3`에 구현이 완성되어 있다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
bff/Dockerfile
|
||||||
|
bff/pom.xml
|
||||||
|
bff/src/main/java/com/example/keycloakpattern/bff/
|
||||||
|
├ BffApplication.java
|
||||||
|
├ BffController.java
|
||||||
|
├ CsrfController.java
|
||||||
|
├ SecurityConfig.java
|
||||||
|
└ SpaCsrfTokenRequestHandler.java
|
||||||
|
bff/src/main/resources/application.yml
|
||||||
|
bff/src/main/resources/static/{index.html,app.js}
|
||||||
|
bff/src/test/java/.../BffControllerTest.java
|
||||||
|
```
|
||||||
|
|
||||||
|
→ 새로 구현할 필요가 없다. **AP3 브랜치에서 이 실험대로 가져온다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop-keycloak-session-store
|
||||||
|
git checkout develop-keycloak-pattern3 -- bff/
|
||||||
|
```
|
||||||
|
|
||||||
|
가져온 뒤 확인할 것 — 질문들이 지목한 부분이 코드에 그대로 있는지.
|
||||||
|
|
||||||
|
| 확인 | 어디를 볼 것인가 |
|
||||||
|
|---|---|
|
||||||
|
| Session 저장소가 in-memory 자동구성인가 | `SecurityConfig.java`, `application.yml`에 Spring Session 설정 부재 |
|
||||||
|
| `OAuth2AuthorizedClientService`가 in-memory인가 | Bean 정의 부재 → 자동구성 결과 확인 필요 |
|
||||||
|
| authorized client 조회에 session ID가 없는가 | Spring Security 기본 계약 |
|
||||||
|
|
||||||
|
Q3가 "어떤 구현체가 실제로 쓰이는지는 자동구성 결과까지 확인해야 정확히
|
||||||
|
알 수 있다"고 남긴 미지수를, **기동 후 Bean을 실제로 조회해서** 확정할 수 있다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n <ns> exec deploy/bff -- \
|
||||||
|
curl -s localhost:8082/actuator/beans | grep -i authorizedClientService
|
||||||
|
```
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
| 문서 | 관계 |
|
||||||
|
|---|---|
|
||||||
|
| [`session-store-lab-roadmap.md`](session-store-lab-roadmap.md) | 이 문서가 그 순서를 교정한다 |
|
||||||
|
| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | Q1의 우회 경로 제약, Q4의 헤더 덮어쓰기 근거 |
|
||||||
|
| [`session-lab-operations.md`](session-lab-operations.md) | 실행 도구와 명령 |
|
||||||
|
| [`four-pattern-tradeoff-matrix.md`](four-pattern-tradeoff-matrix.md) | Q4가 되돌아가는 선택지(BFF)의 비교표 |
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Authorization Code + PKCE stages
|
|
||||||
|
|
||||||
1. SPA가 매 로그인마다 고엔트로피 `code_verifier`를 생성한다.
|
|
||||||
2. SHA-256과 Base64URL로 `code_challenge`를 만든다.
|
|
||||||
3. authorization request에는 challenge와 `S256`만 전송한다.
|
|
||||||
4. redirect의 code와 저장해 둔 state를 대조한다.
|
|
||||||
5. token request에 원래 verifier를 보내 code를 교환한다.
|
|
||||||
6. verifier/state/code는 한 번 사용한 뒤 메모리에서 제거한다.
|
|
||||||
|
|
||||||
Keycloak client는 public client이며 implicit와 password grant를 끄고 S256을
|
|
||||||
강제한다. PKCE는 악성 redirect endpoint가 code만 가로챘을 때의 교환을 막지만,
|
|
||||||
SPA 실행 컨텍스트를 장악한 XSS 자체를 막지는 않는다.
|
|
||||||
|
|
||||||
`verify-pkce-flow-stages.sh`는 Web Crypto 단위 테스트, realm client 계약,
|
|
||||||
authorization/token 요청의 필드를 함께 검사한다.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Refresh token rotation contract
|
|
||||||
|
|
||||||
Realm 설정은 refresh token revoke를 활성화하고 재사용 허용 횟수를 0으로 둔다.
|
|
||||||
SPA는 새 token set을 받은 즉시 이전 refresh token을 폐기한다.
|
|
||||||
|
|
||||||
실제 AP1 E2E는 다음을 구분한다.
|
|
||||||
|
|
||||||
- RT1로 refresh하면 RT2가 새로 발급된다.
|
|
||||||
- 이미 소비한 RT1 재사용은 성공하면 안 된다.
|
|
||||||
- 재사용 탐지 후 RT2까지 무효화할지는 Keycloak realm 정책과 동시 요청
|
|
||||||
상황에 따라 관찰한다.
|
|
||||||
- refresh token revoke 이후 refresh는 실패한다.
|
|
||||||
- 이미 발급된 stateless access JWT는 `exp`까지 유효할 수 있다.
|
|
||||||
|
|
||||||
`verify-refresh-token-rotation-contract.sh`는 realm과 E2E assertions의 계약을
|
|
||||||
빠르게 확인한다. 실제 token 수명주기 재현은 `verify-pattern1.sh`가 수행한다.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,434 @@
|
|||||||
|
# 실험대 운영 — 도구 · 명령 · 훈련
|
||||||
|
|
||||||
|
실험을 돌리는 데 반복해서 쓰는 것들. 개념은
|
||||||
|
[`session-lab-concepts.md`](session-lab-concepts.md), 계획은
|
||||||
|
[`session-store-lab-roadmap.md`](session-store-lab-roadmap.md)에 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 관측 도구
|
||||||
|
|
||||||
|
### htop
|
||||||
|
|
||||||
|
**설치** — 세 대 모두 배포판이 다르다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# lab host (Arch)
|
||||||
|
sudo pacman -S htop
|
||||||
|
|
||||||
|
# 게스트 (Debian) — lab host 에서 한 번에
|
||||||
|
for n in kc-lab-1 kc-lab-2; do
|
||||||
|
ssh $n 'sudo apt-get update -qq && sudo apt-get install -y -qq htop'
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
**lab host에서 htop을 읽는 법 — VM은 프로세스로 보인다**
|
||||||
|
|
||||||
|
가장 중요한 관점이다. 게스트는 **호스트 입장에서 `qemu-system-x86_64`
|
||||||
|
프로세스 하나**다. 그래서 VM의 메모리 사용량이 곧 그 프로세스의 RES다.
|
||||||
|
|
||||||
|
```
|
||||||
|
pid=4677 RSS=3765MB qemu-system-x86 ← kc-lab-1 (할당 3584M)
|
||||||
|
pid=4770 RSS=2670MB qemu-system-x86 ← kc-lab-2 (할당 2560M)
|
||||||
|
```
|
||||||
|
|
||||||
|
**RSS가 할당량보다 조금 큰 이유**는 QEMU 자체의 에뮬레이션 오버헤드
|
||||||
|
(장치 모델, 버퍼)가 더해지기 때문이다. 게스트가 터치한 페이지만큼만
|
||||||
|
RSS로 잡히므로, 게스트가 메모리를 더 쓰면 RSS도 할당 상한까지 올라간다.
|
||||||
|
|
||||||
|
**htop 안에서 쓸 키**
|
||||||
|
|
||||||
|
| 키 | 동작 | 이 실험대에서 |
|
||||||
|
|---|---|---|
|
||||||
|
| `F5` | 트리 뷰 | `libvirtd` → `qemu-system` 계층 확인 |
|
||||||
|
| `F4` | 필터 | `qemu`만 보기 |
|
||||||
|
| `F6` | 정렬 기준 | `PERCENT_MEM`으로 정렬 |
|
||||||
|
| `M` / `P` | 메모리/CPU 정렬 | |
|
||||||
|
| `u` | 사용자 필터 | `libvirt-qemu`로 VM만 |
|
||||||
|
| `H` | 스레드 숨김 | QEMU는 vCPU마다 스레드라 켜두면 지저분하다 |
|
||||||
|
|
||||||
|
**게스트 안에서 htop을 읽을 때** 보이는 것은 `k3s`(server 또는 agent),
|
||||||
|
`containerd`, `containerd-shim`, 그리고 각 파드의 `java` 프로세스다.
|
||||||
|
Java 힙 상한은 컨테이너 limit의 70%(`-XX:MaxRAMPercentage=70`)이므로
|
||||||
|
512Mi limit이면 약 358Mi다.
|
||||||
|
|
||||||
|
### k9s
|
||||||
|
|
||||||
|
설치는 되어 있다. 별도 구성 없이 `~/.kube/config`를 읽는다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
k9s
|
||||||
|
```
|
||||||
|
|
||||||
|
| 키 | 동작 |
|
||||||
|
|---|---|
|
||||||
|
| `:` | 명령 모드 — `:pods` `:svc` `:ing` `:nodes` `:events` |
|
||||||
|
| `0` | 전체 네임스페이스 |
|
||||||
|
| `/` | 필터 |
|
||||||
|
| `l` | 로그 |
|
||||||
|
| `d` | describe |
|
||||||
|
| `y` | YAML |
|
||||||
|
| `s` | 파드 안 셸 |
|
||||||
|
| `Ctrl+d` | 파드 삭제 ← **장애 주입에 씀** |
|
||||||
|
| `esc` / `q` | 뒤로 / 종료 |
|
||||||
|
|
||||||
|
`~/.config/k9s/config.yaml`의 `refreshRate`를 2초로 낮추면 노드를 죽였을 때
|
||||||
|
파드 재배치가 실시간으로 보인다.
|
||||||
|
|
||||||
|
### kubectl top
|
||||||
|
|
||||||
|
k3s가 metrics-server를 기본 배포하므로 바로 쓸 수 있다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl top nodes
|
||||||
|
kubectl -n header-lab top pods
|
||||||
|
```
|
||||||
|
|
||||||
|
**htop과 보는 층이 다르다.**
|
||||||
|
|
||||||
|
| | 보는 것 |
|
||||||
|
|---|---|
|
||||||
|
| `htop` (lab host) | VM 프로세스 = 게스트 전체 |
|
||||||
|
| `htop` (게스트) | 게스트 안의 프로세스 |
|
||||||
|
| `kubectl top` | 파드·노드 단위, 클러스터 관점 |
|
||||||
|
|
||||||
|
### 상태 점검 스크립트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy/lab/scripts/verify-lab.sh # lab host 에서
|
||||||
|
./deploy/lab/scripts/measure-proxy-headers.sh # 어디서든
|
||||||
|
```
|
||||||
|
|
||||||
|
`verify-lab.sh`는 게스트·k3s·nginx·인증서·공개 진입점을 한 번에 확인하고
|
||||||
|
`lab is healthy`를 출력한다. **`404`가 성공 신호**다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 자원 실측과 예산
|
||||||
|
|
||||||
|
**2026-09-03 기준, Keycloak 배포 전**
|
||||||
|
|
||||||
|
### 호스트 여유와 게스트 여유는 다르다
|
||||||
|
|
||||||
|
가장 오해하기 쉬운 지점이다. 호스트만 보면 절망적으로 보인다.
|
||||||
|
|
||||||
|
```
|
||||||
|
lab host 총 7628MB · 사용 7189MB · 여유 439MB
|
||||||
|
├ qemu #1 RSS 3765MB kc-lab-1 (할당 3584MB) → 상한 도달
|
||||||
|
└ qemu #2 RSS 2633MB kc-lab-2 (할당 2560MB) → 상한 도달
|
||||||
|
```
|
||||||
|
|
||||||
|
그런데 게스트 안을 보면 여유가 있다.
|
||||||
|
|
||||||
|
```
|
||||||
|
kc-lab-1 총 3423MB · used 1464 · buff/cache 2020 · available 1959MB
|
||||||
|
kc-lab-2 총 2480MB · used 580 · buff/cache 1714 · available 1899MB
|
||||||
|
─────────────────
|
||||||
|
게스트 여유 합계 약 3.8GB
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이런가** — QEMU의 RSS는 게스트가 **터치한 페이지**만큼이다. 게스트가
|
||||||
|
메모리를 페이지 캐시로 다 채우면 QEMU RSS도 할당 상한까지 올라간다.
|
||||||
|
지금이 그 상태다.
|
||||||
|
|
||||||
|
**그래서 앞으로 워크로드를 올려도 호스트 압박은 늘지 않는다.** 게스트 안의
|
||||||
|
페이지 캐시가 밀려날 뿐이다. **QEMU RSS는 이미 천장이다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
확인 방법:
|
||||||
|
ps -eo rss,args --sort=-rss | grep '[q]emu-system' # 호스트에서 본 VM
|
||||||
|
ssh kc-lab-1 free -m # 게스트 안 실제
|
||||||
|
kubectl top nodes # working set
|
||||||
|
```
|
||||||
|
|
||||||
|
세 값이 다른 것을 보는 것이 이 실험대의 메모리 감각이다.
|
||||||
|
|
||||||
|
### 배포 예산
|
||||||
|
|
||||||
|
| 워크로드 | 예상 | 배치 |
|
||||||
|
|---|---|---|
|
||||||
|
| Keycloak × 2 | 각 700Mi | 노드당 1개 |
|
||||||
|
| PostgreSQL | 300Mi | kc-lab-1 |
|
||||||
|
| Redis | 100Mi | kc-lab-2 |
|
||||||
|
| BFF × 2 | 각 400Mi | 노드당 1개 |
|
||||||
|
| **합계** | **약 2600Mi** | |
|
||||||
|
|
||||||
|
**게스트 여유 3.8GB 중 2.6GB → 가능하다.** 다만 여기에
|
||||||
|
Prometheus/Grafana(로드맵 10번 관측성)를 얹을 여유는 없다.
|
||||||
|
|
||||||
|
### 대응 — 비용이 없는 것부터
|
||||||
|
|
||||||
|
**1. 끝난 실험은 지운다**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl delete ns header-lab # 파드 2개 × 150Mi 회수
|
||||||
|
```
|
||||||
|
|
||||||
|
증거는 `docs/evidence/`에 남아 있으므로 워크로드를 유지할 이유가 없다.
|
||||||
|
|
||||||
|
**2. Keycloak 힙을 명시적으로 제한한다**
|
||||||
|
|
||||||
|
Keycloak은 기본값이 넉넉해 그냥 두면 1GB를 넘긴다.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
- name: JAVA_OPTS_KC_HEAP
|
||||||
|
value: "-Xms256m -Xmx512m"
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 768Mi
|
||||||
|
```
|
||||||
|
|
||||||
|
**모든 워크로드에 `resources.limits`를 반드시 건다.** 안 걸면 한 파드가
|
||||||
|
게스트 메모리를 다 먹고 다른 파드까지 OOMKilled된다.
|
||||||
|
|
||||||
|
**3. 실험을 순차로 돌린다 — 동시에 다 띄우지 않는다**
|
||||||
|
|
||||||
|
```
|
||||||
|
A층(Keycloak + PostgreSQL) → 결과 기록 → 정리
|
||||||
|
↓
|
||||||
|
B층(BFF + Redis) → 결과 기록 → 정리
|
||||||
|
↓
|
||||||
|
관측성(Prometheus) → 필요할 때만
|
||||||
|
```
|
||||||
|
|
||||||
|
절약책이 아니라 **정상적인 실험 운영 방식**이다. 동시에 띄우면 변수가
|
||||||
|
섞여서 원인 분리가 어려워진다.
|
||||||
|
|
||||||
|
### swap은 쓰지 않는다
|
||||||
|
|
||||||
|
호스트에는 8GB swap이 있지만 **게스트에는 0MB이며, 그것이 맞다.**
|
||||||
|
|
||||||
|
| 이유 | |
|
||||||
|
|---|---|
|
||||||
|
| k3s/kubelet | 기본적으로 swap 을 거부한다 |
|
||||||
|
| 성능 | 호스트 swap 으로 QEMU 페이지가 밀리면 급락한다 |
|
||||||
|
| **측정 오염** | 이 실험대는 **타이밍**(refresh 경쟁, Infinispan 복제 지연)을 잰다. swap 이 끼면 측정이 통째로 무의미해진다 |
|
||||||
|
|
||||||
|
### 근본 해결 — 메모리 증설
|
||||||
|
|
||||||
|
남은 실험이 10개이고 관측성까지 하려면 증설이 가장 확실하다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pacman -S dmidecode
|
||||||
|
sudo dmidecode -t memory | grep -E "Maximum Capacity|Number Of Devices|Size:|Locator:|Type:|Speed:"
|
||||||
|
```
|
||||||
|
|
||||||
|
| 슬롯 상태 | 조치 |
|
||||||
|
|---|---|
|
||||||
|
| 2슬롯 중 1개만 사용 | 동일 규격 8GB 추가 → 16GB |
|
||||||
|
| 온보드 8GB + 슬롯 1개 | 16GB 추가 → 24GB |
|
||||||
|
| 2슬롯 모두 사용 | 8GB × 2 를 16GB × 2 로 교체 |
|
||||||
|
|
||||||
|
i5-1135G7(Tiger Lake)은 DDR4-3200 SO-DIMM을 쓰며 최대 용량은 보드마다
|
||||||
|
다르므로 `Maximum Capacity` 값을 확인한다. **비용 대비 효과가 가장 크다** —
|
||||||
|
증설하면 Prometheus·Grafana·BFF 2 replica를 동시에 띄우고도 남는다.
|
||||||
|
|
||||||
|
## 3. 자주 쓰는 명령
|
||||||
|
|
||||||
|
### VM (lab host, `LIBVIRT_DEFAULT_URI=qemu:///system`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh list --all # 상태
|
||||||
|
virsh domstate kc-lab-1
|
||||||
|
virsh domblklist kc-lab-1 # 붙은 디스크
|
||||||
|
virsh net-dhcp-leases default # 게스트 IP
|
||||||
|
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 화면 (PNG 로 저장됨)
|
||||||
|
virsh send-key kc-lab-1 --codeset linux KEY_ENTER
|
||||||
|
|
||||||
|
virsh destroy kc-lab-1 # 전원 강제 차단 = 노드 상실
|
||||||
|
virsh start kc-lab-1 # 재기동
|
||||||
|
virsh shutdown kc-lab-1 # ACPI 정상 종료
|
||||||
|
```
|
||||||
|
|
||||||
|
**`destroy`는 파일을 지우지 않는다.** 전원 코드를 뽑는 것에 해당한다.
|
||||||
|
정의와 디스크를 지우는 것은 `undefine`이다.
|
||||||
|
|
||||||
|
### 클러스터
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get nodes -o wide
|
||||||
|
kubectl get pods -A -o wide
|
||||||
|
kubectl -n <ns> logs -f deployment/<name>
|
||||||
|
kubectl -n <ns> describe pod <pod>
|
||||||
|
kubectl -n <ns> rollout status deployment/<name>
|
||||||
|
kubectl -n <ns> rollout restart deployment/<name>
|
||||||
|
kubectl -n <ns> rollout undo deployment/<name> # 직전 버전으로
|
||||||
|
|
||||||
|
# 설정 스위치 껐다 켜기 — 실험의 기본 동작
|
||||||
|
kubectl -n <ns> set env deployment/<name> KEY=VALUE
|
||||||
|
|
||||||
|
# 임시 파드로 클러스터 안에서 테스트
|
||||||
|
kubectl -n <ns> run t --rm -i --restart=Never --image=curlimages/curl:8.11.1 -- \
|
||||||
|
curl -s http://<svc>:<port>/path
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이미지 반입
|
||||||
|
|
||||||
|
k3s는 containerd를 쓰고 레지스트리가 없다. **자체 빌드 이미지는 매번 이
|
||||||
|
경로를 탄다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 워크스테이션에서
|
||||||
|
docker build -t keycloak-pattern-api:lab backend
|
||||||
|
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||||
|
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||||||
|
|
||||||
|
# 확인
|
||||||
|
ssh test-server "ssh kc-lab-1 'sudo k3s ctr images ls -q | grep keycloak-pattern'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**두 노드 모두**에 넣는다. 스케줄러가 어디에 배치할지 모른다.
|
||||||
|
매니페스트는 `imagePullPolicy: Never`여야 한다.
|
||||||
|
|
||||||
|
`ctr`이 아니라 **`k3s ctr`** 이다. 시스템에 별도 `ctr`이 있으면 다른 소켓을
|
||||||
|
보게 되어 "성공했는데 파드는 못 찾는" 상태가 된다.
|
||||||
|
|
||||||
|
### 저장소·브랜치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 워크스테이션 — 작성·커밋
|
||||||
|
git add -A && git commit -m "..." && git push origin develop-keycloak-session-store
|
||||||
|
|
||||||
|
# lab host — 받기만 (읽기 전용으로 운용)
|
||||||
|
cd ~/workspace/keycloak-pattern && git pull
|
||||||
|
|
||||||
|
# 실험별 브랜치 이동
|
||||||
|
git checkout feature/keycloak-multinode-cluster-jdbc-ping
|
||||||
|
```
|
||||||
|
|
||||||
|
**lab host의 저장소는 읽기 전용으로 쓴다.** 거기서 편집하면 드리프트가
|
||||||
|
생긴다 — nginx 설정에서 실제로 겪었다
|
||||||
|
([`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) 9절).
|
||||||
|
|
||||||
|
### 호스트 nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
sudo nginx -T | grep -n 'upstream\|server_name' # 최종 병합 설정
|
||||||
|
```
|
||||||
|
|
||||||
|
**`nginx -t`를 통과한 뒤에만 reload한다.** 깨진 설정으로 reload하면 서비스가
|
||||||
|
내려간다. `-T`(대문자)는 include까지 펼친 최종 설정을 출력하므로
|
||||||
|
"파일을 고쳤는데 반영이 안 된다" 상황의 확인 수단이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 손에 익혀야 할 훈련
|
||||||
|
|
||||||
|
개념은 알지만 직접 해보지 않은 조작들. **남은 실험 5개 중 4개가 훈련 3
|
||||||
|
위에 서 있다.**
|
||||||
|
|
||||||
|
### 훈련 1 — 이미지 반입
|
||||||
|
|
||||||
|
앞으로 BFF·token-mediator를 올릴 때마다 반복된다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t keycloak-pattern-api:lab backend
|
||||||
|
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
||||||
|
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
||||||
|
ssh test-server "ssh kc-lab-1 'sudo k3s ctr images ls -q | grep keycloak-pattern'"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 훈련 2 — 설정 스위치 껐다 켜기
|
||||||
|
|
||||||
|
**설정을 바꿔가며 비교하는 것이 이 실험대의 본체**다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n header-lab set env deployment/echo SERVER_FORWARD_HEADERS_STRATEGY=none
|
||||||
|
kubectl -n header-lab rollout status deployment/echo
|
||||||
|
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool | grep -E '"scheme"|"secure"'
|
||||||
|
# → "http" / false 로 바뀐다
|
||||||
|
|
||||||
|
kubectl -n header-lab set env deployment/echo SERVER_FORWARD_HEADERS_STRATEGY=native
|
||||||
|
kubectl -n header-lab rollout status deployment/echo
|
||||||
|
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool | grep -E '"scheme"|"secure"'
|
||||||
|
# → "https" / true 로 돌아온다
|
||||||
|
```
|
||||||
|
|
||||||
|
### 훈련 3 — 노드를 죽였다 살리기
|
||||||
|
|
||||||
|
**가장 중요하다.** 장애 실험의 전제 조작이다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 죽이기 — 전원 차단에 해당
|
||||||
|
virsh destroy kc-lab-2
|
||||||
|
|
||||||
|
# 관찰 (NotReady 로 바뀌는 데 40초 안팎)
|
||||||
|
kubectl get nodes
|
||||||
|
kubectl -n header-lab get pods -o wide
|
||||||
|
kubectl get events -A --sort-by=.lastTimestamp | tail -20
|
||||||
|
|
||||||
|
# 서비스가 살아있는지
|
||||||
|
curl -sI https://app1.hyeonworks.com/api/echo | head -1
|
||||||
|
|
||||||
|
# 되살리기
|
||||||
|
virsh start kc-lab-2
|
||||||
|
kubectl get nodes # Ready 복귀
|
||||||
|
kubectl -n header-lab get pods -o wide
|
||||||
|
```
|
||||||
|
|
||||||
|
**이번에 특별히 확인할 것** — 현재 **Traefik은 replica 1**이고
|
||||||
|
`kc-lab-2`에 있다. 그 노드를 죽이면 **진입점 자체가 사라지는지**,
|
||||||
|
아니면 다른 노드로 재배치되어 복구되는지 관찰한다.
|
||||||
|
|
||||||
|
| 관찰 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| `curl`이 계속 200 | svclb가 남은 노드로 흘려보냄 + Traefik 재배치 성공 |
|
||||||
|
| `curl`이 실패했다가 복구 | 재배치에 걸린 시간만큼 다운타임 |
|
||||||
|
| `curl`이 계속 실패 | Traefik replica를 2로 늘려야 한다 |
|
||||||
|
|
||||||
|
이 결과에 따라 **Keycloak 배포 전에 Traefik replica를 조정할지** 결정한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 필요하다면
|
||||||
|
kubectl -n kube-system scale deployment/traefik --replicas=2
|
||||||
|
```
|
||||||
|
|
||||||
|
**망가져도 된다.** `virt-install` 한 줄로 재생성되며
|
||||||
|
([`deploy/lab/README.md`](../deploy/lab/README.md) 게스트 재생성),
|
||||||
|
그러라고 만든 실험대다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 실행 주체 감사 (2026-09-03)
|
||||||
|
|
||||||
|
무엇을 직접 했고 무엇을 대행했는지의 기록. **대행한 항목은 언젠가 직접
|
||||||
|
해야 하는 것들**이다.
|
||||||
|
|
||||||
|
### 직접 수행
|
||||||
|
|
||||||
|
VM 생성 · cloud-init 작성 · SSH 키와 config · k3s server/agent 설치 ·
|
||||||
|
kubeconfig 구성 · 호스트 nginx 설치와 설정 · certbot DNS-01 인증서 발급 ·
|
||||||
|
Cloudflare DNS 레코드 · git 저장소 클론과 브랜치 · `echo.yaml` 최초 배포 ·
|
||||||
|
nginx `X-Forwarded-Proto` 수정
|
||||||
|
|
||||||
|
### 대행 수행
|
||||||
|
|
||||||
|
| 작업 | 언제 다시 필요한가 |
|
||||||
|
|---|---|
|
||||||
|
| 이미지 빌드 → 노드 반입 (2회차 이후) | 자체 이미지를 올릴 때마다 |
|
||||||
|
| `kubectl apply` (traefik HelmChartConfig, NetworkPolicy) | 모든 배포 |
|
||||||
|
| `kubectl set env` / `rollout restart` | 설정 비교 실험마다 |
|
||||||
|
| HelmChartConfig 재조정 대기와 파드 확인 | Traefik 설정 변경 시 |
|
||||||
|
| git 브랜치 생성 · 커밋 · push | 실험마다 |
|
||||||
|
| Playwright 증거 수집 | 브라우저 관점이 필요할 때마다 |
|
||||||
|
| `kubectl run` 임시 파드 위조 테스트 | 클러스터 내부 검증 시 |
|
||||||
|
| 설정 되돌렸다 복구 (단계별 스크린샷) | 비교 증거를 만들 때 |
|
||||||
|
|
||||||
|
### 아직 한 번도 하지 않은 것
|
||||||
|
|
||||||
|
| 항목 | 필요해지는 시점 |
|
||||||
|
|---|---|
|
||||||
|
| **`virsh destroy` + 재생성** | **장애 실험 전부** |
|
||||||
|
| `rebuild-seed.sh` | cloud-init 을 바꿀 때 |
|
||||||
|
| k9s 실사용 | 장애 중 상태 관찰 |
|
||||||
|
| `kubectl delete ns` | 실험 정리, 메모리 회수 |
|
||||||
|
|
||||||
|
### 만들었지만 미검증이었던 것 → 2026-09-03 확인 완료
|
||||||
|
|
||||||
|
| 스크립트 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| `verify-lab.sh` | 정상 — `lab is healthy` |
|
||||||
|
| `measure-proxy-headers.sh` | 정상 — 4개 항목 모두 출력 |
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
# 세션 저장소 실험 축 — 계획과 진행
|
||||||
|
|
||||||
|
`develop-keycloak-session-store` 브랜치가 담당하는 작업의 전체 지도.
|
||||||
|
**무엇이 끝났고 무엇이 남았는지**를 여기서 추적한다.
|
||||||
|
|
||||||
|
## 왜 별도 축인가
|
||||||
|
|
||||||
|
네 인증 패턴(AP1~AP4)은 **브라우저와 토큰의 관계**를 비교한다. 이 축은
|
||||||
|
그것과 직교하는 질문을 다룬다 — **세션과 토큰이 서버 쪽 어디에 저장되고,
|
||||||
|
그 저장소가 죽으면 무슨 일이 벌어지는가.**
|
||||||
|
|
||||||
|
초기 검토에서 전제 하나가 교정됐다. **Keycloak은 Redis를 세션 저장소로
|
||||||
|
지원하지 않는다.** 그래서 이 축은 두 계층으로 갈린다.
|
||||||
|
|
||||||
|
| 계층 | 저장소 | 해당 패턴 |
|
||||||
|
|---|---|---|
|
||||||
|
| **A. Keycloak 자체** | 임베디드 Infinispan + PostgreSQL | 네 패턴 공통 |
|
||||||
|
| **B. 애플리케이션 세션** | **Redis** | AP2 / AP3 / AP4 |
|
||||||
|
|
||||||
|
A층은 네 패턴과 무관하게 공통이고, B층은 서버 세션을 갖는 세 패턴에만
|
||||||
|
존재한다. 그래서 이 축을 AP1~AP4 어디에도 넣지 않고 별도로 둔다.
|
||||||
|
|
||||||
|
## 기존 브랜치 레지스트리에 넣지 않는 이유
|
||||||
|
|
||||||
|
`docs/keycloak-branch-manifest.tsv`와 `scripts/audit-keycloak-branches.sh`는
|
||||||
|
**정확히 39개** 브랜치를 강제하고, 각 브랜치가 외부 노트 파일과 1:1로
|
||||||
|
대응하는지 검사한다.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
if [ "$expected_count" -ne 39 ]; then
|
||||||
|
echo "manifest must contain exactly 39 Keycloak branches" >&2
|
||||||
|
```
|
||||||
|
|
||||||
|
이 축의 브랜치를 manifest에 추가하면 그 감사가 깨진다. 원래 39개는
|
||||||
|
**완결된 인벤토리**이므로 건드리지 않고, 이 축은 이 문서로 추적한다.
|
||||||
|
|
||||||
|
## 진행 상황
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 환경 구축
|
||||||
|
✅ 2홉 프록시 헤더 계약
|
||||||
|
──────────────────────────────────────────────────────────
|
||||||
|
A층 — Keycloak 자체 (공개 질문에 없는 영역)
|
||||||
|
1. 멀티노드 클러스터 형성
|
||||||
|
2. persistent vs volatile 세션
|
||||||
|
|
||||||
|
B층 — 애플리케이션 세션 (공개 열린 질문 대응)
|
||||||
|
3. BFF 저장소 결정 → Q3
|
||||||
|
4. 다중 인스턴스 운영 → Q1
|
||||||
|
5. Refresh Token 경쟁 → Q2 ★ 3 이후여야 재현됨
|
||||||
|
6. Edge 인가 범위 → Q4
|
||||||
|
|
||||||
|
공통 — 운영 역량
|
||||||
|
7. 장애 주입과 복구
|
||||||
|
8. 백업과 복구 리허설
|
||||||
|
9. 버전 업그레이드
|
||||||
|
10. 관측성
|
||||||
|
11. 비밀 관리
|
||||||
|
12. 인증서 갱신 실측
|
||||||
|
```
|
||||||
|
|
||||||
|
**순서 근거는 [`open-questions-coverage.md`](open-questions-coverage.md)에 있다.**
|
||||||
|
특히 5번(refresh 경쟁)은 3번(저장소 공유) 이후여야 **재현 자체가 성립한다.**
|
||||||
|
|
||||||
|
### ✅ 완료 — 환경 구축
|
||||||
|
|
||||||
|
2노드 k3s 실험대. 상세는 [`deploy/lab/README.md`](../deploy/lab/README.md),
|
||||||
|
개념은 [`session-lab-concepts.md`](session-lab-concepts.md),
|
||||||
|
운영 도구는 [`session-lab-operations.md`](session-lab-operations.md).
|
||||||
|
|
||||||
|
```
|
||||||
|
브라우저 ─https─▶ 호스트 nginx(TLS 종료) ─▶ Traefik ─▶ Pod
|
||||||
|
kc-lab-1 / kc-lab-2
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 Docker Compose가 아닌가** — 한 커널에서 "노드 죽이기"는 프로세스
|
||||||
|
죽이기일 뿐이다. 노드 간 방화벽·비대칭 파티션·진짜 노드 상실은 **독립된
|
||||||
|
커널 두 개**가 있어야 성립한다.
|
||||||
|
|
||||||
|
### ✅ 완료 — 2홉 프록시 헤더 계약
|
||||||
|
|
||||||
|
[`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) ·
|
||||||
|
증거 [`evidence/two-hop-proxy-headers/`](evidence/two-hop-proxy-headers/)
|
||||||
|
|
||||||
|
**확인한 것** — `docs/reverse-proxy-headers.md`의 1홉 계약이 2홉에서
|
||||||
|
성립하지 않는다. 원인이 둘이었고 스위치가 셋이었다.
|
||||||
|
|
||||||
|
| 스위치 | 하는 일 |
|
||||||
|
|---|---|
|
||||||
|
| nginx `proxy_set_header` | 헤더를 **만든다** |
|
||||||
|
| Traefik `forwardedHeaders.trustedIPs` | 받은 헤더를 **전달할지 버릴지** |
|
||||||
|
| 앱 `forward-headers-strategy` / `KC_PROXY_HEADERS` | 도착한 헤더를 **읽을지** |
|
||||||
|
|
||||||
|
**발견한 취약점 — 헤더 신뢰를 켠 순간 위조가 통했다**
|
||||||
|
|
||||||
|
세 스위치를 다 켜고 나니 새 구멍이 생겼다. Traefik을 거치지 않고 앱에 직접
|
||||||
|
요청하면서 헤더를 붙이자 **그대로 통과했다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
serverName evil.example.com ← 위조 성공
|
||||||
|
remoteAddr 1.2.3.4 ← 위조 성공
|
||||||
|
requestUrl https://evil.example.com/api/echo
|
||||||
|
```
|
||||||
|
|
||||||
|
두 신뢰 설정이 모두 **"대역"을 믿기 때문**이다. IP로는 Traefik을 특정할 수
|
||||||
|
없다 — 파드 IP가 재시작마다 바뀐다(측정 중 `10.42.0.8` → `10.42.1.12`로,
|
||||||
|
노드까지 옮겨갔다). **NetworkPolicy는 IP가 아니라 라벨로 지정**하므로 이를
|
||||||
|
닫는다.
|
||||||
|
|
||||||
|
**"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다.**
|
||||||
|
이 교훈이 6번(Edge 인가)에서 결정적이 된다 — `X-Auth-Request-*`가 위조되면
|
||||||
|
그것은 쿠키 속성이 아니라 **신원 위조**다.
|
||||||
|
|
||||||
|
**이 결과가 뒤에 미치는 영향** — Keycloak을 올릴 때
|
||||||
|
`KC_PROXY_HEADERS=xforwarded`와 `KC_HOSTNAME=https://auth.hyeonworks.com`을
|
||||||
|
근거를 갖고 넣을 수 있고, 로그인이 깨져도 프록시 원인은 배제하고 볼 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A층 — Keycloak 자체
|
||||||
|
|
||||||
|
**공개 열린 질문 네 개는 전부 애플리케이션 계층(B층)이다.** Keycloak 자체의
|
||||||
|
클러스터링과 세션 저장을 다루는 질문은 아직 등록되어 있지 않다.
|
||||||
|
|
||||||
|
**그러나 이 두 항목이 이 실험대의 존재 이유에 더 가깝다.** B층 실험은 replica
|
||||||
|
2개면 되므로 Docker Compose로도 상당 부분 가능하지만, A층은 **독립된 커널
|
||||||
|
두 개**를 요구한다. 실험 후 결과를 **새 열린 질문으로 등록할 후보**다.
|
||||||
|
|
||||||
|
### 1. Keycloak 멀티노드 클러스터 형성
|
||||||
|
|
||||||
|
브랜치: `feature/keycloak-multinode-cluster-jdbc-ping`
|
||||||
|
|
||||||
|
**확인할 것**
|
||||||
|
|
||||||
|
- Keycloak 2개 파드가 **서로를 발견해 하나의 클러스터를 이루는가**
|
||||||
|
- Keycloak 26의 기본 디스커버리는 `jdbc-ping` — PostgreSQL의 `JGROUPS_PING`
|
||||||
|
테이블로 서로를 찾는다. 멀티캐스트가 필요 없다
|
||||||
|
- **TCP 7800이 막히면 무엇이 먼저 보이는가** — 디스커버리는 DB로 하지만 실제
|
||||||
|
클러스터 통신은 7800이다. 막으면 "DB에는 서로 등록되는데 클러스터가 안 붙는"
|
||||||
|
증상이 나온다. **단일 노드에서는 재현 불가능한 고장**이며, 이 실험대를
|
||||||
|
2노드로 만든 이유 중 하나다
|
||||||
|
|
||||||
|
**주의** — Traefik이 replica 1이므로 그 파드가 있는 노드를 죽이면 진입점
|
||||||
|
자체가 사라질 수 있다. **훈련 3([`session-lab-operations.md`](session-lab-operations.md))에서
|
||||||
|
먼저 확인**하고 replica 조정 여부를 정한다.
|
||||||
|
|
||||||
|
**설정 근거** — 헤더 계약에서 확정한 값을 그대로 쓴다.
|
||||||
|
|
||||||
|
```
|
||||||
|
KC_HOSTNAME=https://auth.hyeonworks.com
|
||||||
|
KC_HOSTNAME_STRICT=true
|
||||||
|
KC_PROXY_HEADERS=xforwarded
|
||||||
|
KC_HTTP_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. persistent vs volatile 세션
|
||||||
|
|
||||||
|
브랜치: `feature/keycloak-persistent-vs-volatile-sessions`
|
||||||
|
|
||||||
|
**확인할 것**
|
||||||
|
|
||||||
|
- Keycloak 26 기본값은 `persistent-user-sessions` — 세션이 **DB가 진실의
|
||||||
|
원천**이다
|
||||||
|
- `--features-disabled=persistent-user-sessions`로 volatile 전환 시 비교
|
||||||
|
- **PostgreSQL을 죽이면** 각각 어떻게 되는가
|
||||||
|
- **노드 하나를 죽이면** 세션이 살아남는가
|
||||||
|
- **롤링 배포 시 로그아웃되는가** ← 운영에서 가장 자주 겪는 시나리오이며,
|
||||||
|
사실상 persistent를 켜는 진짜 이유다
|
||||||
|
|
||||||
|
이것이 "세션을 DB에 둘 때 vs 안 둘 때"의 Keycloak 버전이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B층 — 애플리케이션 세션
|
||||||
|
|
||||||
|
공개 열린 질문 네 개에 대응한다. 각 질문이 요구하는 검증 단계는
|
||||||
|
[`open-questions-coverage.md`](open-questions-coverage.md)에 항목별로 있다.
|
||||||
|
|
||||||
|
**공통 선행 조건** — BFF 2 replica. 구현은 `develop-keycloak-pattern3`에
|
||||||
|
이미 있으므로 가져온다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop-keycloak-pattern3 -- bff/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. BFF 저장소 결정 → [Q3](https://hyeonworks.com/questions/bff-session-authorized-client-store)
|
||||||
|
|
||||||
|
브랜치: `feature/keycloak-redis-app-session-store`
|
||||||
|
|
||||||
|
**질문의 핵심은 "Redis 도입"이 아니라 "Redis와 JDBC 중 무엇이 맞는가"다.**
|
||||||
|
PostgreSQL이 이미 있으므로 같은 조건에서 비교할 수 있다.
|
||||||
|
|
||||||
|
**Session과 Authorized Client는 조회 키가 다르다.**
|
||||||
|
|
||||||
|
| 상태 | 조회 키 |
|
||||||
|
|---|---|
|
||||||
|
| Application Session | **session ID** |
|
||||||
|
| OAuth2AuthorizedClient | **registration 이름 + principal name** |
|
||||||
|
|
||||||
|
`session ID`가 없으므로 **같은 사용자의 여러 브라우저가 동일한 authorized
|
||||||
|
client를 공유**한다. 따라서 **두 저장소를 각각 설계해야 한다.**
|
||||||
|
|
||||||
|
검증 5단계 — 로그인 유지·재시작 복구 / **refresh token 평문 여부** /
|
||||||
|
**session TTL ≠ token 만료** / logout 후 잔여 항목 / 저장소 끊김 시 오류.
|
||||||
|
|
||||||
|
### 4. 다중 인스턴스 운영 → [Q1](https://hyeonworks.com/questions/server-session-pattern-multi-instance)
|
||||||
|
|
||||||
|
브랜치: `feature/keycloak-multi-instance-session-operation` **(생성 필요)**
|
||||||
|
|
||||||
|
검증 5단계 — 다른 인스턴스로 요청 시 200 유지 / 재시작 후 session cookie /
|
||||||
|
**authorized client 덮어쓰기** / logout 전파 / 만료 어긋남.
|
||||||
|
|
||||||
|
**3번(덮어쓰기)이 특히 중요하다.** "Redis만 붙이면 해결"이라는 착각을 깨는
|
||||||
|
항목이다.
|
||||||
|
|
||||||
|
**질문의 제약 하나는 이미 해결법을 안다** — "Resource Server의 8081이 host에도
|
||||||
|
열려 있어 BFF만 거치도록 강제되지 않았다"는 2홉 실험의 **프록시 우회 경로와
|
||||||
|
같은 문제**이며, NetworkPolicy 패턴을 그대로 재사용한다.
|
||||||
|
|
||||||
|
호스트 nginx의 `ip_hash` 주석을 켜고 끄면 **스티키 유무 비교**까지 같은
|
||||||
|
구성에서 된다.
|
||||||
|
|
||||||
|
### 5. Refresh Token 경쟁 → [Q2](https://hyeonworks.com/questions/refresh-rotation-replica-contention)
|
||||||
|
|
||||||
|
브랜치: `feature/keycloak-refresh-token-concurrency`
|
||||||
|
|
||||||
|
**★ 3번 이후여야 한다.** 저장소가 process-local이면 두 replica가 같은 refresh
|
||||||
|
token 항목을 보지 않아 **경쟁 자체가 재현되지 않는다.**
|
||||||
|
|
||||||
|
검증 5단계 — 만료 직후 동시 요청 / 이긴 쪽·지는 쪽 응답 / **지는 쪽이 새
|
||||||
|
token으로 재시도해 성공하는가** / **지는 쪽 사용자 화면** /
|
||||||
|
**lock 유무를 같은 입력으로 비교**.
|
||||||
|
|
||||||
|
**마지막이 결론 기준이다** — *실패가 사용자에게 노출되면 lock, 노출되지 않으면
|
||||||
|
재시도.*
|
||||||
|
|
||||||
|
**제약** — rotation + 재사용 0회는 전제로 고정한다. 그리고 이미 발급된 access
|
||||||
|
token은 만료 전까지 통하므로 **재현은 access token 만료 직후에 맞춰 실행**한다.
|
||||||
|
|
||||||
|
### 6. Edge 인가 범위 → [Q4](https://hyeonworks.com/questions/edge-authorization-scope)
|
||||||
|
|
||||||
|
브랜치: `feature/keycloak-edge-authorization-scope` **(생성 필요)**
|
||||||
|
|
||||||
|
**확인할 것** — role을 헤더에 담고 **다중 값 구분자·escaping** / **헤더 크기
|
||||||
|
상한** 초과 시 자르는가 거부하는가 / role 변경이 **몇 번째 요청부터 반영**되는가
|
||||||
|
/ upstream이 헤더 존재만 보는가 값과 service identity까지 보는가.
|
||||||
|
|
||||||
|
2홉 실험에서 확인한 **nginx가 동명 헤더를 merge하지 않고 덮어쓴다**는 동작이
|
||||||
|
`X-Auth-Request-*`에도 적용되는지 같은 방법으로 검증한다.
|
||||||
|
|
||||||
|
질문의 제약 — internal token 검사가 controller 한 곳에만 있어 **공통 경계로
|
||||||
|
옮겨야** 한다. 코드 변경이므로 `backend/`에서 진행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 공통 — 운영 역량
|
||||||
|
|
||||||
|
**여기부터는 "구성했다"가 아니라 "운영해봤다"에 필요한 항목이다.**
|
||||||
|
백업과 업그레이드는 빠지면 티가 난다.
|
||||||
|
|
||||||
|
### 7. 장애 주입과 복구
|
||||||
|
|
||||||
|
브랜치: `feature/keycloak-failure-injection-recovery`
|
||||||
|
|
||||||
|
| 주입 | 방법 |
|
||||||
|
|---|---|
|
||||||
|
| 노드 상실 | `virsh destroy` — 프로세스 kill 이 아닌 진짜 상실 |
|
||||||
|
| 비대칭 파티션 | 한쪽 게스트의 인바운드만 nftables 로 차단 |
|
||||||
|
| JGroups 7800 차단 | NetworkPolicy — 운영에서 쓸 방식 그대로 |
|
||||||
|
| DB 상실 | PostgreSQL 파드 정지 |
|
||||||
|
| Redis 상실 | Redis 파드 정지 |
|
||||||
|
| 지연 주입 | 게스트 안에서 `tc netem` — 커널이 분리돼 있어 안전 |
|
||||||
|
|
||||||
|
**복구 절차**를 각각 기록한다. 실제 장애의 대부분은 완전 사망이 아니라
|
||||||
|
**부분 장애**(느려짐, 일부 실패)이므로 netem 지연을 기본값으로 둔다.
|
||||||
|
|
||||||
|
### 8. 백업과 복구 리허설
|
||||||
|
|
||||||
|
**"백업이 있다"와 "복구해봤다"는 완전히 다르다.**
|
||||||
|
|
||||||
|
- `pg_dump`로 realm·세션·JGROUPS_PING 포함 전체 덤프
|
||||||
|
- **일부러 파괴** — PVC 삭제 또는 DB 드롭
|
||||||
|
- 덤프에서 복구하고 **로그인이 되는지, 기존 세션이 살아나는지** 확인
|
||||||
|
- 복구에 걸린 시간을 기록한다 (RTO)
|
||||||
|
- 백업 시점 이후 데이터가 무엇을 잃는지 확인한다 (RPO)
|
||||||
|
|
||||||
|
Redis 쪽은 `appendonly` 유무에 따른 차이를 함께 본다.
|
||||||
|
|
||||||
|
### 9. Keycloak 버전 업그레이드
|
||||||
|
|
||||||
|
**운영에서 가장 무서운 작업 중 하나다.** realm 마이그레이션과 **DB 스키마
|
||||||
|
변경이 자동으로 실행**되며, 실패하면 되돌리기 어렵다.
|
||||||
|
|
||||||
|
- 현재 26.7.0 → 다음 마이너로 이미지 태그 변경
|
||||||
|
- **업그레이드 전 백업**을 먼저 확보한다 (8번의 전제)
|
||||||
|
- 롤링 중 **기존 세션이 유지되는가** (2번의 persistent 설정과 연결된다)
|
||||||
|
- 스키마 변경 로그를 확인한다
|
||||||
|
- **롤백이 되는가** — 스키마가 바뀐 뒤에는 이전 버전이 뜨지 않을 수 있다
|
||||||
|
|
||||||
|
### 10. 관측성
|
||||||
|
|
||||||
|
지금은 `kubectl top`뿐이라 **장애 중 무슨 일이 있었는지 사후 추적이 안 된다.**
|
||||||
|
|
||||||
|
- `KC_METRICS_ENABLED=true` + `KC_HEALTH_ENABLED=true`
|
||||||
|
- Prometheus + Grafana 배포
|
||||||
|
- 볼 지표 — Infinispan 캐시 항목 수·축출, DB 커넥션 풀 사용률,
|
||||||
|
로그인 성공/실패율, **클러스터 멤버 수**
|
||||||
|
- 장애 주입(7번) 중에 **어떤 지표가 먼저 움직이는지** 기록한다
|
||||||
|
|
||||||
|
### 11. 비밀 관리
|
||||||
|
|
||||||
|
지금 방식대로면 client secret과 DB 비밀번호가 **매니페스트에 평문**으로 들어간다.
|
||||||
|
|
||||||
|
- k8s `Secret`으로 분리
|
||||||
|
- 저장소에는 `.example`만 커밋 (기존 `.env.example` 관례 그대로)
|
||||||
|
- 평문 Secret은 etcd에 base64로만 저장되므로 실제로는 감춰지지 않는다는 점을
|
||||||
|
확인한다 — `kubectl get secret -o yaml`로 직접 본다
|
||||||
|
- SealedSecret 또는 외부 저장소가 필요한 지점을 판단한다
|
||||||
|
|
||||||
|
### 12. 인증서 갱신 실측
|
||||||
|
|
||||||
|
90일을 기다리지 않고 강제로 겪는다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo certbot renew --force-renewal
|
||||||
|
```
|
||||||
|
|
||||||
|
- nginx reload 타이밍에 **무중단인가**
|
||||||
|
- 갱신 중 진행 중이던 요청은 어떻게 되는가
|
||||||
|
- `certbot-renew.timer`가 실제로 동작하는가 (`--dry-run`이 아니라 실제 갱신)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 스코프에서 제외한 것
|
||||||
|
|
||||||
|
### 이 실험대가 재현하지 못하는 것
|
||||||
|
|
||||||
|
| 항목 | 이유 |
|
||||||
|
|---|---|
|
||||||
|
| 성능·처리량 측정 | 단일 물리 머신의 숫자는 운영에 대해 아무것도 말해주지 않는다 |
|
||||||
|
| 실제 AZ 간 지연 | 한 박스 안이라 재현 불가. `tc netem` 으로 근사만 |
|
||||||
|
| ALB 고유 동작 | 자체 스티키 쿠키·60초 idle timeout 은 실물 ALB 가 있어야 한다 |
|
||||||
|
| PostgreSQL HA | 스코프 폭발. "죽으면 어떻게 되나"까지가 현실적 선 |
|
||||||
|
| 멀티 사이트 / cross-site Infinispan | 로컬에서 "사이트"가 가짜라 배우는 것이 적다 |
|
||||||
|
|
||||||
|
**이 실험대가 검증하는 것은 계약(정합성)이지 성능이 아니다.**
|
||||||
|
|
||||||
|
### 구조적으로 줄 수 없는 경험
|
||||||
|
|
||||||
|
실험 설계로는 만들 수 없는 것들. **무엇을 겪지 않았는지 아는 것도 기록의
|
||||||
|
일부다.**
|
||||||
|
|
||||||
|
| 없는 것 | 왜 |
|
||||||
|
|---|---|
|
||||||
|
| **규모** | 수천 세션에서의 커넥션 풀 고갈, Infinispan 캐시 축출 |
|
||||||
|
| **시간** | 몇 달 돌면서 드러나는 디스크 참, 로그 누적, 메모리 누수 |
|
||||||
|
| **다른 사람** | 동시에 만지는 사람, 온콜, 인수인계, "내가 안 바꿨는데 바뀌어 있음" |
|
||||||
|
| **실제 사용자** | 봇, 오래된 클라이언트, 예측 못 한 사용 패턴 |
|
||||||
|
| **클라우드 관리형 컴포넌트** | ALB·RDS·ElastiCache의 고유 동작과 **그것들의 장애 모드** |
|
||||||
|
| **비용** | 운영 판단의 큰 축인데 실험대엔 없다 |
|
||||||
|
| **보안 사고 대응** | 실제 침해, 토큰 유출 후 회수, 감사 로그 추적 |
|
||||||
|
|
||||||
|
**"시간"은 부분적으로 살 수 있다.** 실험이 끝나도 클러스터를 지우지 않고
|
||||||
|
몇 주 켜둔 채로 두면 인증서가 갱신되고, 로그가 쌓이고, 예상 못 한 것이
|
||||||
|
죽는다. 실험 설계로는 만들 수 없는 종류의 관찰이다.
|
||||||
|
|
||||||
|
### 따라서 말할 수 있는 것과 없는 것
|
||||||
|
|
||||||
|
**말할 수 있다** — Keycloak 멀티노드에서 세션과 토큰이 어디에 저장되고 각
|
||||||
|
저장소가 죽으면 무엇이 어떻게 실패하는지 재현하고 복구했다. 프록시 체인의
|
||||||
|
헤더 계약을 측정으로 확정했고, 신뢰 경계의 구멍을 실증하고 막았다.
|
||||||
|
|
||||||
|
**말하면 안 된다** — "운영해봤다", "대규모 트래픽을 다뤄봤다"
|
||||||
|
|
||||||
|
**그 경계를 정확히 구분해 말하는 것 자체가 이 기록의 목적이다.**
|
||||||
|
|
||||||
|
## 관련 문서
|
||||||
|
|
||||||
|
| 문서 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| [`open-questions-coverage.md`](open-questions-coverage.md) | 공개 열린 질문 4개와의 대조, 순서 근거 |
|
||||||
|
| [`session-lab-concepts.md`](session-lab-concepts.md) | 등장 개념 전체 (가상화·네트워크·k3s·TLS·패키지) |
|
||||||
|
| [`session-lab-operations.md`](session-lab-operations.md) | 관측 도구 · 자주 쓰는 명령 · 훈련 · 자원 예산 |
|
||||||
|
| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | 첫 실험의 측정·진단·수정 |
|
||||||
|
| [`deploy/lab/README.md`](../deploy/lab/README.md) | 실험대 구축·복구 절차 |
|
||||||
|
| [`four-pattern-tradeoff-matrix.md`](four-pattern-tradeoff-matrix.md) | AP1~AP4 비교. "server session" 행이 B층 대상 |
|
||||||
|
| [`refresh-token-rotation.md`](refresh-token-rotation.md) | 회전 계약 (1홉·단일 노드 가정) |
|
||||||
|
| [`reverse-proxy-headers.md`](reverse-proxy-headers.md) | 1홉 헤더 계약 원본 |
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Single EC2 with Google federation
|
|
||||||
|
|
||||||
local-identity single-EC2 topology에 다음 네 요소가 추가된다.
|
|
||||||
|
|
||||||
1. 고정 public HTTPS hostname
|
|
||||||
2. trusted TLS/reverse proxy와 Keycloak public hostname
|
|
||||||
3. Google OAuth client의 exact broker callback
|
|
||||||
4. Keycloak Google IdP secret과 safe First Broker Login policy
|
|
||||||
|
|
||||||
학습 profile은 Cloudflare named tunnel을 사용할 수 있어 EC2 inbound에
|
|
||||||
Keycloak 8080을 열지 않는다. containerized cloudflared의 origin은
|
|
||||||
`127.0.0.1`이 아니라 Compose service DNS의 reverse proxy다.
|
|
||||||
|
|
||||||
실제 Google secret은 `.env`/secret store에만 두며 realm export나 Git에
|
|
||||||
포함하지 않는다. credential이 없는 CI는 mock OIDC realm으로 broker 계약을
|
|
||||||
검증하고, 실제 Google callback은 사용자 소유 credential/domain 환경에서
|
|
||||||
별도 실행한다.
|
|
||||||
|
|
||||||
`verify-single-ec2-google-profile.sh`는 tunnel, proxy headers, redirect URI,
|
|
||||||
broker realm 계약을 묶어 검증한다.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Single EC2, AP1, no Google
|
|
||||||
|
|
||||||
한 VM의 Docker Compose에 PostgreSQL, Keycloak, Spring API, nginx SPA를 둔다.
|
|
||||||
외부 진입점만 nginx/TLS proxy로 제한하고 Keycloak 8080, API 8081, PostgreSQL은
|
|
||||||
loopback 또는 Docker private network에 둔다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
Internet :443 -> TLS proxy/SPA
|
|
||||||
|-> Spring API:8081
|
|
||||||
Browser ----------->|-> Keycloak:8080 (public auth path만 proxy)
|
|
||||||
Keycloak ----------> PostgreSQL:5432
|
|
||||||
```
|
|
||||||
|
|
||||||
이 profile은 Google credential과 tunnel이 필요 없다. EC2 보안 그룹은 22
|
|
||||||
(관리 정책에 따른 제한), 80/443만 열고 container 내부 포트를 공개하지 않는다.
|
|
||||||
backup 대상은 PostgreSQL 데이터와 realm/config/secret이며 Keycloak 개발
|
|
||||||
모드 `start-dev`는 production에서 사용하지 않는다.
|
|
||||||
|
|
||||||
`verify-single-ec2-no-google.sh`는 Compose rendering, loopback binding,
|
|
||||||
AP1 local-identity 계약을 확인한다.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Keycloak realm roles to Spring authorization
|
|
||||||
|
|
||||||
`realm_access.roles`의 각 문자열을 `ROLE_` prefix가 붙은 Spring authority로
|
|
||||||
변환한다. `/api/admin`은 `hasRole("admin-role")` 계약이므로 최종 authority는
|
|
||||||
`ROLE_admin-role`이다. `hasRole("ROLE_admin-role")`로 쓰면 prefix가 중복된다.
|
|
||||||
|
|
||||||
검증은 세 층으로 구성된다.
|
|
||||||
|
|
||||||
- converter 단위 테스트: role claim과 claim 부재
|
|
||||||
- MockMvc: regular 403, admin 200
|
|
||||||
- 실제 Authorization Code + PKCE login: Keycloak token의 realm role을
|
|
||||||
Spring Resource Server가 변환해 regular 403/admin 200을 반환
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./scripts/verify-spring-role-mapping.sh
|
|
||||||
```
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Three-leg trust chain
|
|
||||||
|
|
||||||
| Hop | 발급자/행위자 | 검증자 | 핵심 검증 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | Google/mock IdP | Keycloak broker | signature, upstream issuer, client audience, expiry, nonce |
|
|
||||||
| 2 | Keycloak | SPA | state, PKCE code binding, Keycloak issuer, token response |
|
|
||||||
| 3 | Keycloak access token | Spring Resource Server | Keycloak signature/JWKS, issuer, expiry, API audience, roles |
|
|
||||||
|
|
||||||
Keycloak은 upstream ID token을 그대로 SPA에 전달하지 않는다. upstream
|
|
||||||
identity를 검증·연결한 뒤 자신의 realm key로 새 token을 발급한다. 그 결과
|
|
||||||
backend의 trust store와 issuer 설정은 Google 로그인 여부와 무관하게 하나다.
|
|
||||||
|
|
||||||
`verify-three-leg-trust-chain.sh`는 두 realm의 discovery issuer가 서로 다르고,
|
|
||||||
Spring 코드가 main Keycloak issuer만 신뢰하는지 확인한다. 실제 hop 전체는
|
|
||||||
`verify-federation-spa-zero-change.sh`로 검증한다.
|
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
# 2홉 프록시 헤더 계약 — 측정·진단·적용
|
||||||
|
|
||||||
|
`docs/reverse-proxy-headers.md`의 계약은 **nginx 한 홉**을 가정하고 쓰였다.
|
||||||
|
실험대와 운영은 모두 **`nginx → Traefik` 두 홉**이므로 그 계약이 그대로
|
||||||
|
성립하는지 측정했다. **성립하지 않는다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 왜 이것부터 재는가
|
||||||
|
|
||||||
|
Keycloak과 그 앞의 애플리케이션이 만드는 값 대부분이 **"원래 요청이
|
||||||
|
무엇이었나"** 에 의존한다.
|
||||||
|
|
||||||
|
| 만들어지는 값 | 의존하는 정보 |
|
||||||
|
|---|---|
|
||||||
|
| 토큰의 `iss` 클레임 | 외부 스킴 + 호스트 |
|
||||||
|
| OAuth2 `redirect_uri` | 외부 스킴 + 호스트 + 포트 |
|
||||||
|
| 세션 쿠키의 `Secure` 속성 | 외부 스킴 |
|
||||||
|
| brute-force 탐지·감사 로그 | 클라이언트 IP |
|
||||||
|
|
||||||
|
그런데 **TLS는 맨 앞 nginx가 끊는다.** 그 뒤로는 평문 HTTP가 흐르므로,
|
||||||
|
뒤쪽 구성요소는 원래 요청이 HTTPS였다는 사실을 **오직 `X-Forwarded-*`
|
||||||
|
헤더로만** 알 수 있다. 이 헤더가 중간에서 사라지거나 바뀌면 위 값이 전부
|
||||||
|
틀어진다.
|
||||||
|
|
||||||
|
Keycloak을 올린 뒤에 로그인이 깨지면 **세션 문제인지 프록시 문제인지 구분할
|
||||||
|
수 없다.** 그래서 Keycloak 없이 이 계약만 먼저 떼어내 측정했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 구조 — 누가 어디에 사는가
|
||||||
|
|
||||||
|
### 물리적 배치
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ test-server (호스트 OS · Arch · 베어메탈) ──────────────────┐
|
||||||
|
│ │
|
||||||
|
│ [스위치 1] nginx ← 호스트 OS 의 프로세스 │
|
||||||
|
│ /etc/nginx/sites-available/keycloak-lab │
|
||||||
|
│ = deploy/lab/host/nginx-keycloak-lab.conf │
|
||||||
|
│ │
|
||||||
|
│ ┌─ kc-lab-1 (VM) ─────────────┐ ┌─ kc-lab-2 (VM) ────────┐ │
|
||||||
|
│ │ svclb 파드 :80 │ │ svclb 파드 :80 │ │
|
||||||
|
│ │ ↓ │ │ └────────────────┼──┼─┐
|
||||||
|
│ │ [스위치 2] Traefik 파드 ◀──┼─┼────────────────────────┼──┼─┘
|
||||||
|
│ │ 클러스터 전체에 하나뿐 │ │ │ │
|
||||||
|
│ │ ↓ │ │ │ │
|
||||||
|
│ │ [스위치 3] 앱 파드 │ │ [스위치 3] 앱 파드 │ │
|
||||||
|
│ └─────────────────────────────┘ └────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| # | 무엇 | 사는 곳 | 설정 파일 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | nginx | **호스트 OS의 프로세스** | `deploy/lab/host/nginx-keycloak-lab.conf` |
|
||||||
|
| 2 | Traefik | **클러스터 안 파드 1개** | `HelmChartConfig` (kube-system) |
|
||||||
|
| 3 | 앱 | **클러스터 안 파드 N개** | 각 앱의 매니페스트 `env` |
|
||||||
|
|
||||||
|
### Traefik은 노드마다 있지 않다
|
||||||
|
|
||||||
|
k3s 기본값이 **replica 1**이다. 대신 **svclb**(klipper-lb) DaemonSet이 각
|
||||||
|
노드의 80/443 hostPort를 열어두고, 받은 트래픽을 **그 하나의 Traefik 파드로**
|
||||||
|
전달한다.
|
||||||
|
|
||||||
|
측정에서 8회 요청의 `x-forwarded-server`가 모두 같은 파드 이름이었던 것이
|
||||||
|
그 증거다. 이 사실은 **노드 상실 실험에서 변수**가 된다 — Traefik이 있는
|
||||||
|
노드를 죽이면 다른 노드의 svclb도 보낼 곳을 잃는다.
|
||||||
|
|
||||||
|
### "홉"은 노드 수가 아니라 프록시 계층 수다
|
||||||
|
|
||||||
|
```
|
||||||
|
홉 1 홉 2 목적지
|
||||||
|
호스트 nginx ──▶ Traefik ──▶ 앱 파드
|
||||||
|
(HTTP 를 봄) (HTTP 를 봄) (HTTP 를 봄)
|
||||||
|
```
|
||||||
|
|
||||||
|
**svclb는 홉으로 세지 않는다.** iptables 수준의 전달이라 HTTP를 아예 보지
|
||||||
|
않기 때문이다. 다만 SNAT를 하므로 **IP는 바꾼다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 측정 장치
|
||||||
|
|
||||||
|
`backend`의 `/api/echo`가 **자신에게 실제로 도달한 것**을 그대로 돌려준다.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://app1.hyeonworks.com/api/echo
|
||||||
|
→ { headers, remoteAddr, localAddr, scheme, secure, serverName, serverPort, requestUrl }
|
||||||
|
```
|
||||||
|
|
||||||
|
`scheme` · `secure` · `requestUrl`은 Keycloak이 `iss`와 redirect URL을 만들 때
|
||||||
|
쓰는 것과 **같은 종류의 값**이다. `localAddr`은 파드 IP이므로 어느 노드가
|
||||||
|
응답했는지 알려준다.
|
||||||
|
|
||||||
|
배포는 `deploy/lab/k8s/echo.yaml`, 실행은
|
||||||
|
`deploy/lab/scripts/measure-proxy-headers.sh`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 요청 흐름 — 홉마다 헤더가 어떻게 변하는가
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 1. 브라우저 ────────────────────────────────────────────────┐
|
||||||
|
│ GET /api/echo │
|
||||||
|
│ Host: app1.hyeonworks.com │
|
||||||
|
│ 전 구간 TLS 로 암호화 │
|
||||||
|
└──────────────────────────┬───────────────────────────────────┘
|
||||||
|
│ tailnet → 100.83.212.4:443
|
||||||
|
┌─ 2. 호스트 nginx ────────▼───────────────────────────────────┐
|
||||||
|
│ ★ TLS 종료 — 이 지점부터 평문 HTTP │
|
||||||
|
│ │
|
||||||
|
│ 원래 요청 정보를 헤더로 바꿔 붙인다: │
|
||||||
|
│ Host app1.hyeonworks.com │
|
||||||
|
│ X-Forwarded-Host app1.hyeonworks.com │
|
||||||
|
│ X-Forwarded-Proto https ← 원래 스킴 │
|
||||||
|
│ X-Forwarded-Port 443 │
|
||||||
|
│ X-Forwarded-For <클라이언트 IP> ($remote_addr 로 덮어씀) │
|
||||||
|
│ X-Real-IP <클라이언트 IP> │
|
||||||
|
└──────────────────────────┬───────────────────────────────────┘
|
||||||
|
│ upstream 라운드로빈
|
||||||
|
│ 192.168.122.11:80 또는 .12:80
|
||||||
|
┌─ 3. svclb (klipper-lb) ──▼───────────────────────────────────┐
|
||||||
|
│ 노드의 hostPort 80 에서 받아 iptables 로 전달 │
|
||||||
|
│ externalTrafficPolicy: Cluster → SNAT 발생 │
|
||||||
|
│ │
|
||||||
|
│ ★ 출발지 IP 가 노드의 flannel 게이트웨이로 바뀐다 │
|
||||||
|
│ → 클라이언트 IP 1차 소실 │
|
||||||
|
└──────────────────────────┬───────────────────────────────────┘
|
||||||
|
│ 10.42.0.8 (Traefik 파드)
|
||||||
|
┌─ 4. Traefik ─────────────▼───────────────────────────────────┐
|
||||||
|
│ Ingress 규칙 매칭: host app1.hyeonworks.com, path /api │
|
||||||
|
│ │
|
||||||
|
│ ★ forwardedHeaders.trustedIPs 미설정 │
|
||||||
|
│ → 들어온 X-Forwarded-* 를 신뢰하지 않고 │
|
||||||
|
│ 자기가 받은 연결을 기준으로 다시 쓴다 │
|
||||||
|
│ │
|
||||||
|
│ X-Forwarded-Proto https → http 자기가 받은 게 평문이므로 │
|
||||||
|
│ X-Forwarded-Port 443 → 80 │
|
||||||
|
│ X-Forwarded-For 실IP → 10.42.1.0 ← 2차 소실 │
|
||||||
|
│ X-Real-IP 실IP → 10.42.1.0 │
|
||||||
|
│ X-Forwarded-Host 유지 │
|
||||||
|
│ X-Forwarded-Server traefik-... 자기 이름 추가 │
|
||||||
|
└──────────────────────────┬───────────────────────────────────┘
|
||||||
|
│ Service → 파드
|
||||||
|
┌─ 5. 애플리케이션 ────────▼───────────────────────────────────┐
|
||||||
|
│ Spring: forward-headers-strategy = none │
|
||||||
|
│ → forwarded 헤더를 해석하지 않고 TCP 연결 그대로 보고 │
|
||||||
|
│ │
|
||||||
|
│ scheme http │
|
||||||
|
│ secure false │
|
||||||
|
│ requestUrl http://app1.hyeonworks.com/api/echo │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 측정 결과
|
||||||
|
|
||||||
|
| 헤더 | nginx 가 보낸 값 | 앱에 도달한 값 | |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `X-Forwarded-Proto` | `https` | **`http`** | 덮어써짐 |
|
||||||
|
| `X-Forwarded-Port` | `443` | **`80`** | 덮어써짐 |
|
||||||
|
| `X-Forwarded-For` | 클라이언트 IP | **`10.42.1.0`** | 소실 |
|
||||||
|
| `X-Real-IP` | 클라이언트 IP | **`10.42.1.0`** | 소실 |
|
||||||
|
| `X-Forwarded-Host` | `app1.hyeonworks.com` | `app1.hyeonworks.com` | 유지 |
|
||||||
|
|
||||||
|
앱이 최종적으로 보는 값:
|
||||||
|
|
||||||
|
```
|
||||||
|
scheme http
|
||||||
|
secure false
|
||||||
|
requestUrl http://app1.hyeonworks.com/api/echo
|
||||||
|
```
|
||||||
|
|
||||||
|
**위조 테스트** — 클라이언트가 `X-Forwarded-For: 1.2.3.4`,
|
||||||
|
`X-Forwarded-Host: evil.example.com`을 직접 붙여 요청했으나 **앱에 흔적이
|
||||||
|
도달하지 않았다.** 다만 이는 nginx가 막아서가 아니라 **Traefik이 전부
|
||||||
|
덮어썼기 때문**이다. 신뢰 경계는 결과적으로 작동하지만, 그 대가로 정당한
|
||||||
|
값까지 함께 버려진다.
|
||||||
|
|
||||||
|
**파드 분배** — 8회 요청이 두 파드(`10.42.0.9`, `10.42.1.3`)에 정확히 번갈아
|
||||||
|
도달했다. nginx upstream 라운드로빈과 Service 분배가 모두 작동한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5-1. 대조 실험 — 원인이 둘임을 분리한다
|
||||||
|
|
||||||
|
측정값만으로는 "누가 값을 바꿨는지" 알 수 없다. nginx를 우회해 Traefik에
|
||||||
|
직접 요청하여 원인을 분리했다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# test-server 에서, nginx 를 거치지 않고 노드의 Traefik 에 직접
|
||||||
|
curl -s http://192.168.122.11/api/echo \
|
||||||
|
-H 'Host: app1.hyeonworks.com' \
|
||||||
|
-H 'X-Forwarded-Proto: https' \
|
||||||
|
-H 'X-Forwarded-Port: 443' \
|
||||||
|
-H 'X-Forwarded-For: 203.0.113.7'
|
||||||
|
```
|
||||||
|
|
||||||
|
| | 보낸 값 | 도달한 값 |
|
||||||
|
|---|---|---|
|
||||||
|
| `X-Forwarded-Proto` | `https` | **`http`** |
|
||||||
|
| `X-Forwarded-Port` | `443` | **`80`** |
|
||||||
|
| `X-Forwarded-For` | `203.0.113.7` | **`10.42.0.1`** |
|
||||||
|
|
||||||
|
**올바른 헤더를 명시했는데도 전부 재작성됐다.** Traefik의 덮어쓰기가
|
||||||
|
독립적으로 증명된다.
|
||||||
|
|
||||||
|
그리고 이 과정에서 **두 번째 원인**이 드러났다.
|
||||||
|
|
||||||
|
### 원인 A — nginx가 애초에 틀린 값을 보내고 있다
|
||||||
|
|
||||||
|
`/etc/nginx/sites-available/keycloak-lab`의 443 서버 블록:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
listen 443 ssl default_server;
|
||||||
|
...
|
||||||
|
proxy_set_header X-Forwarded-Proto http; # ← https 여야 한다
|
||||||
|
proxy_set_header X-Forwarded-Port 80; # ← 443 이어야 한다
|
||||||
|
```
|
||||||
|
|
||||||
|
**TLS를 종료하는 서버가 "원래 요청은 평문이었다"고 알리고 있다.**
|
||||||
|
HTTP 전용으로 먼저 세운 뒤 TLS를 얹는 과정에서 이 두 줄을 함께 바꾸지 않아
|
||||||
|
남은 값이다.
|
||||||
|
|
||||||
|
문법 오류가 아니므로 `nginx -t`도 통과하고 **아무 경고 없이 잘못된 값이
|
||||||
|
전파된다.** 이런 종류의 실수는 측정 없이는 드러나지 않는다.
|
||||||
|
|
||||||
|
### 원인 B — Traefik이 올바른 값이 와도 덮어쓴다
|
||||||
|
|
||||||
|
위 대조 실험이 보여준 것이다. `forwardedHeaders.trustedIPs` 미설정.
|
||||||
|
|
||||||
|
**두 원인은 직렬로 걸려 있다. A만 고쳐도 B 때문에 해결되지 않는다.**
|
||||||
|
|
||||||
|
> 증거 원자료: `docs/evidence/two-hop-proxy-headers/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 원인 — 독립된 스위치 세 개
|
||||||
|
|
||||||
|
이 사슬에는 **각각 따로 켜야 하는 스위치가 세 개** 있다.
|
||||||
|
**하나만 꺼져 있어도 정보가 끊긴다.**
|
||||||
|
|
||||||
|
| # | 위치 | 스위치 | 현재 | 하는 일 | 꺼져 있으면 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | nginx | `proxy_set_header X-Forwarded-*` | **켜짐** | 헤더를 **만든다** | 헤더가 존재하지 않음 |
|
||||||
|
| 2 | Traefik | `forwardedHeaders.trustedIPs` | **꺼짐** | 받은 헤더를 **전달할지 버릴지** | **버리고 자기 값으로 재작성** |
|
||||||
|
| 3 | 앱 | `forward-headers-strategy` 등 | **꺼짐** | 도착한 헤더를 **읽어서 반영할지** | 헤더가 와 있어도 무시 |
|
||||||
|
|
||||||
|
지금은 2번에서 끊긴다. 2번을 고쳐도 3번을 켜지 않으면 앱은 여전히 원래
|
||||||
|
스킴을 모른다.
|
||||||
|
|
||||||
|
### 3번을 구체적으로
|
||||||
|
|
||||||
|
헤더는 **이미 앱에 도착해 있다.** `/api/echo` 출력에 `x-forwarded-proto: http`가
|
||||||
|
찍혔다. 도착은 했다.
|
||||||
|
|
||||||
|
그런데 앱이 `request.getScheme()`을 부르면 `http`가 나온다.
|
||||||
|
**헤더를 읽지 않고 TCP 연결 자체를 보기 때문**이다.
|
||||||
|
`forward-headers-strategy=native`를 켜면 Tomcat이 헤더를 읽어서
|
||||||
|
**요청 객체의 scheme·host·port·remoteAddr를 갈아끼운다.**
|
||||||
|
|
||||||
|
즉 3번은 **"도착한 헤더를 진짜로 믿고 내 요청 정보를 바꿔칠까"** 의 스위치다.
|
||||||
|
|
||||||
|
### 3번은 앱마다 하나씩이다
|
||||||
|
|
||||||
|
1번과 2번은 한 번 켜면 끝이지만 **3번은 새 앱을 올릴 때마다 따로 켜야 한다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] nginx 1개 고정
|
||||||
|
[2] Traefik 1개 고정
|
||||||
|
[3] 앱 N개 Keycloak · BFF · oauth2-proxy · backend API …
|
||||||
|
```
|
||||||
|
|
||||||
|
그리고 **빠뜨려도 오류가 나지 않고 조용히 틀린 값으로 동작**한다.
|
||||||
|
이것이 이 계약을 문서로 고정해두어야 하는 이유다.
|
||||||
|
|
||||||
|
### 기본값이 "믿지 않음"인 것은 의도된 설계다
|
||||||
|
|
||||||
|
`X-Forwarded-*`는 **누구나 위조할 수 있는 평범한 HTTP 헤더**다.
|
||||||
|
"누구로부터 온 것을 믿을지"를 명시하지 않으면 **신뢰하지 않는 쪽이 안전**하다.
|
||||||
|
프레임워크들이 하나같이 기본값을 꺼두는 이유다.
|
||||||
|
|
||||||
|
### 네 번째 요인 — `externalTrafficPolicy: Cluster`
|
||||||
|
|
||||||
|
svclb가 트래픽을 SNAT하면서 클라이언트 IP가 Traefik에 도달하기 전에 이미
|
||||||
|
사라진다. 2번을 고치면 `X-Forwarded-For`에 담긴 nginx의 값은 살아나지만,
|
||||||
|
**TCP 출발지 주소 자체는 복원되지 않는다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 앱 스위치를 켜는 방법
|
||||||
|
|
||||||
|
### Spring Boot
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
forward-headers-strategy: native # none | native | framework
|
||||||
|
```
|
||||||
|
|
||||||
|
환경변수는 `SERVER_FORWARD_HEADERS_STRATEGY=native`.
|
||||||
|
|
||||||
|
| 값 | 구현 | 신뢰 IP 제한 |
|
||||||
|
|---|---|---|
|
||||||
|
| `none` (기본) | 무시 | — |
|
||||||
|
| **`native`** | 서블릿 컨테이너 기능 (Tomcat `RemoteIpValve`) | **있음** |
|
||||||
|
| `framework` | Spring `ForwardedHeaderFilter` | **없음 — 무조건 신뢰** |
|
||||||
|
|
||||||
|
**`native`를 권하는 이유가 마지막 열이다.** Tomcat의 `RemoteIpValve`는
|
||||||
|
`internalProxies` 기본 정규식(`10.x`, `192.168.x`, `172.16~31.x`, `127.x`)에
|
||||||
|
해당하는 **출발지에서 온 요청만** 헤더를 반영한다. 파드 IP가 `10.42.x`라
|
||||||
|
기본값에 들어간다.
|
||||||
|
|
||||||
|
`framework`는 그런 필터가 없어 **누가 보내든 믿는다.**
|
||||||
|
|
||||||
|
**켜면 실제로 무슨 일이 일어나나** — 밸브가 요청 객체를 갈아끼운다.
|
||||||
|
|
||||||
|
```
|
||||||
|
X-Forwarded-For → request.getRemoteAddr()
|
||||||
|
X-Forwarded-Proto → request.getScheme(), isSecure()
|
||||||
|
X-Forwarded-Port → request.getServerPort()
|
||||||
|
X-Forwarded-Host → request.getServerName()
|
||||||
|
→ 그 결과 getRequestURL() 이 외부 URL 로 재구성됨
|
||||||
|
```
|
||||||
|
|
||||||
|
**애플리케이션 코드는 한 줄도 고치지 않는다.** 프레임워크가 요청 정보를
|
||||||
|
바꿔서 넘겨준다.
|
||||||
|
|
||||||
|
### Keycloak
|
||||||
|
|
||||||
|
```
|
||||||
|
KC_PROXY_HEADERS=xforwarded # xforwarded | forwarded
|
||||||
|
```
|
||||||
|
|
||||||
|
| 값 | 읽는 헤더 |
|
||||||
|
|---|---|
|
||||||
|
| `xforwarded` | `X-Forwarded-For`, `-Proto`, `-Host`, `-Port` (관례) |
|
||||||
|
| `forwarded` | RFC 7239의 `Forwarded:` 단일 헤더 |
|
||||||
|
| 미설정 | 무시 |
|
||||||
|
|
||||||
|
**Keycloak은 방어가 두 겹이다.**
|
||||||
|
|
||||||
|
| 설정 | 담당 |
|
||||||
|
|---|---|
|
||||||
|
| `KC_HOSTNAME=https://auth...` | 스킴·호스트를 **고정** — 헤더와 무관 |
|
||||||
|
| `KC_PROXY_HEADERS=xforwarded` | **클라이언트 IP** 등 나머지를 헤더에서 |
|
||||||
|
|
||||||
|
그래서 `iss`는 `KC_HOSTNAME`만으로도 살아난다. 하지만 brute-force 탐지와
|
||||||
|
감사 로그의 IP는 `KC_PROXY_HEADERS`가 있어야 맞는다.
|
||||||
|
|
||||||
|
> 예전 `KC_PROXY=edge` 옵션은 Keycloak 24에서 deprecated 되고
|
||||||
|
> `KC_PROXY_HEADERS`로 대체됐다. 오래된 예제 참고 시 주의.
|
||||||
|
|
||||||
|
### oauth2-proxy
|
||||||
|
|
||||||
|
```
|
||||||
|
--reverse-proxy=true # 또는 OAUTH2_PROXY_REVERSE_PROXY=true
|
||||||
|
```
|
||||||
|
|
||||||
|
신뢰 IP 제한 기능이 없어 **무조건 신뢰**한다.
|
||||||
|
|
||||||
|
### 뒤쪽에 nginx가 있는 경우
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
set_real_ip_from 10.42.0.0/16;
|
||||||
|
real_ip_header X-Forwarded-For;
|
||||||
|
real_ip_recursive on;
|
||||||
|
```
|
||||||
|
|
||||||
|
신뢰 IP 지정이 **필수**다. `set_real_ip_from` 없이는 동작하지 않는다.
|
||||||
|
|
||||||
|
### 공통 원리
|
||||||
|
|
||||||
|
어느 프레임워크든 결국 **두 가지를 정하는 일**이다.
|
||||||
|
|
||||||
|
1. **어떤 헤더 형식을 읽을지** — `X-Forwarded-*` vs RFC 7239 `Forwarded`
|
||||||
|
2. **누구로부터 온 것을 믿을지** — 신뢰 프록시 IP 목록
|
||||||
|
|
||||||
|
두 번째가 있는 구현이 안전하다. Spring `native`와 nginx `real_ip`는 있고,
|
||||||
|
Spring `framework`와 oauth2-proxy는 없다.
|
||||||
|
|
||||||
|
### 켤 때 반드시 같이 봐야 하는 것
|
||||||
|
|
||||||
|
**앱에 프록시를 거치지 않고 직접 도달할 경로가 있으면 안 된다.**
|
||||||
|
|
||||||
|
헤더 신뢰를 켠 상태에서 공격자가 앱에 직접 요청하며
|
||||||
|
`X-Forwarded-Proto: https`를 붙이면, 앱은 그걸 믿고 **`Secure` 쿠키를
|
||||||
|
발급하거나 IP 기반 제한을 우회**당한다.
|
||||||
|
|
||||||
|
쿠버네티스에서는 Service ClusterIP로 파드에 직접 접근할 수 있으므로,
|
||||||
|
**NetworkPolicy로 Traefik에서 오는 트래픽만 허용**하는 것이 정석이다.
|
||||||
|
이 클러스터는 kube-router 내장 컨트롤러가 있어 적용 가능하다.
|
||||||
|
|
||||||
|
**"헤더를 믿는다"는 결정과 "그 앞에 반드시 프록시가 있다"는 보장은 한 쌍이다.**
|
||||||
|
한쪽만 하면 구멍이 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 영향 — 패턴별
|
||||||
|
|
||||||
|
| 패턴 | 의존도 | 무엇이 깨지나 |
|
||||||
|
|---|---|---|
|
||||||
|
| AP1 SPA direct | 낮음 | 브라우저가 Keycloak에 직접 감. Keycloak 자체만 필요 |
|
||||||
|
| AP2 token mediator | 중간 | mediator의 redirect URI |
|
||||||
|
| **AP3 BFF** | **높음** | **로그인 자체가 실패**, 세션 쿠키 `Secure` 유실 |
|
||||||
|
| **AP4 edge auth** | **결정적** | **인증 결과가 헤더로 전달됨** |
|
||||||
|
|
||||||
|
### AP3(BFF)에서 왜 중요한가
|
||||||
|
|
||||||
|
**흔한 오해 — "BFF가 넘기는 헤더는 세션 쿠키뿐인데?"**
|
||||||
|
|
||||||
|
쿠키를 **전달하는 것**은 문제가 아니다. 프록시는 `Cookie:` 헤더를 그대로
|
||||||
|
통과시킨다. 문제는 **쿠키와 URL을 만들 때**이고, 그 재료가 `X-Forwarded-*`다.
|
||||||
|
|
||||||
|
**(1) OAuth2 `redirect_uri` 생성 — 가장 먼저 터진다**
|
||||||
|
|
||||||
|
BFF는 Spring Security의 `oauth2Login`을 쓴다. Keycloak으로 사용자를 보낼 때
|
||||||
|
`redirect_uri` 파라미터를 **현재 요청 URL로부터** 만든다.
|
||||||
|
|
||||||
|
```
|
||||||
|
redirect_uri = {scheme}://{serverName}:{serverPort}/login/oauth2/code/keycloak
|
||||||
|
↑ request.getScheme() 에서 온다
|
||||||
|
```
|
||||||
|
|
||||||
|
`scheme=http`면 이렇게 나간다.
|
||||||
|
|
||||||
|
```
|
||||||
|
redirect_uri=http://app1.hyeonworks.com/login/oauth2/code/keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
그러면 두 가지 중 하나가 벌어진다.
|
||||||
|
|
||||||
|
- Keycloak 클라이언트에 `https://...`만 등록돼 있으면
|
||||||
|
→ **`invalid_redirect_uri` 오류로 로그인 거부**
|
||||||
|
- 실수로 `http://...`도 등록해뒀다면
|
||||||
|
→ 브라우저가 https 페이지에서 http로 리다이렉트 →
|
||||||
|
**혼합 콘텐츠 차단 또는 세션 쿠키 유실**
|
||||||
|
|
||||||
|
**세션 쿠키 문제보다 먼저, 로그인 자체가 안 된다.**
|
||||||
|
|
||||||
|
**(2) 세션 쿠키의 `Secure` 속성**
|
||||||
|
|
||||||
|
서블릿 컨테이너는 `request.isSecure()`를 보고 `Set-Cookie`에 `Secure`를
|
||||||
|
붙일지 정한다. `isSecure()`가 `false`면 **`Secure` 없는 세션 쿠키**가 나간다.
|
||||||
|
|
||||||
|
| 결과 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 평문 전송 위험 | 그 쿠키는 http 요청에도 실려 나간다. 중간자가 세션을 탈취할 수 있다 |
|
||||||
|
| **`SameSite=None` 사용 불가** | 브라우저는 `Secure` 없는 `SameSite=None` 쿠키를 **거부**한다 |
|
||||||
|
|
||||||
|
두 번째가 AP3의 학습 주제와 정면으로 부딪힌다. AP3는 `oauth2Login` 세션과
|
||||||
|
**CSRF·SameSite 방어**가 핵심인데, `Secure`가 없으면 `SameSite` 설계
|
||||||
|
선택지가 통째로 사라진다.
|
||||||
|
|
||||||
|
**(3) 로그아웃 `post_logout_redirect_uri`**
|
||||||
|
|
||||||
|
같은 원리로 http가 박히고, Keycloak에 등록된 값과 불일치해 거부된다.
|
||||||
|
|
||||||
|
**정리하면** — BFF에서 헤더 계약이 중요한 이유는 쿠키를 *전달*하기 때문이
|
||||||
|
아니라, **쿠키와 OAuth2 URL을 *생성*하는 재료이기 때문**이다.
|
||||||
|
|
||||||
|
### AP4(edge auth)에서 왜 결정적인가
|
||||||
|
|
||||||
|
AP4는 **헤더 신뢰가 패턴의 존재 이유 자체**다.
|
||||||
|
|
||||||
|
oauth2-proxy는 인증을 끝내고 **결과를 헤더로 downstream에 넘긴다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
X-Auth-Request-User
|
||||||
|
X-Auth-Request-Email
|
||||||
|
X-Auth-Request-Groups
|
||||||
|
X-Auth-Request-Access-Token
|
||||||
|
```
|
||||||
|
|
||||||
|
downstream 앱은 **이 헤더를 믿고 "누가 로그인했는지"를 판단**한다. 토큰을
|
||||||
|
직접 검증하지 않는다. 그것이 AP4가 "기존 upstream을 수정하지 않고 경계에서
|
||||||
|
일괄 인증"할 수 있는 이유다.
|
||||||
|
|
||||||
|
**그래서 여기서 헤더 신뢰가 무너지면 인증 우회가 된다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
공격자가 직접: X-Auth-Request-User: admin
|
||||||
|
프록시가 안 덮어쓰면 → downstream 은 admin 으로 인식
|
||||||
|
```
|
||||||
|
|
||||||
|
지금 측정한 `X-Forwarded-*` 문제와 **구조가 완전히 같다.** 헤더 이름과
|
||||||
|
의미만 다르다.
|
||||||
|
|
||||||
|
| 헤더군 | 담는 정보 | 위조되면 |
|
||||||
|
|---|---|---|
|
||||||
|
| `X-Forwarded-*` | 원래 요청이 어땠나 | 쿠키 속성·URL이 틀어짐 |
|
||||||
|
| `X-Auth-Request-*` | **누가 인증됐나** | **신원 위조 = 인증 우회** |
|
||||||
|
|
||||||
|
저장소의 `feature/keycloak-header-spoofing-defense` 브랜치
|
||||||
|
(manifest: `ap4 / locally-verified`)가 이 문제를 다룬다.
|
||||||
|
**지금 확정하는 2홉 계약이 그 브랜치의 전제**다 — 1홉 가정으로 검증된
|
||||||
|
방어가 2홉에서도 유효한지 다시 확인해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 해결 — 어느 파일의 무엇을 어떻게 고치는가
|
||||||
|
|
||||||
|
세 곳을 순서대로 고쳤다. **각 단계마다 측정하여 어느 스위치가 무엇을
|
||||||
|
담당하는지 데이터로 확인했다.**
|
||||||
|
|
||||||
|
### A. nginx — 원래 스킴을 사실대로 알린다
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 저장소 파일 | `deploy/lab/host/nginx-keycloak-lab.conf` |
|
||||||
|
| 서버 배포 위치 | `/etc/nginx/sites-available/keycloak-lab` |
|
||||||
|
| 활성화 | `/etc/nginx/sites-enabled/keycloak-lab` 심볼릭 링크 |
|
||||||
|
|
||||||
|
`server { listen 443 ssl ... }` 블록의 `location /` 안에서 두 줄을 고친다.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
- proxy_set_header X-Forwarded-Proto http;
|
||||||
|
- proxy_set_header X-Forwarded-Port 80;
|
||||||
|
+ proxy_set_header X-Forwarded-Proto https;
|
||||||
|
+ proxy_set_header X-Forwarded-Port 443;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
```
|
||||||
|
|
||||||
|
**`listen 443 ssl`과 `proxy_pass http://`는 건드리지 않는다.**
|
||||||
|
앞에서 HTTPS로 받고 뒤로 평문으로 보내는 것은 의도된 설계다.
|
||||||
|
고치는 것은 **뒤로 보낼 때 붙이는 라벨**뿐이다.
|
||||||
|
|
||||||
|
배포:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/workspace/keycloak-pattern && git pull
|
||||||
|
sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
grep -n 'X-Forwarded-Proto\|X-Forwarded-Port' /etc/nginx/sites-available/keycloak-lab
|
||||||
|
```
|
||||||
|
|
||||||
|
**손으로 서버 파일을 고치지 않는다.** 저장소에서 단방향으로 복사한다.
|
||||||
|
이 실수가 발생한 원인 자체가 저장소와 서버의 드리프트였다.
|
||||||
|
|
||||||
|
### B. Traefik — nginx의 헤더를 신뢰한다
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 저장소 파일 | `deploy/lab/k8s/traefik-forwarded-headers.yaml` (신규) |
|
||||||
|
| 적용 대상 | `kube-system` 네임스페이스의 Traefik HelmChart |
|
||||||
|
|
||||||
|
k3s의 Traefik은 번들 HelmChart로 설치되므로 **Deployment를 직접 고치면
|
||||||
|
안 된다.** helm-controller가 되돌린다. `HelmChartConfig`로 차트 값을
|
||||||
|
덮어써야 한다.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: helm.cattle.io/v1
|
||||||
|
kind: HelmChartConfig
|
||||||
|
metadata:
|
||||||
|
name: traefik
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
valuesContent: |-
|
||||||
|
ports:
|
||||||
|
web:
|
||||||
|
forwardedHeaders:
|
||||||
|
trustedIPs:
|
||||||
|
- 10.42.0.0/16 # 파드 대역 (svclb SNAT 출발지)
|
||||||
|
- 192.168.122.0/24 # 노드·호스트 대역
|
||||||
|
websecure:
|
||||||
|
forwardedHeaders:
|
||||||
|
trustedIPs:
|
||||||
|
- 10.42.0.0/16
|
||||||
|
- 192.168.122.0/24
|
||||||
|
```
|
||||||
|
|
||||||
|
**`10.42.0.0/16`이 필요한 이유** — traefik Service가
|
||||||
|
`externalTrafficPolicy: Cluster`이므로 svclb가 SNAT한다. Traefik이 보는
|
||||||
|
출발지는 호스트 nginx의 주소가 아니라 **파드 네트워크 주소**다.
|
||||||
|
|
||||||
|
적용:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
|
||||||
|
kubectl -n kube-system rollout status deploy/traefik --timeout=180s
|
||||||
|
```
|
||||||
|
|
||||||
|
**함정 — `rollout status` 완료가 곧 반영은 아니다.** helm-controller가
|
||||||
|
`helm-install-traefik` **Job을 새로 돌려** 차트를 업그레이드하므로, 그 사이
|
||||||
|
**구 파드가 잠시 함께 살아 있다.** 이 시점에 측정하면 옛 파드가 응답해
|
||||||
|
"고쳤는데 안 바뀌었다"고 오해하게 된다. 실제로 이 함정에 한 번 걸렸다.
|
||||||
|
|
||||||
|
파드 이름과 인자로 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n kube-system get pods -l app.kubernetes.io/name=traefik
|
||||||
|
kubectl -n kube-system get pod -l app.kubernetes.io/name=traefik \
|
||||||
|
-o jsonpath='{.items[0].spec.containers[0].args}' | tr ',' '\n' | grep -i forwarded
|
||||||
|
# --entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16,192.168.122.0/24
|
||||||
|
```
|
||||||
|
|
||||||
|
**트레이드오프** — 파드 대역 전체를 신뢰하면 **클러스터 안의 어떤 파드든
|
||||||
|
헤더를 위조할 수 있다.** 실험대에서는 받아들일 만하지만 운영에서는 좁혀야
|
||||||
|
한다. 좁히려면 `externalTrafficPolicy: Local`로 SNAT를 없애고 실제
|
||||||
|
출발지(`192.168.122.1`)만 신뢰하는 방법이 있으나, 그러면 해당 노드에 Traefik
|
||||||
|
파드가 없을 때 트래픽이 버려진다.
|
||||||
|
|
||||||
|
### C. 앱 — 도착한 헤더를 해석한다
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 저장소 파일 | `deploy/lab/k8s/echo.yaml` |
|
||||||
|
| 위치 | `spec.template.spec.containers[0].env` |
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- name: SERVER_FORWARD_HEADERS_STRATEGY
|
||||||
|
- value: "none"
|
||||||
|
+ value: "native"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f deploy/lab/k8s/echo.yaml
|
||||||
|
kubectl -n header-lab rollout status deployment/echo --timeout=180s
|
||||||
|
```
|
||||||
|
|
||||||
|
앱마다 스위치 이름이 다르다.
|
||||||
|
|
||||||
|
| 앱 | 설정 | 넣는 곳 |
|
||||||
|
|---|---|---|
|
||||||
|
| Spring Boot | `SERVER_FORWARD_HEADERS_STRATEGY=native` | 컨테이너 `env` |
|
||||||
|
| **Keycloak** | **`KC_PROXY_HEADERS=xforwarded`** | 컨테이너 `env` |
|
||||||
|
| oauth2-proxy | `--reverse-proxy=true` | 컨테이너 `args` |
|
||||||
|
|
||||||
|
**새 앱을 올릴 때마다 반복해야 한다.** 빠뜨려도 오류가 나지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 단계별 측정 결과
|
||||||
|
|
||||||
|
각 스위치를 하나씩 켜면서 측정한 값이다.
|
||||||
|
**어느 스위치가 무엇을 담당하는지가 그대로 드러난다.**
|
||||||
|
|
||||||
|
| 측정 항목 | 최초 | A 이후 | B 이후 | **C 이후** |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| nginx 가 보내는 값 | `http`/`80` | `https`/`443` | `https`/`443` | `https`/`443` |
|
||||||
|
| `x-forwarded-proto` | `http` | **`http`** | `https` | `https` |
|
||||||
|
| `x-forwarded-port` | `80` | **`80`** | `443` | `443` |
|
||||||
|
| `x-real-ip` | `10.42.1.0` | `10.42.1.0` | `100.123.124.30` | `100.123.124.30` |
|
||||||
|
| `scheme` (앱 해석) | `http` | `http` | **`http`** | **`https`** |
|
||||||
|
| `secure` | `false` | `false` | **`false`** | **`true`** |
|
||||||
|
| `requestUrl` | `http://…` | `http://…` | `http://…` | **`https://…`** |
|
||||||
|
|
||||||
|
**A 이후에 아무것도 바뀌지 않은 것**이 Traefik의 덮어쓰기를 증명한다.
|
||||||
|
nginx가 올바른 값을 보내는데도 앱에는 `http`가 도달했다.
|
||||||
|
|
||||||
|
**B 이후에 헤더는 살아났지만 앱 해석은 그대로**인 것이 2번과 3번 스위치가
|
||||||
|
서로 다른 일을 한다는 증거다. 헤더는 도착해 있었지만 앱이 읽지 않았다.
|
||||||
|
|
||||||
|
**C 이후에야 앱이 원래 요청을 인식한다.**
|
||||||
|
|
||||||
|
최종 상태:
|
||||||
|
|
||||||
|
```
|
||||||
|
x-forwarded-proto https
|
||||||
|
x-forwarded-port 443
|
||||||
|
x-real-ip 100.123.124.30 ← 실제 클라이언트(워크스테이션 tailnet IP)
|
||||||
|
scheme https
|
||||||
|
secure True
|
||||||
|
serverPort 443
|
||||||
|
remoteAddr 100.123.124.30
|
||||||
|
requestUrl https://app1.hyeonworks.com/api/echo
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 검증
|
||||||
|
|
||||||
|
### 정상 동작
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
| 항목 | 기대값 |
|
||||||
|
|---|---|
|
||||||
|
| `x-forwarded-proto` | `https` |
|
||||||
|
| `x-forwarded-port` | `443` |
|
||||||
|
| `x-real-ip` | 실제 클라이언트 IP |
|
||||||
|
| `scheme` | `https` |
|
||||||
|
| `secure` | `true` |
|
||||||
|
| `requestUrl` | `https://app1.hyeonworks.com/api/echo` |
|
||||||
|
|
||||||
|
### 위조 차단 — 이쪽이 더 중요하다
|
||||||
|
|
||||||
|
헤더 신뢰를 켠 뒤에는 **위조가 여전히 막히는지 반드시 확인해야 한다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://app1.hyeonworks.com/api/echo \
|
||||||
|
-H 'X-Forwarded-Proto: http' \
|
||||||
|
-H 'X-Forwarded-Host: evil.example.com' \
|
||||||
|
-H 'X-Forwarded-For: 1.2.3.4' \
|
||||||
|
-H 'X-Real-IP: 1.2.3.4' | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
**주입한 값이 하나도 반영되지 않아야 한다.** 실측 결과 `scheme=https`,
|
||||||
|
`serverName=app1.hyeonworks.com`, `remoteAddr=100.123.124.30`이 유지됐다.
|
||||||
|
|
||||||
|
방어의 주체가 바뀌었다는 점에 유의한다.
|
||||||
|
|
||||||
|
| | 수정 전 | 수정 후 |
|
||||||
|
|---|---|---|
|
||||||
|
| 위조를 막는 주체 | **Traefik** (전부 덮어씀) | **nginx** (`$remote_addr`로 덮어씀) |
|
||||||
|
| 대가 | 정당한 값도 함께 버려짐 | 없음 |
|
||||||
|
|
||||||
|
**따라서 nginx의 `$remote_addr` 사용은 선택이 아니라 필수다.**
|
||||||
|
`$proxy_add_x_forwarded_for`(덧붙이기)로 바꾸면 클라이언트가 넣은 값이
|
||||||
|
사슬 앞부분에 남아 신뢰 경계가 무너진다.
|
||||||
|
|
||||||
|
### 프록시 우회 경로 차단
|
||||||
|
|
||||||
|
앱이 헤더를 신뢰하게 되면 **Traefik을 거치지 않고 파드에 직접 도달할 수
|
||||||
|
있는 경로가 곧 구멍**이 된다. 클러스터 안에서는 Service ClusterIP로 접근할
|
||||||
|
수 있으므로 실제로 위조가 성립했다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n header-lab run t --rm -i --restart=Never --image=curlimages/curl -- \
|
||||||
|
curl -s http://echo:8081/api/echo \
|
||||||
|
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
serverName evil.example.com ← 위조 성공
|
||||||
|
remoteAddr 1.2.3.4 ← 위조 성공
|
||||||
|
requestUrl https://evil.example.com/api/echo
|
||||||
|
```
|
||||||
|
|
||||||
|
**두 신뢰 설정이 모두 "대역"을 믿기 때문**이다.
|
||||||
|
|
||||||
|
| 계층 | 신뢰 범위 | 지정 방식 |
|
||||||
|
|---|---|---|
|
||||||
|
| Traefik `trustedIPs` | 파드 대역 전체 | IP 대역 |
|
||||||
|
| 앱 Tomcat `internalProxies` | 사설 대역 전체 (기본 정규식) | IP 정규식 |
|
||||||
|
|
||||||
|
IP로는 Traefik을 특정할 수 없다. **파드 IP가 재시작마다 바뀌기 때문**이다
|
||||||
|
(측정 중 실제로 `10.42.0.8` → `10.42.1.12`로, 노드까지 옮겨갔다).
|
||||||
|
|
||||||
|
**해결 — NetworkPolicy는 IP가 아니라 라벨로 지정한다.**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 저장소 파일 | `deploy/lab/k8s/echo-network-policy.yaml` |
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: kube-system
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: traefik # ← IP 가 아니라 라벨
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 8081
|
||||||
|
```
|
||||||
|
|
||||||
|
`namespaceSelector`와 `podSelector`를 **같은 리스트 항목**에 두면 AND로
|
||||||
|
결합된다. 별개 항목으로 나누면 OR이 되어 kube-system 전체가 허용되므로
|
||||||
|
주의한다.
|
||||||
|
|
||||||
|
**kubelet probe를 위한 규칙이 별도로 필요하다.** readiness/liveness는 파드가
|
||||||
|
아니라 노드에서 오므로 위 규칙에 걸리지 않는다. 빠뜨리면 probe가 실패하고
|
||||||
|
**파드가 재시작 루프에 빠진다.**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- from:
|
||||||
|
- ipBlock: { cidr: 10.42.0.1/32 } # kc-lab-1 의 cni0
|
||||||
|
- ipBlock: { cidr: 10.42.1.1/32 } # kc-lab-2 의 cni0
|
||||||
|
```
|
||||||
|
|
||||||
|
probe의 출발지는 **노드의 flannel 브리지(cni0)** 이고, 각 노드 `/24`의 첫
|
||||||
|
주소다. `/32`로 정확히 지정해야 한다 — `10.42.0.0/16`으로 넓히면 임의의
|
||||||
|
파드가 다시 들어와 정책이 무의미해진다.
|
||||||
|
|
||||||
|
**적용 후 확인**
|
||||||
|
|
||||||
|
```
|
||||||
|
정상 경로 scheme=https, remoteAddr=100.123.124.30 계속 동작
|
||||||
|
우회 시도 HTTP 000 / curl exit 7 연결 자체가 거부됨
|
||||||
|
파드 상태 1/1 Running, restarts=0 probe 정상
|
||||||
|
```
|
||||||
|
|
||||||
|
**"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다.**
|
||||||
|
앞의 것만 하면 이 구멍이 남는다.
|
||||||
|
|
||||||
|
## 12. 증거
|
||||||
|
|
||||||
|
`docs/evidence/two-hop-proxy-headers/`
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `01-environment.txt` | 수정 전 세 계층 설정 |
|
||||||
|
| `02-measurements.txt` | 수정 전 측정 + 대조 실험 |
|
||||||
|
| `03-browser-https-vs-app-http.png` | 브라우저와 앱의 인식 차이 |
|
||||||
|
| `stage-a-nginx-fixed.png` | A 이후 — 여전히 `http` |
|
||||||
|
| `stage-b-traefik-trusts.png` | B 이후 — 헤더는 살아났으나 앱 해석은 `http` |
|
||||||
|
| `stage-c-resolved.png` | C 이후 — 전 구간 `https` |
|
||||||
|
| `04-after-fix.txt` | 최종 측정 + 위조 테스트 + 분배 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 참고
|
||||||
|
|
||||||
|
- 1홉 계약 원본: `docs/reverse-proxy-headers.md`
|
||||||
|
- 개념 상세: `docs/session-lab-concepts.md`
|
||||||
|
- 패턴 비교: `docs/four-pattern-tradeoff-matrix.md`
|
||||||
|
- 측정 배포: `deploy/lab/k8s/echo.yaml`
|
||||||
|
- 측정 실행: `deploy/lab/scripts/measure-proxy-headers.sh`
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { chromium } from "playwright-core";
|
|
||||||
|
|
||||||
const password = process.env.MOCK_GOOGLE_USER_PASSWORD;
|
|
||||||
assert.ok(password);
|
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
|
||||||
headless: true,
|
|
||||||
args: ["--no-sandbox"],
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const page = await browser.newPage();
|
|
||||||
const tokenResponse = page.waitForResponse((response) =>
|
|
||||||
response.url().includes("/protocol/openid-connect/token")
|
|
||||||
&& response.request().postData()?.includes("grant_type=authorization_code"),
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.goto("http://localhost:8088/");
|
|
||||||
await page.locator("#login").click();
|
|
||||||
await page.locator('a[href*="/broker/mock-google/login"]').click();
|
|
||||||
await page.waitForURL(/\/realms\/mock-google\//u);
|
|
||||||
await page.locator("#username").fill("mock-new-user");
|
|
||||||
await page.locator("#password").fill(password);
|
|
||||||
await page.locator("#kc-login").click();
|
|
||||||
|
|
||||||
const response = await tokenResponse;
|
|
||||||
assert.equal(response.status(), 200);
|
|
||||||
const token = (await response.json()).access_token;
|
|
||||||
const payload = JSON.parse(
|
|
||||||
Buffer.from(token.split(".")[1], "base64url").toString(),
|
|
||||||
);
|
|
||||||
assert.match(payload.preferred_username, /^mock-google\./u);
|
|
||||||
assert.ok(payload.aud.includes("keycloak-pattern-api"));
|
|
||||||
|
|
||||||
await page.waitForURL("http://localhost:8088/");
|
|
||||||
await page.locator('[data-authenticated="true"]').waitFor();
|
|
||||||
await page.locator("#call-api").click();
|
|
||||||
await page.waitForFunction(() =>
|
|
||||||
document.querySelector("#result")?.textContent.includes('"httpStatus": 200'),
|
|
||||||
);
|
|
||||||
console.log("Google federation verified with the unchanged AP1 SPA/API contract");
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
Generated
-28
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "keycloak-pattern-e2e",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "keycloak-pattern-e2e",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"devDependencies": {
|
|
||||||
"playwright-core": "1.62.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright-core": {
|
|
||||||
"version": "1.62.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
|
||||||
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"playwright-core": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "keycloak-pattern-e2e",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"test:pattern1": "node pattern1.mjs",
|
|
||||||
"test:role-mapping": "node role-mapping.mjs",
|
|
||||||
"test:federation-zero-change": "node federation-zero-change.mjs"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"playwright-core": "1.62.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { chromium } from "playwright-core";
|
|
||||||
|
|
||||||
const username = process.env.E2E_USERNAME ?? "regular-user";
|
|
||||||
const password = process.env.E2E_PASSWORD;
|
|
||||||
const frontendUrl = "http://localhost:8088/";
|
|
||||||
const tokenEndpoint =
|
|
||||||
"http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token";
|
|
||||||
const revokeEndpoint =
|
|
||||||
"http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/revoke";
|
|
||||||
|
|
||||||
assert.ok(password, "E2E_PASSWORD must be set");
|
|
||||||
|
|
||||||
async function login(page) {
|
|
||||||
await page.locator("#login").click();
|
|
||||||
await page.waitForURL(/localhost:8080/u);
|
|
||||||
await page.locator("#username").waitFor();
|
|
||||||
|
|
||||||
const tokenResponsePromise = page.waitForResponse((response) =>
|
|
||||||
response.url() === tokenEndpoint
|
|
||||||
&& response.request().postData()?.includes("grant_type=authorization_code"),
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.locator("#username").fill(username);
|
|
||||||
await page.locator("#password").fill(password);
|
|
||||||
await page.locator("#kc-login").click();
|
|
||||||
|
|
||||||
const tokenResponse = await tokenResponsePromise;
|
|
||||||
assert.equal(tokenResponse.status(), 200);
|
|
||||||
const tokenSet = await tokenResponse.json();
|
|
||||||
assert.ok(tokenSet.access_token);
|
|
||||||
assert.ok(tokenSet.refresh_token);
|
|
||||||
assert.ok(tokenSet.id_token);
|
|
||||||
|
|
||||||
await page.waitForURL(frontendUrl);
|
|
||||||
await page.locator('[data-authenticated="true"]').waitFor();
|
|
||||||
return tokenSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postForm(url, values) {
|
|
||||||
return fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
||||||
body: new URLSearchParams(values),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh(refreshToken) {
|
|
||||||
return postForm(tokenEndpoint, {
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
client_id: "spa-public",
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callResource(accessToken, url = "http://localhost:8081/api/me") {
|
|
||||||
return fetch(url, {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
|
||||||
headless: true,
|
|
||||||
args: ["--no-sandbox"],
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const context = await browser.newContext();
|
|
||||||
const page = await context.newPage();
|
|
||||||
let authorizationUrl;
|
|
||||||
|
|
||||||
page.on("request", (request) => {
|
|
||||||
if (request.url().includes("/protocol/openid-connect/auth")) {
|
|
||||||
authorizationUrl = new URL(request.url());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(frontendUrl);
|
|
||||||
const firstTokenSet = await login(page);
|
|
||||||
|
|
||||||
assert.equal(authorizationUrl?.searchParams.get("response_type"), "code");
|
|
||||||
assert.equal(authorizationUrl?.searchParams.get("code_challenge_method"), "S256");
|
|
||||||
assert.ok(authorizationUrl?.searchParams.get("code_challenge"));
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const originalFetch = window.fetch.bind(window);
|
|
||||||
window.__xssProbe = { authorization: null };
|
|
||||||
window.fetch = (input, init = {}) => {
|
|
||||||
const headers = new Headers(
|
|
||||||
init.headers ?? (input instanceof Request ? input.headers : undefined),
|
|
||||||
);
|
|
||||||
const authorization = headers.get("Authorization");
|
|
||||||
if (authorization) {
|
|
||||||
window.__xssProbe.authorization = authorization;
|
|
||||||
}
|
|
||||||
return originalFetch(input, init);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.locator("#call-api").click();
|
|
||||||
await page.waitForFunction(() => {
|
|
||||||
const text = document.querySelector("#result")?.textContent ?? "";
|
|
||||||
return text.includes('"httpStatus": 200');
|
|
||||||
});
|
|
||||||
|
|
||||||
const capturedAuthorization = await page.evaluate(
|
|
||||||
() => window.__xssProbe.authorization,
|
|
||||||
);
|
|
||||||
assert.match(capturedAuthorization, /^Bearer /u);
|
|
||||||
const accessToken = capturedAuthorization.slice("Bearer ".length);
|
|
||||||
assert.equal(accessToken, firstTokenSet.access_token);
|
|
||||||
|
|
||||||
const payload = JSON.parse(
|
|
||||||
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
|
|
||||||
);
|
|
||||||
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
||||||
assert.ok(audiences.includes("keycloak-pattern-api"));
|
|
||||||
|
|
||||||
const storageSnapshot = await page.evaluate(() => ({
|
|
||||||
localStorage: Object.values(localStorage),
|
|
||||||
sessionStorage: Object.values(sessionStorage),
|
|
||||||
}));
|
|
||||||
assert.equal(JSON.stringify(storageSnapshot).includes(accessToken), false);
|
|
||||||
assert.ok(capturedAuthorization);
|
|
||||||
|
|
||||||
if (process.env.WRONG_AUDIENCE_URL) {
|
|
||||||
assert.equal(
|
|
||||||
(await callResource(accessToken, process.env.WRONG_AUDIENCE_URL)).status,
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (process.env.WRONG_ISSUER_URL) {
|
|
||||||
assert.equal(
|
|
||||||
(await callResource(accessToken, process.env.WRONG_ISSUER_URL)).status,
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const logoutRequestPromise = page.waitForRequest((request) =>
|
|
||||||
request.url().includes("/protocol/openid-connect/logout"),
|
|
||||||
);
|
|
||||||
await page.locator("#logout").click();
|
|
||||||
const logoutRequest = await logoutRequestPromise;
|
|
||||||
assert.ok(new URL(logoutRequest.url()).searchParams.get("id_token_hint"));
|
|
||||||
await page.waitForURL(frontendUrl);
|
|
||||||
await page.locator('[data-authenticated="false"]').waitFor();
|
|
||||||
|
|
||||||
const secondTokenSet = await login(page);
|
|
||||||
const rotatedResponse = await refresh(secondTokenSet.refresh_token);
|
|
||||||
assert.equal(rotatedResponse.status, 200);
|
|
||||||
const rotated = await rotatedResponse.json();
|
|
||||||
assert.ok(rotated.refresh_token);
|
|
||||||
assert.notEqual(rotated.refresh_token, secondTokenSet.refresh_token);
|
|
||||||
|
|
||||||
const reusedOldResponse = await refresh(secondTokenSet.refresh_token);
|
|
||||||
assert.notEqual(
|
|
||||||
reusedOldResponse.status,
|
|
||||||
200,
|
|
||||||
"a consumed refresh token must not be accepted again",
|
|
||||||
);
|
|
||||||
|
|
||||||
const rotatedAfterReuseResponse = await refresh(rotated.refresh_token);
|
|
||||||
const rotatedAfterReuseStatus = rotatedAfterReuseResponse.status;
|
|
||||||
assert.ok([200, 400, 401].includes(rotatedAfterReuseStatus));
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
(await callResource(rotated.access_token)).status,
|
|
||||||
200,
|
|
||||||
"a locally validated access JWT remains usable until exp",
|
|
||||||
);
|
|
||||||
|
|
||||||
await context.clearCookies();
|
|
||||||
await page.reload();
|
|
||||||
await page.locator('[data-authenticated="false"]').waitFor();
|
|
||||||
const thirdTokenSet = await login(page);
|
|
||||||
|
|
||||||
const revokeResponse = await postForm(revokeEndpoint, {
|
|
||||||
token: thirdTokenSet.refresh_token,
|
|
||||||
token_type_hint: "refresh_token",
|
|
||||||
client_id: "spa-public",
|
|
||||||
});
|
|
||||||
assert.equal(revokeResponse.status, 200);
|
|
||||||
assert.notEqual((await refresh(thirdTokenSet.refresh_token)).status, 200);
|
|
||||||
assert.equal(
|
|
||||||
(await callResource(thirdTokenSet.access_token)).status,
|
|
||||||
200,
|
|
||||||
"refresh revoke is not an immediate deny-list for a stateless access JWT",
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
await page.locator('[data-authenticated="false"]').waitFor();
|
|
||||||
assert.equal(
|
|
||||||
await page.evaluate(
|
|
||||||
(token) => JSON.stringify({
|
|
||||||
localStorage: Object.values(localStorage),
|
|
||||||
sessionStorage: Object.values(sessionStorage),
|
|
||||||
}).includes(token),
|
|
||||||
thirdTokenSet.access_token,
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"pattern1 verified: PKCE, aud/iss negatives, memory/XSS boundary, logout, RT rotation/reuse, revoke-vs-stateless JWT"
|
|
||||||
+ ` (RT2 after RT1 reuse: ${rotatedAfterReuseStatus})`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { chromium } from "playwright-core";
|
|
||||||
|
|
||||||
const keycloakUrl = "http://localhost:8080";
|
|
||||||
const frontendUrl = "http://localhost:8088/";
|
|
||||||
|
|
||||||
async function accessToken(browser, username, password) {
|
|
||||||
const context = await browser.newContext();
|
|
||||||
const page = await context.newPage();
|
|
||||||
await page.goto(frontendUrl);
|
|
||||||
const tokenResponse = page.waitForResponse((response) =>
|
|
||||||
response.url().includes("/protocol/openid-connect/token")
|
|
||||||
&& response.request().postData()?.includes("grant_type=authorization_code"),
|
|
||||||
);
|
|
||||||
await page.locator("#login").click();
|
|
||||||
await page.locator("#username").fill(username);
|
|
||||||
await page.locator("#password").fill(password);
|
|
||||||
await page.locator("#kc-login").click();
|
|
||||||
const response = await tokenResponse;
|
|
||||||
assert.equal(response.status(), 200);
|
|
||||||
const token = (await response.json()).access_token;
|
|
||||||
await context.close();
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function adminStatus(token) {
|
|
||||||
return (
|
|
||||||
await fetch("http://localhost:8081/api/admin", {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
).status;
|
|
||||||
}
|
|
||||||
|
|
||||||
const regularPassword = process.env.REGULAR_USER_PASSWORD;
|
|
||||||
const adminPassword = process.env.ADMIN_USER_PASSWORD;
|
|
||||||
assert.ok(regularPassword && adminPassword);
|
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
|
||||||
headless: true,
|
|
||||||
args: ["--no-sandbox"],
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const regularToken = await accessToken(
|
|
||||||
browser,
|
|
||||||
"regular-user",
|
|
||||||
regularPassword,
|
|
||||||
);
|
|
||||||
const regularPayload = JSON.parse(
|
|
||||||
Buffer.from(regularToken.split(".")[1], "base64url").toString(),
|
|
||||||
);
|
|
||||||
assert.ok(regularPayload.realm_access.roles.includes("user-role"));
|
|
||||||
assert.equal(await adminStatus(regularToken), 403);
|
|
||||||
|
|
||||||
const adminToken = await accessToken(browser, "admin-user", adminPassword);
|
|
||||||
const adminPayload = JSON.parse(
|
|
||||||
Buffer.from(adminToken.split(".")[1], "base64url").toString(),
|
|
||||||
);
|
|
||||||
assert.ok(adminPayload.realm_access.roles.includes("admin-role"));
|
|
||||||
assert.equal(await adminStatus(adminToken), 200);
|
|
||||||
console.log("Spring RBAC verified: realm role -> ROLE_ authority -> 403/200");
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,4 @@
|
|||||||
FROM node:24-alpine AS build
|
|
||||||
|
|
||||||
WORKDIR /workspace
|
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY src ./src
|
|
||||||
COPY test ./test
|
|
||||||
RUN npm test && npm run build
|
|
||||||
|
|
||||||
FROM nginx:1.29-alpine
|
FROM nginx:1.29-alpine
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY index.html /usr/share/nginx/html/index.html
|
COPY index.html /usr/share/nginx/html/index.html
|
||||||
COPY --from=build /workspace/dist/app.js /usr/share/nginx/html/app.js
|
|
||||||
|
|||||||
+11
-46
@@ -3,66 +3,31 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="referrer" content="no-referrer">
|
<title>Keycloak Authentication Patterns</title>
|
||||||
<title>AP1 · SPA Direct + Resource Server</title>
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
max-width: 56rem;
|
max-width: 48rem;
|
||||||
margin: 6vh auto;
|
margin: 8vh auto;
|
||||||
padding: 0 1.5rem;
|
padding: 0 1.5rem;
|
||||||
line-height: 1.55;
|
line-height: 1.6;
|
||||||
}
|
|
||||||
button {
|
|
||||||
margin: 0 0.5rem 0.5rem 0;
|
|
||||||
padding: 0.6rem 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
code, pre {
|
|
||||||
border-radius: 0.35rem;
|
|
||||||
background: color-mix(in srgb, CanvasText 9%, Canvas);
|
|
||||||
}
|
}
|
||||||
code {
|
code {
|
||||||
padding: 0.1rem 0.3rem;
|
padding: 0.15rem 0.35rem;
|
||||||
}
|
border-radius: 0.25rem;
|
||||||
pre {
|
background: color-mix(in srgb, CanvasText 10%, Canvas);
|
||||||
min-height: 8rem;
|
|
||||||
padding: 1rem;
|
|
||||||
overflow: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
.notice {
|
|
||||||
border-left: 0.3rem solid #e09f3e;
|
|
||||||
padding-left: 1rem;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<h1>Keycloak Authentication Patterns</h1>
|
||||||
<h1>AP1 · SPA Direct + Resource Server</h1>
|
<p>공통 Docker Compose baseline이 실행 중입니다.</p>
|
||||||
<p>
|
<p>
|
||||||
바닐라 JavaScript SPA가 <code>spa-public</code> client로 Authorization
|
공개 API는 <code>/api/public</code>, 보호 API는
|
||||||
Code + PKCE를 수행하고, access token을 직접 Spring Resource Server에
|
<code>/api/me</code>에서 확인할 수 있습니다.
|
||||||
전달합니다.
|
|
||||||
</p>
|
</p>
|
||||||
<p class="notice">
|
|
||||||
access/refresh token은 메모리에만 존재합니다. 새로고침하면 사라지는 것이
|
|
||||||
이 패턴의 의도된 보안 경계입니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<button id="login" type="button">Keycloak 로그인</button>
|
|
||||||
<button id="call-api" type="button" disabled>보호 API 호출</button>
|
|
||||||
<button id="pkce-demo" type="button">수동 PKCE 생성</button>
|
|
||||||
<button id="logout" type="button" disabled>로그아웃</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p id="session-state" data-authenticated="false">세션 확인 중…</p>
|
|
||||||
<pre id="result" aria-live="polite"></pre>
|
|
||||||
</main>
|
|
||||||
<script type="module" src="/app.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
add_header Cache-Control "no-store";
|
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-523
@@ -1,523 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "keycloak-pattern1-spa",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "keycloak-pattern1-spa",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"oidc-client-ts": "3.5.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"esbuild": "0.28.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openharmony"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.28.1",
|
|
||||||
"@esbuild/android-arm": "0.28.1",
|
|
||||||
"@esbuild/android-arm64": "0.28.1",
|
|
||||||
"@esbuild/android-x64": "0.28.1",
|
|
||||||
"@esbuild/darwin-arm64": "0.28.1",
|
|
||||||
"@esbuild/darwin-x64": "0.28.1",
|
|
||||||
"@esbuild/freebsd-arm64": "0.28.1",
|
|
||||||
"@esbuild/freebsd-x64": "0.28.1",
|
|
||||||
"@esbuild/linux-arm": "0.28.1",
|
|
||||||
"@esbuild/linux-arm64": "0.28.1",
|
|
||||||
"@esbuild/linux-ia32": "0.28.1",
|
|
||||||
"@esbuild/linux-loong64": "0.28.1",
|
|
||||||
"@esbuild/linux-mips64el": "0.28.1",
|
|
||||||
"@esbuild/linux-ppc64": "0.28.1",
|
|
||||||
"@esbuild/linux-riscv64": "0.28.1",
|
|
||||||
"@esbuild/linux-s390x": "0.28.1",
|
|
||||||
"@esbuild/linux-x64": "0.28.1",
|
|
||||||
"@esbuild/netbsd-arm64": "0.28.1",
|
|
||||||
"@esbuild/netbsd-x64": "0.28.1",
|
|
||||||
"@esbuild/openbsd-arm64": "0.28.1",
|
|
||||||
"@esbuild/openbsd-x64": "0.28.1",
|
|
||||||
"@esbuild/openharmony-arm64": "0.28.1",
|
|
||||||
"@esbuild/sunos-x64": "0.28.1",
|
|
||||||
"@esbuild/win32-arm64": "0.28.1",
|
|
||||||
"@esbuild/win32-ia32": "0.28.1",
|
|
||||||
"@esbuild/win32-x64": "0.28.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jwt-decode": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/oidc-client-ts": {
|
|
||||||
"version": "3.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz",
|
|
||||||
"integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"jwt-decode": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "keycloak-pattern1-spa",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "esbuild src/app.js --bundle --format=esm --outfile=dist/app.js --minify --sourcemap",
|
|
||||||
"test": "node --test test/*.test.mjs"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"oidc-client-ts": "3.5.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"esbuild": "0.28.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
function base64Url(bytes) {
|
|
||||||
let binary = "";
|
|
||||||
for (const byte of bytes) {
|
|
||||||
binary += String.fromCharCode(byte);
|
|
||||||
}
|
|
||||||
return btoa(binary)
|
|
||||||
.replaceAll("+", "-")
|
|
||||||
.replaceAll("/", "_")
|
|
||||||
.replaceAll("=", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createAccountLinkUrl({
|
|
||||||
keycloakBaseUrl,
|
|
||||||
realm,
|
|
||||||
provider,
|
|
||||||
clientId,
|
|
||||||
redirectUri,
|
|
||||||
sessionState,
|
|
||||||
issuedFor,
|
|
||||||
nonce = crypto.randomUUID(),
|
|
||||||
cryptoApi = crypto,
|
|
||||||
}) {
|
|
||||||
const material = `${nonce}${sessionState}${issuedFor}${provider}`;
|
|
||||||
const digest = await cryptoApi.subtle.digest(
|
|
||||||
"SHA-256",
|
|
||||||
new TextEncoder().encode(material),
|
|
||||||
);
|
|
||||||
const url = new URL(
|
|
||||||
`${keycloakBaseUrl}/realms/${realm}/broker/${provider}/link`,
|
|
||||||
);
|
|
||||||
url.search = new URLSearchParams({
|
|
||||||
nonce,
|
|
||||||
hash: base64Url(new Uint8Array(digest)),
|
|
||||||
client_id: clientId,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
});
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import {
|
|
||||||
InMemoryWebStorage,
|
|
||||||
UserManager,
|
|
||||||
WebStorageStateStore,
|
|
||||||
} from "oidc-client-ts";
|
|
||||||
|
|
||||||
import { createPkcePair } from "./pkce.js";
|
|
||||||
|
|
||||||
const authority = "http://localhost:8080/realms/keycloak-patterns";
|
|
||||||
const backendBaseUrl = "http://localhost:8081";
|
|
||||||
|
|
||||||
const userManager = new UserManager({
|
|
||||||
authority,
|
|
||||||
client_id: "spa-public",
|
|
||||||
redirect_uri: "http://localhost:8088/callback.html",
|
|
||||||
post_logout_redirect_uri: "http://localhost:8088/",
|
|
||||||
response_type: "code",
|
|
||||||
scope: "openid profile email",
|
|
||||||
automaticSilentRenew: true,
|
|
||||||
monitorSession: false,
|
|
||||||
loadUserInfo: false,
|
|
||||||
userStore: new WebStorageStateStore({
|
|
||||||
store: new InMemoryWebStorage(),
|
|
||||||
}),
|
|
||||||
stateStore: new WebStorageStateStore({
|
|
||||||
store: window.sessionStorage,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const loginButton = document.querySelector("#login");
|
|
||||||
const logoutButton = document.querySelector("#logout");
|
|
||||||
const apiButton = document.querySelector("#call-api");
|
|
||||||
const pkceButton = document.querySelector("#pkce-demo");
|
|
||||||
const sessionState = document.querySelector("#session-state");
|
|
||||||
const result = document.querySelector("#result");
|
|
||||||
|
|
||||||
let currentUser = null;
|
|
||||||
|
|
||||||
function renderJson(value) {
|
|
||||||
result.textContent = JSON.stringify(value, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenMetadata(user) {
|
|
||||||
return {
|
|
||||||
subject: user.profile.sub,
|
|
||||||
username: user.profile.preferred_username,
|
|
||||||
expiresAt: new Date(user.expires_at * 1000).toISOString(),
|
|
||||||
accessTokenHeldBy: "browser memory",
|
|
||||||
refreshTokenHeldBy: user.refresh_token ? "browser memory" : "not issued",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSession(user) {
|
|
||||||
currentUser = user;
|
|
||||||
const authenticated = Boolean(user && !user.expired);
|
|
||||||
sessionState.dataset.authenticated = String(authenticated);
|
|
||||||
sessionState.textContent = authenticated
|
|
||||||
? `${user.profile.preferred_username} 로그인됨 · token은 메모리에만 보관`
|
|
||||||
: "로그인되지 않음 · 브라우저 저장소에 token 없음";
|
|
||||||
loginButton.disabled = authenticated;
|
|
||||||
logoutButton.disabled = !authenticated;
|
|
||||||
apiButton.disabled = !authenticated;
|
|
||||||
|
|
||||||
if (authenticated) {
|
|
||||||
renderJson(tokenMetadata(user));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function finishSigninCallback() {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const isCallback = window.location.pathname === "/callback.html"
|
|
||||||
&& (params.has("code") || params.has("error"));
|
|
||||||
|
|
||||||
if (!isCallback) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await userManager.signinRedirectCallback();
|
|
||||||
window.history.replaceState({}, document.title, "/");
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callProtectedApi() {
|
|
||||||
if (!currentUser || currentUser.expired) {
|
|
||||||
throw new Error("로그인이 필요합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${backendBaseUrl}/api/me`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${currentUser.access_token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const body = await response.json();
|
|
||||||
renderJson({
|
|
||||||
httpStatus: response.status,
|
|
||||||
resourceServerResponse: body,
|
|
||||||
tokenBoundary: tokenMetadata(currentUser),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`보호 API가 ${response.status}를 반환했습니다.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loginButton.addEventListener("click", () => userManager.signinRedirect());
|
|
||||||
logoutButton.addEventListener("click", () => userManager.signoutRedirect());
|
|
||||||
apiButton.addEventListener("click", () => {
|
|
||||||
callProtectedApi().catch((error) => renderJson({ error: error.message }));
|
|
||||||
});
|
|
||||||
pkceButton.addEventListener("click", () => {
|
|
||||||
createPkcePair()
|
|
||||||
.then(({ verifier, challenge, method }) => renderJson({
|
|
||||||
method,
|
|
||||||
verifierLength: verifier.length,
|
|
||||||
challengeLength: challenge.length,
|
|
||||||
note: "실제 로그인에서는 oidc-client-ts가 같은 S256 단계를 수행합니다.",
|
|
||||||
}))
|
|
||||||
.catch((error) => renderJson({ error: error.message }));
|
|
||||||
});
|
|
||||||
|
|
||||||
userManager.events.addUserLoaded(renderSession);
|
|
||||||
userManager.events.addUserUnloaded(() => renderSession(null));
|
|
||||||
userManager.events.addAccessTokenExpired(() => renderSession(null));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const callbackUser = await finishSigninCallback();
|
|
||||||
renderSession(callbackUser ?? await userManager.getUser());
|
|
||||||
} catch (error) {
|
|
||||||
renderSession(null);
|
|
||||||
renderJson({ error: error.message });
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
function base64Url(bytes) {
|
|
||||||
let binary = "";
|
|
||||||
for (const byte of bytes) {
|
|
||||||
binary += String.fromCharCode(byte);
|
|
||||||
}
|
|
||||||
return btoa(binary)
|
|
||||||
.replaceAll("+", "-")
|
|
||||||
.replaceAll("/", "_")
|
|
||||||
.replace(/=+$/u, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPkcePair(cryptoApi = globalThis.crypto) {
|
|
||||||
const verifierBytes = new Uint8Array(32);
|
|
||||||
cryptoApi.getRandomValues(verifierBytes);
|
|
||||||
const verifier = base64Url(verifierBytes);
|
|
||||||
const digest = await cryptoApi.subtle.digest(
|
|
||||||
"SHA-256",
|
|
||||||
new TextEncoder().encode(verifier),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
verifier,
|
|
||||||
challenge: base64Url(new Uint8Array(digest)),
|
|
||||||
method: "S256",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
const { createAccountLinkUrl } = await import("../src/account-linking.js");
|
|
||||||
|
|
||||||
test("creates a signed client-initiated account-link URL", async () => {
|
|
||||||
const url = await createAccountLinkUrl({
|
|
||||||
keycloakBaseUrl: "https://auth.example.test",
|
|
||||||
realm: "keycloak-patterns",
|
|
||||||
provider: "google",
|
|
||||||
clientId: "spa-public",
|
|
||||||
redirectUri: "https://app.example.test/settings/identity",
|
|
||||||
sessionState: "session-state",
|
|
||||||
issuedFor: "spa-public",
|
|
||||||
nonce: "fixed-nonce",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
url.pathname,
|
|
||||||
"/realms/keycloak-patterns/broker/google/link",
|
|
||||||
);
|
|
||||||
assert.equal(url.searchParams.get("client_id"), "spa-public");
|
|
||||||
assert.equal(url.searchParams.get("nonce"), "fixed-nonce");
|
|
||||||
assert.match(url.searchParams.get("hash"), /^[A-Za-z0-9_-]{43}$/u);
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { webcrypto } from "node:crypto";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
globalThis.btoa = (value) => Buffer.from(value, "binary").toString("base64");
|
|
||||||
|
|
||||||
const { createPkcePair } = await import("../src/pkce.js");
|
|
||||||
|
|
||||||
test("manual PKCE helper creates an RFC 7636 S256 pair", async () => {
|
|
||||||
const pair = await createPkcePair(webcrypto);
|
|
||||||
|
|
||||||
assert.equal(pair.method, "S256");
|
|
||||||
assert.equal(pair.verifier.length, 43);
|
|
||||||
assert.equal(pair.challenge.length, 43);
|
|
||||||
assert.match(pair.verifier, /^[A-Za-z0-9_-]+$/u);
|
|
||||||
assert.match(pair.challenge, /^[A-Za-z0-9_-]+$/u);
|
|
||||||
assert.notEqual(pair.verifier, pair.challenge);
|
|
||||||
});
|
|
||||||
@@ -55,22 +55,7 @@
|
|||||||
"attributes": {
|
"attributes": {
|
||||||
"pkce.code.challenge.method": "S256",
|
"pkce.code.challenge.method": "S256",
|
||||||
"post.logout.redirect.uris": "http://localhost:8088/*##http://127.0.0.1:8088/*"
|
"post.logout.redirect.uris": "http://localhost:8088/*##http://127.0.0.1:8088/*"
|
||||||
},
|
|
||||||
"protocolMappers": [
|
|
||||||
{
|
|
||||||
"name": "keycloak-pattern-api-audience",
|
|
||||||
"protocol": "openid-connect",
|
|
||||||
"protocolMapper": "oidc-audience-mapper",
|
|
||||||
"consentRequired": false,
|
|
||||||
"config": {
|
|
||||||
"included.custom.audience": "keycloak-pattern-api",
|
|
||||||
"id.token.claim": "false",
|
|
||||||
"access.token.claim": "true",
|
|
||||||
"userinfo.token.claim": "false",
|
|
||||||
"introspection.token.claim": "true"
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"clientId": "token-mediating-confidential",
|
"clientId": "token-mediating-confidential",
|
||||||
|
|||||||
@@ -70,18 +70,6 @@ def validate(path: Path, runtime: bool) -> None:
|
|||||||
spa.get("attributes", {}).get("pkce.code.challenge.method") == "S256",
|
spa.get("attributes", {}).get("pkce.code.challenge.method") == "S256",
|
||||||
"spa-public must enforce PKCE S256",
|
"spa-public must enforce PKCE S256",
|
||||||
)
|
)
|
||||||
audience_mappers = [
|
|
||||||
mapper
|
|
||||||
for mapper in spa.get("protocolMappers", [])
|
|
||||||
if mapper.get("protocolMapper") == "oidc-audience-mapper"
|
|
||||||
]
|
|
||||||
if not runtime:
|
|
||||||
require(len(audience_mappers) == 1, "spa-public must declare one audience mapper")
|
|
||||||
require(
|
|
||||||
audience_mappers[0].get("config", {}).get("included.custom.audience")
|
|
||||||
== "keycloak-pattern-api",
|
|
||||||
"spa-public access token must target keycloak-pattern-api",
|
|
||||||
)
|
|
||||||
|
|
||||||
for client_id, placeholder in CONFIDENTIAL_CLIENTS.items():
|
for client_id, placeholder in CONFIDENTIAL_CLIENTS.items():
|
||||||
client = clients[client_id]
|
client = clients[client_id]
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
./scripts/set-first-broker-login-mode.sh secure
|
|
||||||
npm --prefix e2e ci
|
|
||||||
MOCK_GOOGLE_USER_PASSWORD="$MOCK_GOOGLE_USER_PASSWORD" \
|
|
||||||
npm --prefix e2e run test:federation-zero-change
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
./scripts/verify-google-broker-config.sh
|
|
||||||
./scripts/verify-federation-spa-zero-change.sh
|
|
||||||
echo "AP1 Google-federated profile verified"
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
jq -e '
|
|
||||||
(.clients[] | select(.clientId == "spa-public")
|
|
||||||
| .publicClient == true
|
|
||||||
and .attributes["pkce.code.challenge.method"] == "S256")
|
|
||||||
and
|
|
||||||
([.users[].username] | index("regular-user") != null)
|
|
||||||
' keycloak/import/keycloak-patterns-realm.json >/dev/null
|
|
||||||
|
|
||||||
npm --prefix frontend test
|
|
||||||
echo "AP1 local-identity profile verified without a Google dependency"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "missing .env; copy .env.example and set local-only values" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
docker compose down --volumes --remove-orphans
|
|
||||||
docker compose up --build -d --wait
|
|
||||||
docker compose --profile diagnostics up -d --wait \
|
|
||||||
app-wrong-audience \
|
|
||||||
app-wrong-issuer
|
|
||||||
|
|
||||||
npm --prefix e2e ci
|
|
||||||
E2E_USERNAME=regular-user \
|
|
||||||
E2E_PASSWORD="$REGULAR_USER_PASSWORD" \
|
|
||||||
WRONG_AUDIENCE_URL=http://localhost:18081/api/me \
|
|
||||||
WRONG_ISSUER_URL=http://localhost:18082/api/me \
|
|
||||||
npm --prefix e2e run test:pattern1
|
|
||||||
|
|
||||||
echo "AP1 verified end to end"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
npm --prefix frontend ci
|
|
||||||
npm --prefix frontend test
|
|
||||||
|
|
||||||
jq -e '
|
|
||||||
.clients[]
|
|
||||||
| select(.clientId == "spa-public")
|
|
||||||
| .publicClient == true
|
|
||||||
and .standardFlowEnabled == true
|
|
||||||
and .implicitFlowEnabled == false
|
|
||||||
and .directAccessGrantsEnabled == false
|
|
||||||
and .attributes["pkce.code.challenge.method"] == "S256"
|
|
||||||
' keycloak/import/keycloak-patterns-realm.json >/dev/null
|
|
||||||
|
|
||||||
rg -q 'response_type: "code"' frontend/src/app.js
|
|
||||||
rg -q 'new InMemoryWebStorage' frontend/src/app.js
|
|
||||||
rg -Fq 'cryptoApi.subtle.digest(' frontend/src/pkce.js
|
|
||||||
rg -Fq '"SHA-256"' frontend/src/pkce.js
|
|
||||||
echo "PKCE stages verified: verifier -> S256 challenge -> code -> verifier exchange"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
jq -e '
|
|
||||||
.revokeRefreshToken == true
|
|
||||||
and .refreshTokenMaxReuse == 0
|
|
||||||
and .accessTokenLifespan <= 300
|
|
||||||
' keycloak/import/keycloak-patterns-realm.json >/dev/null
|
|
||||||
|
|
||||||
rg -Fq 'assert.notEqual(rotated.refresh_token, secondTokenSet.refresh_token)' \
|
|
||||||
e2e/pattern1.mjs
|
|
||||||
rg -Fq 'a consumed refresh token must not be accepted again' e2e/pattern1.mjs
|
|
||||||
rg -Fq 'refresh revoke is not an immediate deny-list' e2e/pattern1.mjs
|
|
||||||
|
|
||||||
echo "Refresh rotation/reuse/revocation contract verified"
|
|
||||||
echo "Run ./scripts/verify-pattern1.sh for the destructive live-token E2E"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
./scripts/verify-public-tunnel-config.sh
|
|
||||||
./scripts/verify-reverse-proxy-headers.sh
|
|
||||||
./scripts/verify-google-redirect-uri-policy.sh
|
|
||||||
./scripts/verify-google-broker-config.sh
|
|
||||||
echo "Single-EC2 Google federation deployment profile verified"
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
rendered="$(docker compose config --format json)"
|
|
||||||
printf '%s\n' "$rendered" | jq -e '
|
|
||||||
(.services.keycloak.ports[] | .host_ip == "127.0.0.1" and .target == 8080)
|
|
||||||
and
|
|
||||||
(.services.app.ports[] | .host_ip == "127.0.0.1" and .target == 8081)
|
|
||||||
and
|
|
||||||
(.networks["keycloak-net"].driver == "bridge")
|
|
||||||
' >/dev/null
|
|
||||||
|
|
||||||
./scripts/verify-internal-spa-no-google-contract.sh
|
|
||||||
echo "Single-EC2 local-identity topology verified"
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
docker compose down --volumes --remove-orphans
|
|
||||||
docker compose up --build -d --wait
|
|
||||||
npm --prefix e2e ci
|
|
||||||
REGULAR_USER_PASSWORD="$REGULAR_USER_PASSWORD" \
|
|
||||||
ADMIN_USER_PASSWORD="$ADMIN_USER_PASSWORD" \
|
|
||||||
npm --prefix e2e run test:role-mapping
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
base="${KEYCLOAK_URL:-http://localhost:8080}"
|
|
||||||
main_issuer="$base/realms/keycloak-patterns"
|
|
||||||
mock_issuer="$base/realms/mock-google"
|
|
||||||
|
|
||||||
test "$(curl -fsS "$main_issuer/.well-known/openid-configuration" | jq -r .issuer)" \
|
|
||||||
= "$main_issuer"
|
|
||||||
test "$(curl -fsS "$mock_issuer/.well-known/openid-configuration" | jq -r .issuer)" \
|
|
||||||
= "$mock_issuer"
|
|
||||||
|
|
||||||
rg -Fq 'SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:' docker-compose.yml
|
|
||||||
if rg -qi 'accounts\\.google\\.com|realms/mock-google' backend/src/main; then
|
|
||||||
echo "backend must trust only the Keycloak issuer, not the upstream IdP" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Three-leg trust anchors verified: upstream -> Keycloak -> Resource Server"
|
|
||||||
Reference in New Issue
Block a user