> Hello World !!!

     

@syaku

Spring Security OAuth - Authorization Code 인증 방식

https://github.com/syakuis/spring-security-oauth

코드를 함께 보면서 작업하시면 도움이 됩니다.

목차


다른 혹은 외부 애플리케이션에서 인증 서버와 연동하기 위해 사용되는 인증 방식이다. 프론트엔드, 모바일앱, 그외 애플리케이션에서 로그인 연동을 하기위해 사용한다. Authorization Code 인증 방식으로 발급받은 액세스 토큰으로 해당 애플리케이션에서만 사용할 수 있어야 한다.

예를들면 카카오 통한 로그인을 연동한 애플리케이션에서 액세스 토큰을 발급받고 해당 액세스 토큰으로 카카오 서비스는 이용할 수 없다.

작업 흐름

Spring security에서 AuthorizationEndpoint 와 TokenEndpoint 클래스가 인증 방식을 구현한 Controller 를 제공하고 있다.

사용자 확인 절차를 위해 제공되는 페이지는 백엔드에서 forward 되도록 설계해야한다. 백엔드 템플릿 엔진을 사용한다면 문제가 되지 않겠지만 프론트엔드 기술을 사용한다면 백엔드에서 해당 스크립트를 로드하고 브라우저로 서빙되어야 한다.

인증 서버와 애플리케이션 간에 보안적으로 안정적인 상호작용을 위한 방법으로 생각된다.

ClientSecret 을 사용할 경우 Client Application 을 위한 BFF 서버를 구성해야 한다. 대부분 API Gateway 를 구성하기 때문에 Gateway 에서 BFF 역할을 하기도 한다.

요청 예시

Authorization Code 인증 방식을 요청하기 전에 사용자는 인증된 상태여야 한다.

임시코드 요청

애플리케이션에서 인증을 요청하면 이용 약관이나 애플리케이션에서 사용자 정보를 사용하는 범위에 대해 설명을 페이지로 응답한다. 사용자는 허용과 거절을 선택할 수 있고 허용할 경우 다음 페이지로 이동한다.

POST /oauth/authorize?response_type=code&client_id=xxxx&rediect_uri=xxxx&scope=read

rediect_uri 값으로 rediect 페이지가 요청된다. 이때 code 파라메터로 임의코드가 발급된다.

  • rediect_uri - uri 값은 클라이언트 등록시 등록한 여러 uri 중에 하나여야 한다.
  • scope - scope 값은 클라이언트 등록시 등록한 여러 scope 중에 하나여야 한다.

임시코드로 액세스 토큰 요청

POST /oauth/token?grant_type=authorization_code&code=임시코드&redirect_uri=xxxx
Authorization: Basic ClientId ClientSecret

액세스트 토큰이 발급되면 rediect_uri 로 rediect 페이지가 요청된다.

  • rediect_uri - uri 값은 클라이언트 등록시 등록한 여러 uri 중에 하나여야 한다.

테스트 코드

@Slf4j
@ExtendWith(SpringExtension.class)
@SpringBootTest
@AutoConfigureMockMvc
@DirtiesContext(classMode = BEFORE_CLASS)
class AuthorizationCodeRestControllerTest {
    @Autowired
    private MockMvc mvc;

    @Autowired
    private TestProperties props;

    private OAuth2UserDetails oAuth2UserDetails;
    private String clientId;
    private String clientSecret;

    @BeforeEach
    void init() {
        this.oAuth2UserDetails = OAuth2UserDetails.builder()
            .username(props.getUsername())
            .uid(UUID.randomUUID())
            .name("material")
            .build();

        clientId = props.getClientId();
        clientSecret = props.getClientSecret();
    }

    @Test
    void accessToken() throws Exception {
        String redirectUri = "<http://localhost>";

        // cofirm_access 페이지 호출
        MvcResult authorize = mvc.perform(post("/oauth/authorize")
                .param("response_type", "code")
                .param("client_id", clientId)
                .param("redirect_uri", redirectUri)
                .param("scope", "read")
                    .with(user(oAuth2UserDetails))
            )
            .andExpect(status().isOk())
            .andExpect(forwardedUrl("/oauth/confirm_access"))
            .andDo(print())
            .andReturn()
        ;

        ModelAndView modelAndView = authorize.getModelAndView();
        assertNotNull(modelAndView);

        // 사용자 허가 처리
        MvcResult result = mvc.perform(post("/oauth/authorize")
            .flashAttrs(modelAndView.getModel())
            .param("user_oauth_approval", "true") // 사용자가 직접 허용한다.
            .param("scope.read", "true") // 사용자가 직접 허용한다.
                .with(user(oAuth2UserDetails))
        )
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrlPattern(redirectUri + "?code=*"))
            .andDo(print())
            .andReturn()

        ;

        String redirectUrl = result.getResponse().getRedirectedUrl();
        String code = getCode(redirectUrl);

        // 액세스 토큰 발급 요청
        mvc.perform(post("/oauth/token")
                .param("grant_type", GrantType.AUTHORIZATION_CODE.getValue())
                .param("code", code)
                .param("redirect_uri", "<http://localhost>")
                .with(httpBasic(clientId, clientSecret))
                .with(user(oAuth2UserDetails))
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            )
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.access_token").isNotEmpty())
            .andExpect(jsonPath("$.uid").isNotEmpty())
            .andExpect(jsonPath("$.name").isNotEmpty())
            .andDo(print())
        ;
    }

    private String getCode(String redirectUrl) {
        Assert.hasText(redirectUrl, "입력된 값이 없습니다.");
        return redirectUrl.replaceAll("^https?://.*\\\\?code=(.*)", "$1");
    }
}

@DirtiesContext(classMode = BEFORE_CLASS) 설정으로 테스트 코드는 단독적으로 실행되게 한다. 다른 인증 테스트와 같이 구동되면 원할한 실행 결과를 얻을 수 없다.