WEBBOOK CHAPTER

Spring Security 6, 인증에서 운영까지: 40장. 출간·출시 전 최종 판정표

40장. 출간·출시 전 최종 판정표

이 책을 읽은 뒤 다음을 설명하고 실행할 수 있어야 한다.

  • 인증과 인가, URL과 service·data 경계를 구분한다.
  • 세션 cookie와 CSRF, CORS의 책임을 설명한다.
  • issuer, audience, 시간, signature를 검증하는 JWT resource server를 구성한다.
  • OIDC 계정 연결과 패스키 step-up의 복구 경로까지 설계한다.
  • 다른 tenant 관리자와 CSRF 누락을 부정 테스트로 고정한다.
  • 비밀을 로그·저장소·이미지에 남기지 않고 감사 결정은 재현한다.
  • key 회전, credential stuffing, tenant 유출 사고를 runbook으로 훈련한다.

상업 출간 전에는 Spring Security 실무자, 애플리케이션 보안 담당자, 기술 교정자, 법무·상표 담당자의 독립 감수를 받는다. 예제 테스트 통과는 production 인증이나 무취약점 보증이 아니다. 독자는 자신의 규제, IDP, reverse proxy, session store, threat model에 맞춰 다시 검증해야 한다.

저작권과 상표

본문, ContractHub 인물·계약 fixture, SVG, 실습 UI, 테스트는 이 프로젝트를 위해 새로 작성했다. 공식 문서는 사실 확인과 추가 학습을 위해 링크하고 장문을 복제하지 않았다. Spring, Spring Boot, Spring Security, Java, OAuth, OpenID Connect, WebAuthn 등 명칭과 표장은 각 권리자의 것일 수 있으며, 이 책은 해당 프로젝트나 회사의 승인·제휴를 암시하지 않는다. 공식 로고와 실제 공급자 화면을 출고 자산에 포함하지 않는다.

공식 자료 확인표

마지막 질문은 “로그인되었나?”가 아니다. “이 요청이 이 자원에 이 작업을 해도 되는 이유를 같은 입력으로 재현하고, 거부와 사고까지 운영할 수 있는가?”다. 그 답이 코드, 테스트, 감사 영수증, runbook에 모두 있으면 보안은 기능을 넘어 시스템이 된다.

부록 A. 세션 웹과 JWT API를 두 체인으로 분리하는 설계

하나의 애플리케이션이 관리자 웹과 외부 API를 함께 제공한다면 인증 방식과 공격 방어를 체인별로 명확히 나눈다. API가 먼저 좁은 matcher로 선택되고, 웹 체인이 나머지를 맡는다. 아래 코드는 구조를 설명하는 출발점이다. 실제 프로젝트에서는 entry point, CORS, audience validator, scope converter와 endpoint 목록을 보강한다.


@Bean
@Order(1)
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
  http
    .securityMatcher("/api/external/**")
    .authorizeHttpRequests(auth -> auth
      .requestMatchers(HttpMethod.GET, "/api/external/contracts/**")
        .hasAuthority("SCOPE_contract.read")
      .anyRequest().authenticated())
    .sessionManagement(session -> session
      .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
    .csrf(csrf -> csrf.disable())
    .oauth2ResourceServer(oauth -> oauth.jwt(withDefaults()));
  return http.build();
}

@Bean
@Order(2)
SecurityFilterChain webSecurity(HttpSecurity http) throws Exception {
  http
    .authorizeHttpRequests(auth -> auth
      .requestMatchers("/", "/assets/**", "/error").permitAll()
      .anyRequest().authenticated())
    .formLogin(withDefaults())
    .csrf(withDefaults());
  return http.build();
}

여기서 API의 CSRF를 끈 이유는 “API라서”가 아니라 해당 체인이 브라우저 자동 전송 cookie를 인증에 쓰지 않고 Authorization header의 Bearer token만 받도록 계약했기 때문이다. 같은 경로에서 refresh cookie, form login, webhook 관리 화면을 섞으면 전제가 깨진다. 다음 부정 테스트를 반드시 둔다.


