IoC, DI, AOP

스프링의 핵심원리는 여러 가지 기술을 이용하여 복잡한 요소들을 스프링 프레임워크에 위임하고, 개발자는 비즈니스 로직 개발에만 집중할 수 있게 된다. 그중 가장 유명한 것은 IoC(제어의 역전), DI(의존성 주입), AOP(관점 지향 프로그래밍) 이 있다.

Bean

Bean은 컴포넌트 스캔(자동 등록 @Component)수동으로 등록(@Configuration 에 직접 빈 등록)할 수 있는, Spring IoC 컨테이너가 관리하는 객체들을 말한다.

 

Spring이 ApplicaionContext를 띄우면 Bean으로 등록된 클래스들을 스캔하고, 빈 객체들을 직접 new 해서 메모리에 올린다.

그 이후로, 의존성 주입(DI)까지 완료하고, 초기화 작업(PostConstruct)까지 완료한다.

Bean Lifecycle (빈의 생명주기)

생성 → 주입 → 초기화 → 사용 → 소멸

생성 Spring이 new로 객체 생성
의존성 주입 필요한 다른 Bean들을 주입
초기화 @PostConstruct, InitializingBean.afterPropertiesSet() 호출
사용 실제 애플리케이션에서 사용
소멸 @PreDestroy, DisposableBean.destroy() 호출

 


IoC, Inversion of Control 이란,

사용할 객체를 개발자가 직접 생성하지 않고, 객체의 생명주기를 외부(스프링 컨테이너)에게 위임하는 것을 말한다.

이때 객체의 관리를 맡기는 스프링 컨테이너를 `Spring Container` 또는 `IoC Container` 라고 한다.

 

이런 제어의 역전을 통해서 DI 와 AOP 가 가능해진다.


DI, Dependency Injection 이란,

`의존성을 주입한다` 는 것은 제어의 역전(IoC)의 방법 중에 하나로, 사용할 객체를 직접 생성하지 않고 외부 컨테이너가 생성한 객체를 주입받아 사용하는 것을 말한다.

 

따라서 스프링에서는 직접 객체를 제어하지 않고 제어권을 스프링에게로 넘긴다.

 

의존성을 주입하는 방법에는 세가지가 있다.

  • 생성자를 통한 의존성 주입
  • 필드 객체 선언을 통한 의존성 주입
  • setter를 통한 의존성 주입

그러나 가장 좋은 의존성 주입 방식으로 추천되는 것은 `생성자를 통한 의존성 주입` 방식이다.

@Autowired 나 final 이 붙은 필드 생성자를 추적하는 @RequiredArgsConstructor 를 사용하면 편리하다.

(참고로 @Autowired 는 어노테이션이고, @RequiredArgsConstructor 는 Lombok 라이브러리에서 제공한다.)


AOP, Aspect-Oriented Programming 이란,

관점 지향 프로그래밍은 관점을 기준으로 묶어서 프로그래밍 하는 방식을 말한다.

어떤 기능을 볼때, 그 기능의 핵심인

비즈니스 로직(핵심 관심사(Core Concern))

부가 로직(횡단 관심사(Cross-cutting Concern))

구분해서 보는 것이다.

 

바로 AOP가 해주는 것이 패키지별로 클래스별로 공통된 부가 로직을 부여할 수 있다는 것이다.

부가 로직이라 함은, 로깅이나 트랜잭션 처리, 권한 체크 등을 말한다.

프록시의 문제점

AOP가 스프링에서 프록시처럼 앞뒤로 감싸서 동작을하는데,

프록시 객체는 외부에서 메서드 호출할 때만 가로챌 수 있다.

내부 메서드 호출은 가로채지 못한다!

@Component
public class MyService {

    @Transactional
    public void outer() {
        inner(); // 내부 메서드 호출
    }

    @Transactional
    public void inner() {
        // 트랜잭션이 적용돼야 함
    }
}
  • 여기서 outer()는 프록시를 통해 가로채지만,
  • inner()는 프록시를 거치지 않고 직접 호출되기 때문에 AOP 적용이 안 된다!

Spring AOP vs AspectJ

Spring AOP 프록시 기반, 런타임에 메서드 호출을 가로챔
AspectJ 바이트코드 조작 기반, 컴파일/클래스 로딩 시점에 코드에 AOP 삽입

JDK Proxy vs CGLIB

JDK Proxy 인터페이스 기반 동적 프록시
CGLIB 구체 클래스 기반 상속 프록시

 

Spring AOP는 프록시 기반으로 런타임에 부가기능을 삽입한다.
인터페이스가 있으면 JDK Proxy, 구체 클래스만 있으면 CGLIB을 사용해서 프록시를 생성한다.


AspectJ는 컴파일/클래스 로딩 시점에 코드 자체를 수정하는 방식이라,
더 강력하지만 세팅이 복잡해서 실무에서는 주로 Spring AOP를 사용한다.

0. 서론

최근 Spring Boot 3.x + Spring Security 6.x로 프로젝트를 시작하면서 기존에 잘 사용하던 `.requestMatchers("/login", "/api/**")` 구문이 갑자기 다음과 같은 에러를 발생시키기 시작했다!

This method cannot decide whether these patterns are Spring MVC patterns or not.
If this endpoint is a Spring MVC endpoint, please use requestMatchers(MvcRequestMatcher); otherwise, please use requestMatchers(AntPathRequestMatcher).

