[Spring Authorization Server Sample 따라 구현하기] Client Server

2024. 9. 24. 03:25Spring Security/Spring Authorization Server

반응형
작년 이맘때 쯤 지금은 운영하지 않는 velog 블로그에 게시글을 옮겨온 글

개요

Client Server 를 구현한다.

클라이언트 서버는 사용자가 원하는 리소스를 얻기 위해 요청을 보내는 것을 담당하는 서버이다.

API 서버를 호출할 화면인 것이다.

방법과 구현

구조

리소스 서버에 비해 구조가 다소 복잡해 보이지만, html 코드와 디바이스 인증 부분을 빼면 내용이 감소한다.

인증 서버까지 구현한 이후에, 프로젝트에서 사용하지 않는 부분을 제거할 것이다.

의존성 - build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation "org.springframework:spring-webflux"
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    implementation 'io.projectreactor.netty:reactor-netty'
    implementation 'org.webjars:webjars-locator-core'
    implementation 'org.webjars:bootstrap:5.2.3'
    implementation 'org.webjars:popper.js:2.9.3'
    implementation 'org.webjars:jquery:3.6.4'
}

리소스 서버와 공통적인 부분이 많아, 클라이언트 서버에서 추가된 패키지만 살펴본다.

  • spring-boot-starter-oauth2-client : 클라이언트 서버 구축에 필요한 스프링 부트 하위 패키지
  • spring-boot-starter-thymeleaf : 클라이언트 코드 구동에 필요한 패키지. 자바 코드로 화면을 표현
  • 기타 bootstrap, jquery 등 화면 출력에 필요한 라이브러리 패키지들

어플리케이션 설정 - application.yml

spring:
  security:
    oauth2:
      client:
        registration:
          messaging-client-oidc:
            provider: spring
            client-id: messaging-client
            client-secret: secret
            authorization-grant-type: authorization_code
            redirect-uri: "http://127.0.0.1:8080/login/oauth2/code/{registrationId}"
            scope: openid, profile
            client-name: messaging-client-oidc
          messaging-client-authorization-code:
            provider: spring
            client-id: messaging-client
            client-secret: secret
            authorization-grant-type: authorization_code
            redirect-uri: "http://127.0.0.1:8080/authorized"
            scope: message.read,message.write
            client-name: messaging-client-authorization-code
                    ...
        provider:
          spring:
            issuer-uri: http://localhost:9000
  • spring.security.oauth2.client.registration.. : 인증 방법과 정보를 등록한다.
    • 클라이언트 id와 secret, 인증 방식, 콜백 uri, 클라이언트 어플리케이션에서 요청할 scope 를 정의한다.
  • spring.sequrity.oauth2.client.provider.. : 인증을 제공할 인증 서버의 uri를 등록한다.
    • 샘플의 oauth2 구성에서 클라이언트는 8080번, 리소스 서버는 8090번, 인증 서버는 9000번 포트를 사용할 것이므로 9000으로 적혀 있다.

보안 설정 - SecurityConfig.java

@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().requestMatchers("/webjars/**", "/assets/**");
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                   ClientRegistrationRepository clientRegistrationRepository) throws Exception {
        http
                .authorizeHttpRequests(authorize ->
                        authorize
                                .requestMatchers("/logged-out").permitAll()
                                .anyRequest().authenticated()
                )
                .oauth2Login(oauth2Login ->
                        oauth2Login.loginPage("/oauth2/authorization/messaging-client-oidc"))
                .oauth2Client(withDefaults())
                .logout(logout ->
                        logout.logoutSuccessHandler(oidcLogoutSuccessHandler(clientRegistrationRepository)));
        return http.build();
    }

    private LogoutSuccessHandler oidcLogoutSuccessHandler(
            ClientRegistrationRepository clientRegistrationRepository) {
        OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler =
                new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);

        oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/logged-out");

        return oidcLogoutSuccessHandler;
    }
}
  • webSecurityCustomizer() : 클라이언트 파일 전송에 필요한 uri 의 보안 설정을 해제한다.
  • securityFilterChain()
    • .authorizaHttpRequests() : 로그아웃 요청은 전부 허용, 다른 요청은 전부 인증 요구
    • .oauth2Login() : 로그인 페이지는 "/oauth2/authorization/messaging-client-oidc" 를 사용한다.
    • .oauth2Cleint() : 스프링 시큐리티 기본 설정 사용
    • .logout() : 로그아웃 성공시 동작을 제어할 핸들러 설정
  • oidcLogoutSuccessHandler() : 로그아웃 성공시 redirect uri 를 지정한다.