/api/external + cookie only         -> 401
/api/external + wrong audience JWT -> 401
/api/external + read scope GET     -> success
/api/external + read scope POST    -> 403
/admin + session + no CSRF POST    -> 403
/admin + session + CSRF POST       -> policy result

두 체인이 공통으로 쓸 것은 password가 아니라 감사 형식과 도메인 인가다. JWT의 sub와 세션 사용자를 내부 Actor로 변환하고 ContractAuthorization에는 인증 방식에 독립적인 식별·tenant·authority·authentication strength를 전달한다.

부록 B. 권한 매트릭스를 테스트 데이터로 바꾸는 법

회의에서 만든 표를 문서로만 남기면 코드와 어긋난다. 행을 fixture로 바꿔 parameterized test에 넣는다.

사용자 소유 tenant 역할 계약 상태 작업 step-up 예상
익명 IN_REVIEW read 401
alice ACME REVIEWER IN_REVIEW read 200
alice ACME REVIEWER IN_REVIEW approve 403
bob ACME MANAGER IN_REVIEW approve 불필요 200
bob ACME MANAGER DRAFT approve 불필요 403
bob ACME MANAGER IN_REVIEW·고액 approve 없음 403
bob ACME MANAGER IN_REVIEW·고액 approve 완료 200
mallory OTHER ADMIN IN_REVIEW read 403

record Case(String user, String tenant, String role, String state,
            String action, boolean stepUp, boolean allowed) {}

static Stream<Case> authorizationCases() {
  return Stream.of(
    new Case("alice", "ACME", "REVIEWER", "IN_REVIEW", "read", false, true),
    new Case("alice", "ACME", "REVIEWER", "IN_REVIEW", "approve", false, false),
    new Case("mallory", "OTHER", "ADMIN", "IN_REVIEW", "read", false, false)
  );
}

새 역할을 추가하면 “허용 행”만 넣지 않는다. 기존 자원에 새 역할이 가져서는 안 되는 작업을 모두 넣는다. action이 추가되면 모든 역할의 기본값이 거부인지 확인한다. 매트릭스의 소유자는 제품 책임자와 보안 책임자가 공동으로 맡고, 변경 요청에는 위험 설명과 만료 가능한 임시 권한을 구분한다.

부록 C. 증상에서 시작하는 보안 트러블슈팅

로그인 뒤 계속 로그인 화면으로 돌아온다. 브라우저 Network에서 callback 응답의 Set-Cookie, 다음 요청의 Cookie, cookie domain/path/SameSite/Secure, 서버 시간, session store write/read, load balancer stickiness를 순서대로 본다. OIDC state mismatch라면 여러 callback pod의 session 공유와 proxy scheme을 확인한다. token이나 cookie 원문을 티켓에 붙이지 않는다.

브라우저만 403이고 curl은 성공한다. 상태 변경 요청이라면 CSRF header와 cookie, 프런트의 token 획득, CORS preflight, credentials 옵션을 분리한다. AccessDeniedHandler log의 reason category를 decision ID로 찾는다. 문제를 감추려고 CSRF를 끄지 않는다.

OPTIONS가 401이다. CORS가 Security보다 먼저 처리되는지, CorsConfigurationSource가 bean인지, origin·method·header가 정확히 허용됐는지 본다. preflight 성공과 실제 요청 인증 성공은 별도 조건이다.

JWT는 decode되는데 401이다. decode 가능은 서명 검증 성공이 아니다. issuer, audience, exp/nbf, clock, 알고리즘, kid, JWK fetch를 본다. 공급자 console의 access token 대상 API와 현재 API audience가 같은지 확인한다.

ROLE_ADMIN인데 method가 403이다. 실제 authority 문자열의 prefix, JWT converter, @EnableMethodSecurity, 정책 bean 이름, tenant membership, proxy self-invocation을 본다. 권한을 넓혀 해결하기 전 실패 검사 이름을 확인한다.

한 사용자만 반복 로그아웃된다. 동시 세션 제한, session registry, role 변경에 따른 session revoke, Redis TTL, 동일 username의 중복 계정, device cookie를 본다. 공격 가능성을 고려해 보안 이벤트와 고객 지원 기록을 연결한다.