This is because there is more than one mappable servlet in your servlet context:
{org.h2.server.web.JakartaWebServlet=[/h2-console/*], org.springframework.web.servlet.DispatcherServlet=[/]}.

 

해석해보자면...

 

SpringMVC 패턴인지 아닌지 알수없다.

만약 Spring MVC 엔드포인트라면 MvcRequestMatcher를 사용하라. 아니면, AntPathRequesMatcher를 사용해주세요.

 

서블릿 컨텍스트 안에 하나의 서블릿보다 더 많은 매칭가능한 서블릿이 존재합니다:

JakartaWebServlet 과 DispatcherServlet.

 

1. SpringSecurity6.x 버전부터는 requestMatcher를 명시해주어야 한다.

URL 경로는 이제 MVC 기반인지, 서블릿 기반인지 정확히 구분해서 써줘!
- SpringSecurity팀

 

SpringSecurity5.x까지도 그냥 requestMatchers("/", "/login") 이런 식으로 막 사용해도 작동했다. 그이유는 SpringSecurity가 알아서 `아~ MVC 패턴 경로겠지` 하고 추측해서 매칭해주었기 때문이다.

 

SpringSecurity5.x 시절,

http
  .authorizeHttpRequests()
  .requestMatchers("/login", "/user/**").permitAll()

 

이렇게만 작성해줘도,

  • 내부에서 AntPathRequestMatcher를 자동으로 생성했다.
  • 경로가 Spring MVC 컨트롤러의 매핑이든 정적 리소스든 크게 구분하지 않고 느슨히 모두 허용했다.

 

2. 그런데 왜 이제와서 문제가 되었을까?

 

예를들어, SpringSecurity 서비스에서 h2-console 데이터베이스를 사용한다고 했을때,

http
  .authorizeHttpRequests()
  .requestMatchers("/h2-console/**").permitAll()

 

스프링 시큐리티는 헷갈리게 된다.

“이 요청이 DispatcherServlet을 타는 거야?
아니면 H2 콘솔 서블릿으로 바로 가는 거야?”

 

이때, DispatcherServlet은 Spring MVC의 핸들러 매핑(컨트롤러)으로 연결되는 서블릿이고,
H2 콘솔 서블릿은 Spring과 무관하게 직접 HTML UI를 서블릿에서 처리하는 완전 별개 서블릿이다.

 

DispatcherServlet의 경우,

@GetMapping("/chat")
public String chatPage() {
    return "chat";
}

 

DispatcherServlet은 요청이 들어오면, 어떤 @Controller 메서드에 보낼지 결정하는 역할을 한다.
위 코드와 같은 Controller 의 요청을 받아 처리한다. 즉, Spring MVC 의 핵심 서블릿이다.

 

처리 흐름은 다음과 같다.

요청 → DispatcherServlet → HandlerMapping → @Controller

 

 

H2 콘솔 서블릿의 경우,

자체적으로 html 기반 웹 ui를 띄워주는 Spring의 외부 라이브러리이다,

Spring MVC 구조와 전혀 무관하다. 왜냐하면 Controller도, ViewResolver도 사용하지 않는다.

 

처리 흐름은 다음과 같다.

요청 → org.h2.server.web.WebServlet → 내부 HTML 처리

 

3. 그러니까, 헷갈리는 건 알겠는데, 5.x버전에서는 문제가 없었잖아.

실은 보안상 구멍이 존재했다. 스프링 시큐리티팀은 이 구멍을 발견하고 6.x버전부터 업데이트했다.

 

스프링5.x에서는, 모두 AntPathRequestMatcher로 처리되었다.

// 이런 requestMatchers 가 등록되면,
.requestMatchers("/chat", "/h2-console/**")

// 내부에서는 아래와 같이 변환함
new AntPathRequestMatcher("/chat")
new AntPathRequestMatcher("/h2-console/**")

 

  • 이 matcher는 단순히 URL 경로만 보고 매칭했다.
  • 어떤 서블릿이 받는지는 아예 고려하지 않았다.
  • 그래서 DispatcherServlet이든 H2든 그냥 URL만 일치하면 통과시킨것이다 !!!

 

AntPathRequestMatcher 의 경우,

단순히 요청 URI 가 매칭되면 전부 다 ! 배칭된다. 해당 경로에 컨트롤러가 존재하든 말든!!!

그러니까, 존재하지 않는 경로도 인증 필터 체인을 타게되는 것이다.

 

예를들어, 현재 내 서비스에 /api/v1/users 라는 getMapping 만 지정해놓았다고 해보자.

requestMatchers 에다가 /api/** 이렇게 등록을 해놓으면,

/api/v1/users 라는 uri 뿐만이 아니라, /api/foo/bar 와 같이 전혀 등록하지 않은 의미없는 경로에 대해서도 인증 필터를 타게 되는 것이다!

 

실은 탈필요없다, 왜냐하면 만들어놓은 컨트롤러가 아니니까!!

 

그러나 MvcRequestMatcher 의 경우,

Spring MVC 에 실제 매핑된 컨트롤러의 경로와 일치해야만 매칭이 된다.

심지어, PathVariable 이 존재하는 컨트롤러 경로까지 추가할 수 있다.

없는 경로는 인증 필터 자체가 타지 않는다~!

 

이제 /api/foo/bar 와 같이 존재하지 않는 컨트롤러 경로에 대해서는 인증 필터가 반응하지 않게 될 것이다.

 

 

4. SpringSecurity 팀이 6.1버전부터 서블릿 종류를 명시하도록 한 이유.

 

  • 실제 존재하는 컨트롤러에만 보안 필터를 적용하도록
  • 보안 설정 범위와 실제 API 경로가 100% 일치하는 구조로 유지하도록
  • 필터에서 토큰 검증 등 무거운 처리를 피하도록

 

예시

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                   HandlerMappingIntrospector introspector) throws Exception {
        // MvcRequestMatcher builder 생성 + DispatcherServlet 경로 명시
        MvcRequestMatcher.Builder mvc = new MvcRequestMatcher.Builder(introspector)
                .servletPath("/"); // Spring MVC 기준으로 필터링

        http
            .csrf().disable()
            .headers().frameOptions().disable() // H2 콘솔 띄우기 위함
            .authorizeHttpRequests(auth -> auth

                // Spring MVC 컨트롤러 경로 - MvcRequestMatcher
                .requestMatchers(mvc.pattern("/chat")).permitAll()
                .requestMatchers(mvc.pattern("/api/v1/users")).authenticated()

                // 정적 자원이나 외부 서블릿 경로 - AntPathRequestMatcher
                .requestMatchers(new AntPathRequestMatcher("/h2-console/**")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/css/**")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/js/**")).permitAll()

                // 그 외는 인증 필요
                .anyRequest().authenticated()
            );

        return http.build();
    }

    @Bean
    public HandlerMappingIntrospector handlerMappingIntrospector() {
        return new HandlerMappingIntrospector();
    }
}

 

 

 

결론.

MvcRequestMatcher: Spring MVC 핸들러(@Controller, @RestController)에 매핑된 경로를 보호한다.

AntPathRequestMatcher: 정적 리소스, 외부 서블릿(H2 등) 경로를 허용한다.

 

혼합해서 매칭 방식을 달리 사용해야한다.

경로마다 처리 대상이 다르기 때문이다.

0. 준비작업

이전 포스팅

 

1편 - IdP 구성

https://jinn-o.tistory.com/321

 

[SAML 2.0] IdP 와 SP 인증을 직접 구현해보자. (1) - IdP 설정

0. 준비작업먼저 SAML 2.0 의 구조나 개념, 동작방식에 대해서 예습해오자.https://jinn-o.tistory.com/320 [SAML 2.0] Security Assertion Markup LanguageSAML 2.0 이란?OAuth 2.0 과 마찬가지로 인증/인가와 관련된 규칙들

jinn-o.tistory.com

 

2편 - SP 구성 : SpringBoot편

https://jinn-o.tistory.com/322

 

[SAML 2.0] IdP 와 SP 인증을 직접 구현해보자. (2) - SP 설정 : SpringBoot 편

0. 준비작업이전 포스팅https://jinn-o.tistory.com/321 [SAML 2.0] IdP 와 SP 인증을 직접 구현해보자. (1) - IdP 설정먼저 SAML 2.0 의 구조나 개념, 동작방식에 대해서 예습해오자.https://jinn-o.tistory.com/320 [SAML 2.0

jinn-o.tistory.com

 

 

먼저 SAML 2.0 의 구조나 개념, 동작방식에 대해서 예습해오자.

https://jinn-o.tistory.com/320

 

[SAML 2.0] Security Assertion Markup Language

SAML 2.0 이란?OAuth 2.0 과 마찬가지로 인증/인가와 관련된 규칙들을 묶어서 표준화한 방식이다.다만 이상한 점이 있다. MarkUp Language 라고 정의되어 있다. 먼저, OAuth 2.0 은 주로 인가(Authorization)와 관

jinn-o.tistory.com

 

해당 포스팅에서 SP-React를 구현해 놓은 코드는 다음 Github 에 올려놓았다.

 

https://github.com/SMJin/React-SAML

 

GitHub - SMJin/React-SAML: React + Spring Boot 를 활용하여 SAML 2.0 방식의 SP *(Service Provider) 구축

React + Spring Boot 를 활용하여 SAML 2.0 방식의 SP *(Service Provider) 구축 - SMJin/React-SAML

github.com

 

 

 

 

본격적으로 시작해보자.

1. 사전정보

Keycloak 서버 http://localhost:8081
SpringBoot 서버 http://localhost:8080
React 서버 http://localhost:3000

 

 

2. React Project 만들기

npx create-react-app saml-auth-app
cd ./saml-auth-app
npm start // 서버실행

 

webvitals 에러가 뜨면 깔끔하게 해당 코드 태그와 js 파일을 지워준다.

 

 

3. 로그인 버튼 컴포넌트 생성

const LoginButton = () => {
    const handleLogin = () => {
      window.location.href = "http://localhost:8080/saml/login"; // Spring Boot의 로그인 API 호출
    };
  
    return <button onClick={handleLogin}>Login with SAML</button>;
  };
  
  export default LoginButton;

 

로그인 컴포넌트에 로그인 버튼은 Spring Boot 의 SAML 로그인 API 를 호출하도록 연결해둔다.

 

 

3. 사용자 프로필 보여줄 컴포넌트 생성

import { useEffect, useState } from "react";

const UserProfile = () => {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch("http://localhost:8080/saml/user", {
      credentials: "include", // 세션 정보 유지
    })
      .then((res) => res.json())
      .then((data) => setUser(data))
      .catch((err) => console.error(err));
  }, []);

  return (
    <div>
      {user ? (
        <div>
          <h2>Welcome, {user.username}!</h2>
          <p>Email: {user.email}</p>
        </div>
      ) : (
        <p>Not logged in</p>
      )}
    </div>
  );
};

export default UserProfile;

 

사용자 프로필을 보여주는 화면에서는,

세션 서버에서 사용자 정보를 가져오는 SpringBoot API 를 연결해둔다.

또한, 세션 정보가 유지되어야 하므로, credentials: include 를 헤더에 추가해주는 것을 잊지말자!

 

 

4. 메인 App.js 에 생성한 컴포넌트들을 추가해준다.

import './App.css';
import LoginButton from './components/LoginButton';
import UserProfile from './components/UserProfile';

function App() {
  return (
    <div className="App">
      <h1>SAML Authentication with Keycloak</h1>
      <LoginButton />
      <UserProfile />
    </div>
  );
}

export default App;

 

 

5. 와 .. ~ 로그인 연동 성공

이 문제 때문에 며칠을 애먹었는지 모른다.

 

아무튼 결론부터 말하겠다.

 

Spring MVC는 3xx 응답(특히 304)에는 CORS 헤더를 자동으로 추가하지 않는다 ...

 

아무리 내가 다음과 같이 지정했다고 해도 말이다.

package spring.saml.sp.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@EnableWebMvc
public class CorsConfig {

    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/**")
                        .allowedOrigins("*")
                        .allowedMethods("*")
                        .allowedHeaders("*")
                        .allowCredentials(true);
            }
        };
    }
}

 

 

이유는 다음과 같다.

304 Not Modified 응답은 브라우저 캐시를 활용할 때 발생한다.

이때 Spring MVC는 기존에 캐시된 응답을 반환할 뿐, CORS 헤더(Access-Control-Allow-*)를 완전히 제외시킨다.

 

 

왜그럴까?

 

 

 

왜그랬어요?

 

아무리 성능 최적화때문에라도 그렇지 cors 헤더는 추가해줬어야 하는거아니에요?

난 분명 WebMvcConfigurer 필터에 cors 적용했는데 몇날며칠을 계속 cors 오류 뜨니까 그쪽이 나한테 사과해야되는거아니에요?

 

 

네..

 

304 응답에서 최소한의 헤더만 유지하고 불필요한 헤더를 제거하는 작업이 최우선되어서 그렇답니다..

 

Spring MVC는 304만 차별을 한다..

그래서 Browser 가 304 응답을 받을 때 Access-Control-Allow-Origin 이 없기 때문에

그 응답에 대해서만 자꾸만 CORS 에러가 뜨는 것이다..

 

아니근데 그거는 추가했어야되지않았을가요? 네 ?

캐싱이된건지안된건지 비교는해봐야되지않은것인가하는저의생각입니다.

 

참고로 HTTP 웹 캐싱에 대해서 더 알고싶다면

https://jinn-o.tistory.com/351

 

[HTTP] 웹 캐싱(Web Caching)

HTTP 웹 캐싱이란?HTTP 웹 캐싱은 서버가 중복적인 요청을 했을 때 상태값이나 식별이 가능한 유용한 데이터를 저장하여, 또 다시 동일한 데이터의 통신을 하지 않게끔 유도하여, 웹 성능을 최적화

jinn-o.tistory.com

 

 

 

해결방법

1. Spring Security 에서 cors 설정하기 (Spring Security 를 사용한다면 가장 추천)

얘는 차별안한대요...

 

2. Spring MVC 의 초기 cors 설정을 버리고 커스텀 해준다.. 다음과 같이 말이다. (추천)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;

import java.util.List;

@Configuration
public class CorsConfig {

    @Bean
    public FilterRegistrationBean<CorsFilter> corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.setAllowedOrigins(List.of("*"));  // 모든 도메인 허용
        config.setAllowedHeaders(List.of("*"));
        config.setAllowedMethods(List.of("*"));  // 모든 HTTP 메서드 허용
        source.registerCorsConfiguration("/**", config);

        FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<>(new CorsFilter(source));
        bean.setOrder(0); // 가장 먼저 실행되도록 우선순위 설정
        return bean;
    }
}

 

 

3. Spring MVC 를 버린다.. 공식 서블릿API 에서 제공하는 Filter 를 사용하자 (비추)

package spring.saml.sp.config;
import java.io.IOException;

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;

@Component
public class DisableCorsFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;

        // CORS 허용 설정
        res.setHeader("Access-Control-Allow-Origin", "http://localhost:8081");
        res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
        res.setHeader("Access-Control-Allow-Headers", "*");
        res.setHeader("Access-Control-Allow-Credentials", "true");

        // OPTIONS 요청 자동 처리
        if ("OPTIONS".equalsIgnoreCase(req.getMethod())) {
            res.setStatus(HttpServletResponse.SC_OK);
            return;
        }

        chain.doFilter(request, response);
    }
}

0. SAML 2.0 이란?

Security Assertion Markup Language 의 2.0 버전이라는 의미로, 인증/인가 프로토콜 중 하나이다.

https://jinn-o.tistory.com/320

 

[SAML 2.0] Security Assertion Markup Language

SAML 2.0 이란?OAuth 2.0 과 마찬가지로 인증/인가와 관련된 규칙들을 묶어서 표준화한 방식이다.다만 이상한 점이 있다. MarkUp Language 라고 정의되어 있다. 먼저, OAuth 2.0 은 주로 인가(Authorization)와 관

jinn-o.tistory.com

 

1. IdP? SP?

IdP 는 Indentity Provider 라는 의미로, SSO 인증서버를 의미한다.

SP 는 Service Provider 라는 의미로, 해당 SSO 인증서버를 사용하는 서비스 애플리케이션을 의미한다.

 

2. SP-Initiated SSO 방식?

SAML 인증 프로토콜에서 일반적으로 사용하는 방식이다. 보통 SAML 방식을 사용해서 인증한다. 라고하면 죄다 이 방식을 의미하는 것이다. 

 

작동 방식은 다음과 같다.

① SP 가 IdP 에게 SAML Request (XML 파일)를 보낸다. 이때, RelayState 에 redirect_uri 를 첨가하여 보낸다.

② IdP 는 인증을 처리한 이후에, RelayState 에 redirect_uri 를 전달받았던 그대로 넣어서 SAML Response (XML 파일)를 SP 에게 보낸다.

③ 브라우저는 전달받은 redirect_uri 로 인증 완료 화면으로 리다이렉트한다.

SP -> IdP: SAML Request 전송

GET https://keycloak.example.com/auth/realms/{realm}/protocol/saml
    ?SAMLRequest=base64_encoded_saml_request
    &RelayState=https://app.example.com/dashboard

이때, SAMLRequest 는 metadata.xml 파일을 base64 로 인코딩한 값이다.

RelayState 에 인증 성공 후 redirect 할 url 을 첨부하여 보낸다.

 

 

IdP -> SP: SAML Response 전송

<form method="POST" action="https://app.example.com/saml/acs">
    <input type="hidden" name="SAMLResponse" value="base64_encoded_saml_response">
    <input type="hidden" name="RelayState" value="https://app.example.com/dashboard">
</form>

IdP 에서 사용자를 인증하고, 인증에 성공했다면 SP 의 ACS URL 로 SAML Response 를 HTTP POST 에 담아서 전송한다. 이때 <form> 태그가 응답(Response Body) 자체에 포함되어 브라우저가 자동으로 실행되도록 설정된다.

즉, 브라우저가 IdP(Keycloak)에서 SP로 리디렉트될 때, 응답 자체가 form 태그로 구성되어 있으며, 자동으로 제출되도록 처리된다.

 

 

SP 가 SAML Response 를 검증하고, 사용자를 RelayState의 redirect_uri로 리다이렉트한다.

HTTP 302 Found
Location: https://app.example.com/dashboard

 

 

 

3. IdP-Initiated SSO 방식?

보통은 이 방식을 사용하지 않고, SP-Initiated SSO 방식을 사용해서 SAML 인증 방식을 구현한다.

 

그렇다면 이 방식은 어떤 것이며, 언제 쓰이는가?

 

보통은 SP가 먼저 SAML Request 를 보내야 비로소 IdP가 그에 대한 SAML Response 를 보내는 것이 맞다.

그러나 이 방식은 SP 없이, IdP 자체에서 SAML 인증을 처리해보는 방식이다.

따라서 특수한 상황이나, 테스트용으로 자주 사용된다.

 

사용자가 Keycloak(IdP)에서 직접 로그인 요청을 보냄.

GET https://keycloak.example.com/auth/realms/{realm}/protocol/saml/idp-initiated/my-sp

 

 

IdP(Keycloak)가 SP의 ACS URL로 SAMLResponse 전송

<form method="POST" action="https://app.example.com/saml/acs">
    <input type="hidden" name="SAMLResponse" value="base64_encoded_saml_response">
    <input type="hidden" name="RelayState" value="https://app.example.com/dashboard">
</form>
 

 

SP가 SAMLResponse를 검증하고, 사용자를 RelayState 값에 설정된 페이지로 리디렉션

HTTP 302 Found
Location: https://app.example.com/dashboard
 
 


4. 결론

📌  즉, SP-Initiated SSO에서는 SP가 먼저 SAMLRequest를 보내고,

IdP가 SAMLResponse를 보내며, 최종적으로 브라우저가 redirect_uri로 이동하는 방식!

 

📌 즉, IdP-Initiated SSO에서는 SP가 먼저 요청을 보내지 않고,

Keycloak(IdP)이 SAMLResponse를 생성해서 SP로 전송하는 방식!

 

두 방식 모두, 사용자에 대한 정보는 Base64 로 인코딩된 SAML Request 혹은 SAML Response 에 담겨있다. 이것을 디코딩해서 사용자를 식별하면 된다.

ServletRegistrationBean 이란?

이름에도 나와있다시피, Servlet(서블릿) Registration(등록) Bean(빈 객체)이다.

먼저 간단하게 설명하자면, Spring 에서 서블릿을 등록하고 특정 URL 로 매핑하기 위한 도구이다.

 

그런데 이제, 서블릿을 `커스텀(Custom)` 하게 개발자 입맛대로 추가할 수 있도록 도와주는 도구이다.

 

Spring 에서 Servlet 을 원래 어떻게 다루고 있나? (기본 설정에 대하여)

1. @SpringBootApplication 어노테이션이 달린 메인 실행 클래스에서, SpringApplication.run() 을 기동한다.

2. 이 과정에서 ServletContext 가 만들어진다.

3. 또한 DispatcherServlet 을 등록하여, 모든 요청을 처리하도록 URL 을 "/" 로 매핑해준다.

@Bean
public DispatcherServlet dispatcherServlet() {
    return new DispatcherServlet();
}

@Bean
public ServletRegistrationBean<DispatcherServlet> dispatcherServletRegistration(DispatcherServlet dispatcherServlet) {
    return new ServletRegistrationBean<>(dispatcherServlet, "/");
}

 

DispatcherServlet 을 자동 등록하는 코드를 보면 있지 않은가?

바로 ~ `ServletRegistrationBean` 이라는 객체에 dispatcherServlet 이 묶여있는 것을.

 

 

 

 

그 전에 조금의 개념 정리부터.

 

ServletContext 는 서블릿의 환경 관리자이다.

애플리케이션의 전역 정보나, 서블릿 간 데이터를 공유하는 일 등을 도맡고 있다.

 

DispatcherServlet 은 서블릿의 길을 찾아주는 지도이다.

SpringMVC 를 구성하는 가장 핵심 요소인 중앙 컨트롤러이다. 스프링에 존재하는 모든 컨트롤러들이 길을 잘 찾아서 요청을 보내고 응답을 받을 수 있도록 도와주는 역할을 한다.

 

 

자자,, 이제 다시 돌아와서

ServletRegistrationBean 으로 `커스텀` 서블릿 처리를 등록하는 방법.

@Configuration
public class MyServletConfig {

    @Bean
    public ServletRegistrationBean<HttpServlet> myServlet() {
        HttpServlet myCustomServlet = new HttpServlet() {
            @Override
            protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
                resp.getWriter().write("Hello from My Custom Servlet!");
            }
        };

        ServletRegistrationBean<HttpServlet> registrationBean = new ServletRegistrationBean<>(myCustomServlet, "/custom/*");
        registrationBean.setName("myCustomServlet");
        registrationBean.setLoadOnStartup(1); // 초기화 우선순위 설정
        return registrationBean;
    }
}

 

 

  • 먼저 @Configuration 애노테이션이 달린 설정 클래스를 생성한다.
  • ServletRegistrationBean<?> 객체를 반환하는 메소드를 생성하고, Bean 으로 등록한다.
  • ? 에 들어가는 객체는 Servlet 을 반드시 상속받은 객체여야 한다.
  • 또한 Servlet 을 상속받은 객체는 doGet(..) 혹은 doPost(..) 등의 요청 방식에 대해서 메소드로 정의해야 한다.
  • ServletRegistrationBean 객체를 반환하는 메소드안에서는 이제, 연결할 mapping URL 정보초기 설정 parameter 들 등을 설정한 ServletRegistrationBean 객체를 반환한다.

 

위 예시에서는 익명 클래스를 이용하여 바로 구현했지만, 만약 Servlet 클래스를 생성했다면,

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    resp.getWriter().write("Handled GET request");
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    resp.getWriter().write("Handled POST request");
}

 

위와 같이 각 요청방식에 해당하는 구현 방식을 정의해야 할 것이다.

 

 

질문 1. url 은 하나만 지정할 수 있는가?

아니다. url 배열로 지정이 가능하다

 

질문 2. 그렇다면 같은 Servlet 객체를 반환할때 여러 url 을 지정했어도 다른 동작 방식을 주고 싶다면?

@Bean
public ServletRegistrationBean<HttpServlet> multiBehaviorServlet() {
    HttpServlet servlet = new HttpServlet() {
        @Override
        protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
            String path = req.getRequestURI();
            if (path.contains("/path1")) {
                resp.getWriter().write("Handled by Path 1");
            } else if (path.contains("/path2")) {
                resp.getWriter().write("Handled by Path 2");
            } else {
                resp.getWriter().write("Handled by Default Path");
            }
        }
    };

    ServletRegistrationBean<HttpServlet> registrationBean = new ServletRegistrationBean<>(servlet, "/path1/*", "/path2/*", "/default/*");
    return registrationBean;
}

 

이런 방식으로 Servlet 객체를 조정하면 된다.

 

 

질문 3. 하나의 url 에 get 방식 post 방식 둘다 정의되는 것인가?

맞다.

SecurityContextHolder

Security 보안 Context 맥락 Holder 보유자 라는 의미로, spring-security-core 의존성 패키지에 포함되어 있는 클래스이다. Spring Security 내에서 현재 인증 정보를 저장하고 관리하는 데에 큰 역할이 있다. 특히, Spring Security 의 인증 상태인, Authentication 객체를 전역적으로 저장하며, JWT 인증을 설정할 때 Authentication 객체를 적용한다.

// SecurityContextHolder 에서 Authentication 설정하기
SecurityContextHolder.getContext().setAuthentication(authentication);

 

설정하는 메소드에서도 알 수 있다시피, SecurityContextHolder 안에는, SecurityContext 가 있고(getContext()로 불러오므로), 또 그 안에 Authentication 객체가 있다.

 

SecurityContextHolder > SecurityContext > Authentication

 

이런 포함관계를 가지는 것이다.

 

Authentication 객체

현재 사용자의 인증 상태를 나타내며, JWT 검증 후 생성된 Authentication 객체를 SecurityContext 에 설정하여 인증 상태를 유지하는 객체이다. UsernamePasswordAuthenticationToken 같은 구현체를 사용하여 생성할 수 있다.

Authentication authentication = new UsernamePasswordAuthenticationToken(
    username, // Principal
    null, // Credentials (JWT에서는 필요 없음)
    authorities // GrantedAuthority 리스트
);

 

  • Principal: 현재 사용자를 식별하는 정보 (주로 UserDetails 객체 또는 사용자 이름).
  • Credentials: 인증 정보 (예: 비밀번호, 인증 토큰).
  • Authorities: 사용자 권한(역할)의 리스트 (GrantedAuthority 객체로 구성).
  • Details: 추가적인 인증 정보 (예: IP 주소, 세션 정보).
  • Authenticated: 인증 여부(Boolean).

 

 

Authorities 리스트

이 권한 리스트는, 특정 사용자에게 여러 권한을 줄 수도 있기 때문에 한 사용자에게 리스트로 권한이 주어지는 것이다. Spring Security 에서 권한은 GrantedAuthority 인터페이스로 표현되며, 일반적으로 ROLE_ 접두어를 붙이고 표현된다. (ex. ROLE_USER, ROLE_ADMIN)

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication.getAuthorities().stream()
    .anyMatch(authority -> authority.getAuthority().equals("ROLE_ADMIN"))) {
    System.out.println("사용자가 관리자 역할을 가지고 있습니다.");
}

0. 준비작업

이전 포스팅

https://jinn-o.tistory.com/321

 

[SAML 2.0] IdP 와 SP 인증을 직접 구현해보자. (1) - IdP 설정

먼저 SAML 2.0 의 구조나 개념, 동작방식에 대해서 예습해오자.https://jinn-o.tistory.com/320 [SAML 2.0] Security Assertion Markup LanguageSAML 2.0 이란?OAuth 2.0 과 마찬가지로 인증/인가와 관련된 규칙들을 묶어서 표

jinn-o.tistory.com

 

먼저 SAML 2.0 의 구조나 개념, 동작방식에 대해서 예습해오자.

https://jinn-o.tistory.com/320

 

[SAML 2.0] Security Assertion Markup Language

SAML 2.0 이란?OAuth 2.0 과 마찬가지로 인증/인가와 관련된 규칙들을 묶어서 표준화한 방식이다.다만 이상한 점이 있다. MarkUp Language 라고 정의되어 있다. 먼저, OAuth 2.0 은 주로 인가(Authorization)와 관

jinn-o.tistory.com

 

해당 포스팅에서 SP-SpringBoot를 구현해 놓은 코드는 다음 Github 에 올려놓았다.

 

(시행착오 - 오류남)

https://github.com/SMJin/Security-SAML

 

GitHub - SMJin/Security-SAML: Spring Security SAML 2.0

Spring Security SAML 2.0 . Contribute to SMJin/Security-SAML development by creating an account on GitHub.

github.com

 

(추가 - 성공)

https://github.com/SMJin/SpringBoot-SAML

 

GitHub - SMJin/SpringBoot-SAML: Spring Boot 를 활용하여 SAML 2.0 방식의 SP *(Service Provider) 구축

Spring Boot 를 활용하여 SAML 2.0 방식의 SP *(Service Provider) 구축 - SMJin/SpringBoot-SAML

github.com

 

 

 

본격적으로 시작해보자.

1. Gradle Dependency 추가

implementation 'org.springframework.boot:spring-boot-starter-security' // Spring Security
implementation 'org.springframework.security:spring-security-saml2-service-provider' // SAML 2.0

 

 

2. Security 테스트

 

비밀번호는 실행 콘솔 창에 나와있다. 이 비밀번호는 실행할때마다 달라진다.

 

다음과 같이 오류 페이지가 뜨면 잘 된것이다. 왜냐하면 아무 컨트롤러도 만들어주지 않았기 때문.

Spring Security library 가 잘 implements 되었다.

 

 

3. spring-security-saml2-service-provider 라이브러리 오류

버전에 맞는 opensaml-saml-api 라이브러리가 없다는 오류가 발생했다.

 

하지만 maven repository 에 가보니 opensaml-saml-api:4.3.2 버전은 없었다.

 

 

그렇다면 spring-security-saml2-service-provider 라이브러리의 버전을 낮춰서 implements 하자.

implementation 'org.springframework.security:spring-security-saml2-service-provider:5.8.12' // SAML 2.0

 

 

4. IdP 엔지니어에게 받은 metadata.xml 파일을 다운로드한다.

metadata.xml 파일을 /src/main/resources/ 하위에 저장한다.

 

그전에 metadata.xml 파일에서 수정해야할 부분이 있다.

entityID를 keycloak Client ID 로 지정한 값으로 맞춰주어야 한다.

<EntityDescriptor entityID="http://localhost:8080/spring-saml-sp">

 

그리고 해당 경로를 application.yml 에 명시해줘야 한다.

spring:
  security:
    saml2:
      relyingparty:
        registration:
          spring-saml-sp:
            identityprovider:
              metadata-location: classpath:/saml/idp-metadata.xml # 메타데이터.xml 경로
            assertion-consumer-service-location: http://localhost:8080/login/saml2/sso/spring-saml-sp # Assertion (SAML Response) 받을 경로 (ACS URL)

 

 

5. 임시 유저를 생성하자.

 

비밀번호도 설정완료!

 

 

6. IDP-Initiated SSO 로 테스트해보자.

[형식]
http://<Keycloak-Host>/realms/<Realm-Name>/protocol/saml/clients/<Client-ID>

[예시]
http://localhost:8081/realms/identity-provider/protocol/saml/clients/spring-saml-sp

 

 

 

7. 문제발생. 왜 ClientId 가 "null" 로 받아와질까? SP 에서 설정 다시 검토.

① Client Id 확인해보기

 

keycloack 에 등록된 Client Id: spring-saml-sp

 

 

metadata.xml 에 지정한 entidyID 도 맞고 ...

 

 

application.yml 에서도 clientID를 잘 지정했는데 !

 

 

.. 어디서 Client ID 를 잘못 지정한거지?

라고 생각했는데 문제는 keycloak 에서 있었다.

 

설정을 이렇게하면 안된다! name 이니까 ClientID 를 지정해놓아야 한다!!!!!

 

 

 

1편에 작성해놓았던 설정파일을 수정해야겠다.. 총총

 

8. 다시 IDP-Initiated SSO 로 테스트해보자.

 

접속성공!

 

 

keycloak 에서 미리 작성하지 못했던 필요한 정보를 묻는다. 착실히 작성해준다.

 

SP (Spring) 페이지로 리다이렉트....... 

 

..성공?

 

9. SecurityConfig 클래스 작성해주기

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .saml2Login(Customizer.withDefaults())
                .authorizeHttpRequests(auth -> auth
                        .anyRequest().authenticated()
                )
                .build();
    }

}

 

Spring Security 에게 SAML 2.0 으로 로그인할 것이라고 명시해주어야 한다.

 

오류 발생!!

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration': Unsatisfied dependency expressed through method 'setFilterChains' parameter 0: Error creating bean with name 'securityFilterChain' defined in class path resource [spring/sso/common/config/SecurityConfig.class]: Failed to instantiate [org.springframework.security.web.SecurityFilterChain]: Factory method 'securityFilterChain' threw exception with message: org/springframework/security/saml2/provider/service/web/OpenSamlAuthenticationTokenConverter

 

뭔가 SAML 클래스 OpenSamlAuthenticationTokenConverter가 없다고 하니 라이브러리를 추가해주자.

아니면 버전이 안맞나?

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.security:spring-security-saml2-service-provider:6.1.2'
	implementation 'org.opensaml:opensaml-saml-api:4.0.1' // 추가
	implementation 'org.opensaml:opensaml-saml-impl:4.0.1' // 추가
	implementation 'org.opensaml:opensaml-core:4.0.1' // 추가
}

 

 

 

그러나 ...........................

치명적인 현재 상황 발생....

현재 상황 요약

  1. Spring Security 6.x 이상은 OpenSAML 4.3.x 이상을 요구
  2. Maven Central에는 OpenSAML 4.0.1까지만 제공되고, OpenSAML 4.3.x는 공식적으로 아직 릴리스되지 않았다....
  3. Spring Boot 3.x 이상 환경에서는 Spring Security 6.x와 호환성을 유지해야 하지만, OpenSAML의 부재로 인해 일부 기능에서 제약이 발생하고 있다 !!!!!!!.

 

결론.

현재 Spring Security 를 이용해서 SAML2.0 을 구현하는 건 포기해야 된다. ㅠㅠ

 

 

10. RequestURL 생성 로직부터.. 다시 해보자 ^^

다음 url 에는 RequestURL 명세에 대해서 정말 잘 나와있다.

https://developers.worksmobile.com/kr/docs/sso-idp-request

 

NAVER WORKS Developers

 

developers.worksmobile.com

 

Controller

@GetMapping("/sso/login")
public RedirectView redirectToIdP() {
    String samlRequest = samlApiService.createSamlRequest();
    String redirectUrl = samlApiService.getRedirectUrl(samlRequest);
    return new RedirectView(redirectUrl);
}

 

Service

package spring.saml.sp.service;

import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.StringWriter;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.UUID;

@Service
public class SamlApiService {

    public String getRedirectUrl(String samlRequest) {
        String redirectUrl = "http://localhost:8081/realms/identity-provider/protocol/saml/clients/spring-saml-sp"
                + "?SAMLRequest=" + URLEncoder.encode(samlRequest, StandardCharsets.UTF_8)
                + "&RelayState=" + URLEncoder.encode("/dashboard", StandardCharsets.UTF_8);

        System.out.println("Redirect to: " + redirectUrl);
        return redirectUrl;
    }

    public String createSamlRequest() {
        try {
            Document authnRequestDoc = getAuthnRequest();

            TransformerFactory transformerFactory = TransformerFactory.newInstance();
            Transformer transformer = transformerFactory.newTransformer();
            DOMSource source = new DOMSource(authnRequestDoc);
            StringWriter writer = new StringWriter();
            transformer.transform(source, new StreamResult(writer));

            return Base64.getEncoder().encodeToString(writer.toString().getBytes(StandardCharsets.UTF_8));
        } catch (Exception e) {
            throw new RuntimeException("Failed to create SAML request - AuthnRequest", e);
        }
    }

    private Document getAuthnRequest() {
        try {
            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            factory.setNamespaceAware(true);
            DocumentBuilder builder = factory.newDocumentBuilder();

            Document document = builder.newDocument();
            Element authnRequest = document.createElementNS("urn:oasis:names:tc:SAML:2.0:protocol", "samlp:AuthnRequest");
            authnRequest.setAttribute("AssertionConsumerServiceURL", "http://localhost:8080/saml/consume");
            authnRequest.setAttribute("ID", "_" + UUID.randomUUID());
            authnRequest.setAttribute("IssueInstant", Instant.now().toString());
            authnRequest.setAttribute("ProtocolBinding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST");
            authnRequest.setAttribute("ProviderName", "spring-saml-sp");
            authnRequest.setAttribute("Version", "2.0");

            Element issuerElement = document.createElementNS("urn:oasis:names:tc:SAML:2.0:assertion", "saml:Issuer");
            issuerElement.setTextContent("http://localhost:8080/saml");
            authnRequest.appendChild(issuerElement);

            Element nameIDPolicyElement = document.createElementNS("urn:oasis:names:tc:SAML:2.0:protocol", "samlp:NameIDPolicy");
            nameIDPolicyElement.setAttribute("Format", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified");
            authnRequest.appendChild(nameIDPolicyElement);

            document.appendChild(authnRequest);
            return document;
        } catch (Exception e) {
            throw new RuntimeException("Failed to create SAML request - AuthnRequest", e);
        }
    }
}

 

 

11. CORS 오류를 해결하자

그런데 왜인지 cors 오류가 났다. 일단 결론만 말하자면

Spring MVC 에서 기본적으로 제공하는 cors 필터만으로는 부족하다.

cors 필터를 커스텀해야 한다.

package spring.saml.sp.config;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

import java.util.List;

@Configuration
public class CorsConfig {

    @Bean
    public FilterRegistrationBean<CorsFilter> corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true); // 쿠키와 세션 허용
        config.setAllowedOrigins(List.of("http://localhost:8081", "http://localhost:3000"));
        config.setAllowedOriginPatterns(List.of("null")); // 명시적으로 null 허용
        config.setAllowedHeaders(List.of("*"));
        config.setAllowedMethods(List.of("GET", "POST", "OPTIONS"));
        source.registerCorsConfiguration("/**", config);

        FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<>(new CorsFilter(source));
        bean.setOrder(0); // 가장 먼저 실행되도록 우선순위 설정
        return bean;
    }
}

 

오류에 대한 자세한 내용은 다음을 참고해보자.

https://jinn-o.tistory.com/352

 

[Spring MVC] WebMvcConfigurer로 cross-origin 설정을 해도 cors 오류가 나는 이유 (304 응답에서)

이 문제 때문에 며칠을 애먹었는지 모른다. 아무튼 결론부터 말하겠다. Spring MVC는 3xx 응답(특히 304)에는 CORS 헤더를 자동으로 추가하지 않는다 ... 아무리 내가 다음과 같이 지정했다고 해도 말

jinn-o.tistory.com

 

 

12. http://localhost:8080/saml/login 으로 이동하자. (SAML Request 전송)

 

13. acs url 에 SAML Response 가 잘담겨있다

 

 

 

14. 다음으로

React 까지 연결해보자.

https://jinn-o.tistory.com/353

 

[SAML 2.0] IdP 와 SP 인증을 직접 구현해보자. (3) - SP 설정 : React편

0. 준비작업이전 포스팅 1편 - IdP 구성https://jinn-o.tistory.com/321 [SAML 2.0] IdP 와 SP 인증을 직접 구현해보자. (1) - IdP 설정0. 준비작업먼저 SAML 2.0 의 구조나 개념, 동작방식에 대해서 예습해오자.https:/

jinn-o.tistory.com

 

0. 준비작업

먼저 SAML 2.0 의 구조나 개념, 동작방식에 대해서 예습해오자.

https://jinn-o.tistory.com/320

 

[SAML 2.0] Security Assertion Markup Language

SAML 2.0 이란?OAuth 2.0 과 마찬가지로 인증/인가와 관련된 규칙들을 묶어서 표준화한 방식이다.다만 이상한 점이 있다. MarkUp Language 라고 정의되어 있다. 먼저, OAuth 2.0 은 주로 인가(Authorization)와 관

jinn-o.tistory.com

 

해당 포스팅에서 SP를 구현해 놓은 코드는 다음 Github 에 올려놓았다.

https://github.com/SMJin/Security-SAML

 

GitHub - SMJin/Security-SAML: Spring Security SAML 2.0

Spring Security SAML 2.0 . Contribute to SMJin/Security-SAML development by creating an account on GitHub.

github.com

 

 

 

본격적으로 시작해보자.

1. Keycloak 을 이용해서 Identity Provider 를 생성하자.

★ SAML 2.0 에서 IdP(IdentityProvider) 란?

https://jinn-o.tistory.com/320

 

[SAML 2.0] Security Assertion Markup Language

SAML 2.0 이란?OAuth 2.0 과 마찬가지로 인증/인가와 관련된 규칙들을 묶어서 표준화한 방식이다.다만 이상한 점이 있다. MarkUp Language 라고 정의되어 있다. 먼저, OAuth 2.0 은 주로 인가(Authorization)와 관

jinn-o.tistory.com

 

먼저 Keycloak 을 다운받아야 한다.

https://www.keycloak.org/downloads

 

downloads - Keycloak

Keycloak is a Cloud Native Computing Foundation incubation project © Keycloak Authors 2024. © 2024 The Linux Foundation. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Founda

www.keycloak.org

 

★ Keycloak 이란?

Keycloak은 오픈소스이며, 인증 및 권한 관리에 관한 기능을 제공한다. 주로 SAML, OpenID Connect, OAuth2를 지원하여 SSO(Single Sign-On)를 구현할 수 있도록 한다. 쉽게 말해, 인증과 권한관리를 담당하는 서버이다.

 

2. Keycloak 실행하기

keycloak 이 설치되어있는 파일 공간으로 이동하여 다음 명령어를 실행한다.

참고로 keycloak 의 실행 파일은 /bin 파일 내부에 존재한다.

./bin/kc.sh start-dev --http-port 8081

 

kc.sh 이 쉘파일이 실행 파일이며, start-dev 가 실행 명령어이고, http-port 옵션은 8081 포트에서 실행되도록 지정한 것이다. 포트를 지정하지 않으면 8080 포트에서 default 실행된다. 8080 포트는 현재 SP(Service Provider)인 나의 서비스가 자리할 것이기 때문에 8081 로 지정하였다.

 

기본 설정된 Admin 의 username 은 admin 이다.

다음 명령어로 admin 을 추가할 수 있다.

./bin/kc.sh add-user --username new_admin --password new_password

 

로그인하면 Keycloak 대시보드가 등장한다.

 

 

3. IdP 생성하기

 

Realm 생성

이렇게 생성한 영역들은 각 서비스별로 인증서버를 분리하여 생성할 수 있다.

(이는 멀티테넌트 환경에서 필수적인 역할을 한다.)

 

Client 생성

이때 지정하는 클라이언트 이름인 클라이언트 ID(client-id)는 Spring Security SAML2 설정에서 추후에 registration-id 로 사용된다. 따라서 이때 지정한 클라이언트 ID 로 SP(Service Provider)를 고유하게 식별하게 되기 때문에, 중요한 요소이다. 추후에 사용되니 기억해놓자. (*참고. registration-id 는 Spring Security SAML2 를 도입했을 때 사용되는, SP 를 고유하게 식별하는 이름이다.)

 

Client Type 은 SAML 로 지정하자. 우리가 사용할 인증방식이기 때문이다.

다시한번 강조하지만, Client ID 로 SP 를 식별한다.

  • Root URL: 클라이언트의 기본 URL.
    - SP 의 메인 도메인 정보이자, 메타데이터를 찾기위한 경로이다.
    - 이것을 지정하면 상대 경로로 지정이 가능하다.
    (위에 지정한 것처럼 다른 url 들에서 기본 url 없이 path 만 지정하여 상대경로 지정이 가능한 것이다.)
    - SAML 에서 거의 안쓰임

  • Home URL: 로그인 후 리디렉션될 URL.
    - SP에서 별도로 리디렉션 경로를 요청하지 않을 경우, keycloak이 Home URL로 사용자를 Redirection
    - 기본적으로 Valid Redirect URI 로 리디렉션한다. 실은, keycloak 에서는 `권한 허용`만 해준다.
    - SAML 에서 거의 안쓰임

  • Valid Redirect URIs : Keycloak이 인증 성공 후 허용된 Redirect 경로.
    - Keycloak 에서는 해당 경로에 대해 `권한 허용`만 해주는 것이다.
    - 이 경로는 SP 에서 리디렉션이 가능하도록, 열어놓는 SP의 허용 URL 목록이다.
    - 여러 복수의 경로를 지정할 수 있는 이유는 로컬/테스트/운영 환경이 나뉘어져 있는 경우를 위해서이거나, 사용자의 상태나 특정 동작에 따라서 리디렉션의 경로가 다를 경우가 있기 때문이다.
    - 인증이 성공한 이후에 redirect_uri 로 브라우저의 리디렉션이 목적이다. 그러니까 특정 프로토콜(HTTP/SAML)에 종속되지 않고 브라우저 자체에서 리디렉션할 수 있는 경로이다. (정확하게는, Keycloak 에서는 redirect_uri 가 유효한지 검증하고 승인하는 역할을 맡고, 브라우저에서 해당 redirect_uri 로 자체 리디렉션한다.)

    - 실은, Valid Redirect URIs 는 SAML 에서 자주 쓰이지 않는 방식이다. SAML Response는, ACS URL 로 응답을 보내고 그 응답의 RelayState 에 redirect_uri 를 담아서 보내는 것이 일반적인 방식이다. Valid Redirect URIs 는 실은, OAuth2/OIDC 방식에서 자주 쓰인다. 그러나 Keycloak 이 OAuth2/OIDC 방식도 지원하기 때문에 남아있는 항목이다.

    더보기
    (내용 수정)
    RelayState는 리디렉션 경로가 아닌, SP가 요청과 함께 보낸 상태 정보를 유지하는 컨텍스트 값이다. Keycloak은 RelayState를 그대로 반환하며, SP는 이 값을 사용해 최종 리디렉션 경로를 결정한다. (** 그러나 암묵적으로 보통 일반적으로 SAML 방식에서 RelayState 값에 리디렉션 경로를 넣어서 보내곤 한다. **)
  • Valid Post Logout Redirect URIs: 로그아웃 후 허용된 리디렉션 경로.
    - Valid Redirect URLs 와 비슷한 경로 목록이다. 그러나 이 경우는 `로그아웃`시에 적용된다.
    - 그러나 이 항목은 필수가 아니다. 그 이유는, 설정하지 않으면 Keycloak은 기본적으로 사용자를 Keycloak 로그인 화면 또는 IdP의 기본 페이지로 리디렉션하기 때문이다.
    - Valid redirect URIs 와 비슷하게, SAML 방식에서는 자주 사용되지 않는다. 추후에 Advanced 설정에서 더 알아보도록 하자.

  • IDP-Initiated SSO URL Name: IdP-Initiated SSO에서 클라이언트를 식별하는 이름.
    - IdP-Initiated SSO 방식에서 사용한다. 이는 뒤에서 설명하겠다.
    - IdP-Initiated SSO 방식은 보안성 문제 때문에 주로 테스트용으로 쓰인다.

  • IDP-Initiated SSO Relay State: 인증 후 리디렉션할 특정 경로.
    - IdP-Initiated SSO 방식에서 사용한다. 이는 뒤에서 설명하겠다.
    - IdP-Initiated SSO 방식은 보안성 문제 때문에 주로 테스트용으로 쓰인다.

  • ★ Master SAML Processing URL (필수): Keycloak의 SAML 메시지 처리 URL.
    - SP(Spring) → IdP(Keycloak)로 SAML Request를 전송하는 경우에 사용된다.
    - 이 url 을 통해서 요청(Get or Post)을 보낸다.
    - 그러나 따로 실은, 따로 지정하지 않아도 Keycloack 에서 자동으로 설정해주기 때문에, 중요한 URL 임에도 굳이 수동적으로 만들지 않는다.
// [Master SAML Processing URL] 의 형식
http://<Keycloak-Host>/realms/<Realm-Name>/protocol/saml

 

 

궁금증1. Valid Redirect URIs 는 IdP(Keycloak)에서 SP(Spring)의 URL 을 제한하는 걸까?

 

상식적으로 SP의 주소는 SP가 제한하는 것이 맞지않나? 하는 생각이 들었다.

 

인증을 요청했을 때, IdP(keycloak)가 SAML Response 를 SP(Spring)로 전달하게 된다. 이때 만약 Redirection URL 검증이 없다면, 공격자는 악의적인 URL을 삽입하여, 민감한 데이터를 탈취할 수 있는 가능성이 있기 때문이다.

 

(추가 내용)
그러나, Valid Redirect URIs 는 SAML 통신 방식에서 통상적으로 거의 사용되지 않는다. Keycloack이  OAuth2/OIDC 방식도 지원하기 때문에, 이 항목이 남아 있는 것이다. SAML 에서는, ACS URL 의 RelayState 에 redirect_uri 데이터를 태워서 SAML Response 를 전달하고 리다이렉트하는 것이 일반적이다.



 

궁금증2. IDP-Initiated SSO URL Name 는 SP(Spring)을 구분한다고 했는데, metadata.xml 의 ID를 말하는건가?

 

그렇지 않다. IDP-Initaited SSO URL Name 은 Keycloak 내부에서 SP(Spring)를 식별하기 위해 사용되는 이름이지, metadata.xml의 ID나 entityID와는 다르다. Keycloak의 클라이언트를 구분하는 데 사용되며, SAML 표준의 속성(entityID 또는 ID)과는 별개의 Keycloak 전용 개념이다.

 

IdP-Initiated SSO URL의 일부로 사용되며, Keycloak이 해당 요청이 어느 SP로 가야 하는지 매핑하는 데 활용된다. 예를 들어, Keycloak이 여러 SP를 지원하는 경우, 이 이름을 통해 SP별 설정(ACS URL, Redirect URI 등)을 구분할 수 있다.

// IDP-Initiated SSO URL Name: spring-saml-sp
// 해당 SP의 IdP-Initiated SSO URL:
http://localhost:8081/realms/identity-provider/protocol/saml/clients/spring-saml-sp

// [형식]
// {IdP의 Host주소}/realms/{realm-name}/protocol/saml/clients/{SP 식별명}

 

IDP-Initaited SSO URL Name 은 keycloak 내부에서`만` SP 를 식별하는 용도이다.


이렇게 SP 없이 테스트하는 것을 `IdP-Initiated SSO` 라고한다.

 

그러나 이 IDP-Initiated SSO URL Name 은 일반적으로 테스트 용도로만 사용된다.

왜냐하면 SP 에서는 IdP 에 인증요청을 보낼때, RelayState 정보를 참조하기 때문이다.

 

그러니까, IDP-Initiated SSO URL Name은 SP 설정 없이도 Keycloak이 IdP-Initiated SSO를 통해 SAML 인증 흐름을 검증 가능하게 해준다.

 

(추가 내용)
일반적으로 사용되는 ACS URL 로 SAML Response 를 보내는 방식은 SP-Initiated SSO 방식이라고 하며, 이 방식을 통상적으로 사용한다. IdP-Initiated SSO 방식은 예외적인 상황이나, 테스트용으로만 사용된다.

 

 

 

이제 Client 생성시에 하나 더 필수 속성을 지정해줘야 한다.
ㄴ--> 이게 바로 SP-Initiated SSO 방식

 

Advanced Settings 탭에 존재하는 > Assertion Consumer Service (ACS) URL 설정이다.

 

  • ★ Assertion Consumer Service POST Binding URL (필수) :
    - Keycloak(IdP)이 SAML Response를 POST 방식으로 SP(Spring Boot)의 엔드포인트로 전송하는 URL
    - 그러니까, SAML 응답을 보내는 경로라는 거다.

  • Assertion Consumer Service Redirect Binding URL : 
    - SAML 응답을 보내는 GET 방식 경로.
    - 그러나 GET 방식은 url 을 그대로 노출하기에 보안성 좋지않다.

 

4. metadata.xml 파일 다운받기.

Realm settings > SAML 2.0 Identity Provider Metadata

SAML 2.0 Identity Provider Metadata 를 클릭하면, metadata.xml 파일을 다운받을 수 있다.

이제 이 파일을 SP 개발자에게 전달해줘야 연동이 가능하다.

 

 

 

5. 다음으로

IdP 설정을 마쳤으니, SP 설정도 해보자.

https://jinn-o.tistory.com/322

 

[SAML 2.0] IdP 와 SP 인증을 직접 구현해보자. (2) - SP 설정

0. 준비작업이전 포스팅https://jinn-o.tistory.com/321 [SAML 2.0] IdP 와 SP 인증을 직접 구현해보자. (1) - IdP 설정먼저 SAML 2.0 의 구조나 개념, 동작방식에 대해서 예습해오자.https://jinn-o.tistory.com/320 [SAML 2.0

jinn-o.tistory.com

 

그리고 다음 문서도 읽어보자.

SP-Initiated SSO 방식` vs `IdP-Initiated SSO 방식

https://jinn-o.tistory.com/350

 

[SAML 2.0] `SP-Initiated SSO 방식` vs `IdP-Initiated SSO 방식`

0. SAML 2.0 이란?Security Assertion Markup Language 의 2.0 버전이라는 의미로, 인증/인가 프로토콜 중 하나이다.https://jinn-o.tistory.com/320 [SAML 2.0] Security Assertion Markup LanguageSAML 2.0 이란?OAuth 2.0 과 마찬가지로

jinn-o.tistory.com

 

SAML 2.0 이란?

OAuth 2.0 과 마찬가지로 인증/인가와 관련된 규칙들을 묶어서 표준화한 방식이다.

다만 이상한 점이 있다. MarkUp Language 라고 정의되어 있다.

 

먼저, OAuth 2.0 은 주로 인가(Authorization)와 관련된 "권한 부여" 에 초점을 맞춘, 인가 프로토콜이다. 그러나 SAML은 XML 언어에 기반한, 주로 인증(Authentication)과 관련된 표준화된 방식이다.

 

그러니까, 그 어떤 다양한 데이터 포맷과 다양한 구조에서 활용이 가능한 OAuth 2.0 와 다르게, SAML 2.0 의 경우에는 무조건 XML 언어로만 생성되어야 하기 때문에 Markup Language 라고 불린다.

 

 

SAML 2.0 의 특징

주로 엔터프라이즈 애플리케이션으로, 기업 환경에서 SSO(Single Sign-On) 기술을 활용할 때 자주 사용된다.

그렇다. 나의 회사 프로젝트에서 최근 SAML 2.0 SSO Agent 를 구입하였고, 나에게 그것을 적용하는 막중한 임무가 주어졌다 ...

 

주로 SSO 기술을 구현할 때 사용되므로, 하나의 로그인 서버를 두고 여러 사이트를 접근하고 싶을 때 주로 사용된다.

 

 구성 요소

  • IdP (Identity Provider) : 인증 정보를 제공하는 주체로, 사용자를 인증하고 SAML Assertion 을 발행한다. (이때 SAML Assertion 은 OAuth2.0 에서 Access Token 과 비슷한 개념이다.)
  • SP (Service Provider) : 인증된 사용자 정보를 받아 서비스를 제공하는 주체로, 사용자가 접근하려는 웹 애플리케이션 혹은 서비스이다.

 

SAML2.0 vs OAuth2.0

비교 항목 SAML 2.0 OAuth 2.0
주요 목적 인증(Authentication), SSO 권한 부여 (Authorization)
포맷 XML 기반 JSON 기반 (Access Token 사용)
사용 사례 기업용 SSO, 엔터프라이즈 애플리케이션 웹/모바일 애플리케이션 권한 부여
토큰 SAML Assertion Access Token
복잡도 더 복잡하고 무겁다 상대적으로 가볍고 유연하다
동작 방식 IdP와 SP 간의 신원 확인 클라이언트와 권한 서버 간 권한 부여
확장성 주로 엔터프라이즈 환경 다양한 웹 및 모바일 애플리케이션

 

 

SAML 2.0 의 동작방식

이미지 출처 : https://developers.worksmobile.com/kr/docs/sso-sp-saml

  1. 사용자가 SP 에 접근하려고 요청한다. (ex 일반 사용자가 메일 서비스에 접속했다.)
  2. SP 는 인증을 요청하기 위해서, IdP 로 리다이렉트한다. 이때 SP 는 SAML Request 를 생성하여 IdP 에 전달한다. 이 요청은 주로 HTTP Redirect 또는 POST Binding 을 통해 전달된다.
  3. IdP 의 인증이 진행된다. 이때 로그인이 이미 되어 있으면 넘어간다.
  4. 로그인이 되어 있지 않으면, 사용자는 아이디와 비밀번호를 입력한다.
  5. 인증에 성공하면 SAML Assertion 을 생성한다. 이때 SAML Assertion 은 XML 문서이다.
  6. IdP 는 SAML Assertion 을 SAML Response 에 담아서 SP 로 전달한다. 이는 보통 POST Binding 으로 이루어진다.
  7. SP 는 Assertion 을 검증하기 위해서 응답받은 SAML Response 를 서명과 유효시간(만료시간)을 검증한다.
  8. 사용자는 인증되었다. 자원에 접근해도 좋다.

 

SAML Assertion 에 포함된 정보

 

  • Subject: 사용자 정보 (ID, 이메일 등).
  • Conditions: Assertion의 유효 기간.
  • Attribute Statements: 사용자 속성 (역할, 부서 등).
  • Authentication Statements: 인증 방식 및 시간.

 

 

SAML Request Binding 의 종류

먼저 SAML Request Binding 이란, SP 가 IdP 에 인증 요청(SAML Request)를 보낼 때 사용되는 전달 방식으로써, 이 전달 방식에는 여러 방식이 존재하며, 이를 Binding 이라고 부른다.

 

SAML 2.0 의 주요 Request Binding 방식에 대해서 알아보자.

  • HTTP Redirect Binding
  • HTTP POST Binding
  • HTTP Artifact Binding
  • SOAP Binding

이 중에서도 사실, HTTP Redirect Biding 이 주로 사용된다. 왜냐하면 단순하고, 어느 환경에서나 사용이 가능하며, SSO 구현에 가장 적합한데다, 데이터 전송량이 적기 때문이다. 그러나 Request 길이가 너무 커지거나, url 노출을 막아 보안을 강화하고 싶은 경우에 HTTP POST Binding 방식도 사용한다.



 

참고 문서

 

https://sddev.tistory.com/239

 

[인증/인가 기술] SSO - SAML이란?

1. SAML 정의 SAML(Security Assertion Markup Language)은 네트워크를 통해 여러 컴퓨터에서 보안 자격 증명을 공유할 수 있도록 하는 개발형 표준 테이터 포맷으로 2005년 3월 SAML2.0은 OASIS 표준이다. 인증 정

sddev.tistory.com

https://developers.worksmobile.com/kr/docs/sso-sp-saml

 

NAVER WORKS Developers

 

developers.worksmobile.com

 

+ Recent posts