기타 설정 - WebClientConfig.java

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
                new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        return WebClient.builder()
                .apply(oauth2Client.oauth2Configuration())
                .build();
    }

    @Bean
    public OAuth2AuthorizedClientManager authorizedClientManager(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientRepository authorizedClientRepository) {

        OAuth2AuthorizedClientProvider authorizedClientProvider =
                OAuth2AuthorizedClientProviderBuilder.builder()
                        .authorizationCode()
                        .refreshToken()
                        .clientCredentials()
                        .provider(new DeviceCodeOAuth2AuthorizedClientProvider())
                        .build();

        DefaultOAuth2AuthorizedClientManager authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(
                clientRegistrationRepository, authorizedClientRepository);
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

        authorizedClientManager.setContextAttributesMapper(DeviceCodeOAuth2AuthorizedClientProvider
                .deviceCodeContextAttributesMapper());

        return authorizedClientManager;
    }

}

클라이언트 서버에 필요한 설정들 (authorization code, refresh token 등) 을 담고 있다.

이 부분은 현재 이해도가 높지 않아서, 샘플에서 구현한 부분을 두고 수정하지 않을 예정이다.

API 엔드포인트 - AuthorizationController.java

@Controller
public class AuthorizationController {
    private final WebClient webClient;
    private final String messagesBaseUri;

    public AuthorizationController(WebClient webClient,
                                   @Value("${messages.base-uri}") String messagesBaseUri) {
        this.webClient = webClient;
        this.messagesBaseUri = messagesBaseUri;
    }

    @GetMapping(value = "/authorize", params = "grant_type=authorization_code")
    public String authorizationCodeGrant(Model model,
                                         @RegisteredOAuth2AuthorizedClient("messaging-client-authorization-code")
                                         OAuth2AuthorizedClient authorizedClient) {

        String[] messages = this.webClient
                .get()
                .uri(this.messagesBaseUri)
                .attributes(oauth2AuthorizedClient(authorizedClient))
                .retrieve()
                .bodyToMono(String[].class)
                .block();
        model.addAttribute("messages", messages);

        return "index";
    }

    // '/authorized' is the registered 'redirect_uri' for authorization_code
    @GetMapping(value = "/authorized", params = OAuth2ParameterNames.ERROR)
    public String authorizationFailed(Model model, HttpServletRequest request) {
        String errorCode = request.getParameter(OAuth2ParameterNames.ERROR);
        if (StringUtils.hasText(errorCode)) {
            model.addAttribute("error",
                    new OAuth2Error(
                            errorCode,
                            request.getParameter(OAuth2ParameterNames.ERROR_DESCRIPTION),
                            request.getParameter(OAuth2ParameterNames.ERROR_URI))
            );
        }

        return "index";
    }

    @GetMapping(value = "/authorize", params = "grant_type=client_credentials")
    public String clientCredentialsGrant(Model model) {

        String[] messages = this.webClient
                .get()
                .uri(this.messagesBaseUri)
                .attributes(clientRegistrationId("messaging-client-client-credentials"))
                .retrieve()
                .bodyToMono(String[].class)
                .block();
        model.addAttribute("messages", messages);

        return "index";
    }

    @GetMapping(value = "/authorize", params = "grant_type=device_code")
    public String deviceCodeGrant() {
        return "device-activate";
    }

    @ExceptionHandler(WebClientResponseException.class)
    public String handleError(Model model, WebClientResponseException ex) {
        model.addAttribute("error", ex.getMessage());
        return "index";
    }

}
  • /authorize : 각 인증 타입별로 인증에 성공한 경우 리소스 서버의 /messege 를 호출하여 index.js 에 담아 반환한다.
  • /authorized : 인증에 성공한 경우 index.js 를, 인증에 실패한 경우 oauth2 에러를 반환한다.

페이지 연결 - DefaultController.java

@Controller
public class DefaultController {
    @GetMapping("/")
    public String root() {
        return "redirect:/index";
    }

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

    @GetMapping("/logged-out")
    public String loggedOut() {
        return "logged-out";
    }
}
  • index.js, logged-out.js 를 각 uri 호출시 반환한다.

실행

Run

  • 에러가 발생한다. 이유는 9000번 포트에 설정해놓은 Issuer, 즉 인증 서버가 구동되지 않았기 때문이다

결론

리소스 서버와 클라이언트 서버를 구현하였지만, 인증을 제공할 Auth Server가 없어 기능하지 못하고 있다.

인증 서버를 구현하고 실행을 테스트 해 보겠다.

Repository

https://github.com/gigyesik/spring-authorization-server-sample-connect-db/tree/clone

반응형