배포 후 redirect URI가 http다. ingress가 전달한 proto/host, 애플리케이션 forwarded header 전략, 신뢰 proxy allowlist, CDN에서 덮어쓴 header를 확인한다. 사용자 입력 X-Forwarded-*를 직접 신뢰하지 않는다.

권한 변경이 즉시 반영되지 않는다. 세션에 저장된 authorities, JWT TTL, entitlement cache, method result cache의 key를 본다. 고위험 강등은 session/token version과 cache invalidation을 함께 수행한다.

부록 D. 보안 변경 요청서와 리뷰 질문


SECURITY-CHANGE-____
목표:
보호 자산과 위협:
변경 endpoint / method / data query:
새로 허용되는 주체·자원·작업:
새로 거부되는 경우:
인증 강도와 세션/token 영향:
개인정보·감사 이벤트 변화:
부정 테스트:
배포 관측 지표:
rollback 방법과 데이터 호환성:
임시 예외의 owner·만료일:
검토자:

리뷰어는 “Spring 문법이 맞는가” 외에 다음을 묻는다. 신규 endpoint가 default deny를 통과하는가? role 검사만 있고 tenant/resource 검사가 빠졌는가? repository query와 cache key에 tenant가 있는가? 실패 응답이 계정·자원 존재를 노출하는가? token·cookie·개인정보가 로그에 추가되는가? 테스트가 허용과 거부를 함께 증명하는가? key나 secret rotation과 롤백이 가능한가?

긴급 patch도 이 질문을 생략하지 않는다. 다만 변경 범위를 최소화하고 동시 기록자를 붙인다. 사고가 안정되면 임시 WAF/feature flag/권한을 제거하거나 정식 통제로 전환한다.

부록 E. 팀이 같은 뜻으로 써야 할 용어

  • 인증(Authentication): 제시된 자격 증명을 확인해 현재 주체를 확립하는 일.
  • 인가(Authorization): 그 주체가 특정 자원에 특정 작업을 할 수 있는지 결정하는 일.
  • principal: 인증된 주체를 식별하는 값 또는 객체.
  • authority: role, scope처럼 주체에 부여된 고수준 권한 표현.
  • session fixation: 공격자가 아는 세션 ID를 피해자가 로그인 뒤 계속 쓰게 만드는 공격.
  • CSRF: 브라우저가 자동 전송하는 자격 증명을 이용해 다른 사이트가 의도하지 않은 요청을 보내는 공격.
  • CORS: 다른 origin 응답을 브라우저 JavaScript에 노출할지 정하는 정책. 서버 인증이 아니다.
  • OIDC: OAuth 2.0 위에서 최종 사용자 인증 정보를 전달하는 identity layer.
  • Resource Server: access token을 검증하고 보호 API를 제공하는 서버.
  • JWT: claim을 담고 서명할 수 있는 compact token 형식. 기본적으로 암호화된 비밀 상자가 아니다.
  • issuer: token을 발급한 주체를 나타내는 claim.
  • audience: token이 사용되도록 발급된 대상을 나타내는 claim.
  • JWK/JWKS: JWT 서명 검증 등에 사용하는 공개 키 표현과 그 집합.
  • passkey/WebAuthn: 공개키 자격 증명과 origin binding을 사용한 phishing 저항 인증 방식.
  • step-up: 현재 세션보다 더 강하거나 최근의 인증을 고위험 작업 직전에 요구하는 과정.
  • tenant: 하나의 서비스 인스턴스 안에서 데이터와 권한을 격리해야 하는 고객 조직 단위.
  • decision receipt: 인가 입력, 정책 버전, 결과와 이유를 비밀 없이 재현할 수 있는 기록.

용어를 통일하면 “로그인은 됐으니 권한 문제는 아니다”, “CORS로 외부 호출을 막았다”, “JWT라서 폐기할 수 있다” 같은 위험한 오해를 줄일 수 있다.

팀 회고에서는 용어 정의를 암기시키지 말고 실제 최근 요청 하나를 골라 principal, credential, authority, tenant, resource, decision receipt를 표시한다. 표시하지 못한 값은 관측이나 설계의 빈칸이다. 그 빈칸을 다음 작은 개선 작업으로 등록한다.