[Spring] Oauth2.0과 Spring Security 작동원리(+ 42Seoul Api를 이용한 Oauth2.0 로그인 구현)

Intro

프로젝트를 진행하며 oauth2.0 로그인 기능과 일반회원 기능을 같이 구현하고자 하였다. 내 목표는, 42Seoul의 api를 이용해서 oauth2.0을 구현하기 위한 것이다. 이번 포스팅에서는 oauth2.0에 대한 이야기 Spring 프레임워크에서 Oauth를 편하게 사용할 수 있게 해주는 Spring Security 기능 들에 대해서 적어보고자 한다.


Oauth2.0(Open Authorization)

요즘 oauth를 이용한 로그인 기능이 없는 곳이 없다. 네이버, 카카오, 구글 등 로그인을 할 때에 이전처럼 아이디(혹은 이메일)와 비밀번호를 전달받아 로그인할 수도 있지만, 카카오 계정으로 로그인 버튼 등이 있는 것을 볼 수 있다.

실제 티스토리 로그인 화면

저 아래쪽의 티스토리계정 로그인을 누른다면, 기존 우리가 아는 아이디 비밀번호를 입력하는 화면이 나오지만, 카카오 계정 로그인을 누른다면 티스토리가 아닌 카카오계정으로 로그인을 요청하게 된다.

난 티스토리를 쓰는데 카카오 로그인을 하면 어떻게 이런 것이 가능해지는걸까?

과정을 간단하게 순서대로 설명해본다면

  1. 티스토리에서 카카오측의 oauth 로그인 페이지로 사용자를 보내게 됩니다.
  2. 카카오 계정으로 로그인을 합니다
  3. 카카오측에서 티스토리를 쓰려면 ~~한 정보를 티스토리측에 제공하려 하는데 괜찮으세요?? 하고 물어본다.
  4. 내가 그 정보제공에 동의를 하게 되면 카카오측에서는 티스토리에게 인증 코드를 보낸다.
  5. 티스토리는 이러한 인증 코드를 통해 카카오측에서 내 정보를 볼 수 있는 자격증(AccessToken)을 발급받는다.
  6. 그럼 티스토리에서는 그 자격증을 바탕으로 내 정보를 조회할 수 있고 이를 기반으로 회원정보를 등록할 수 있다.
  7. 난 이제 카카오계정으로 티스토리를 쓸 수 있다!

물론 4번에서 존재하는 인증 코드에는, 내가 선택한 정보에 대한것만 조회할 수 있는 권한 기능이 존재하게 된다.

즉, Oauth는 접근 권한을 당사자(티스토리)가 아닌 외부(카카오, 네이버, 구글 등)에 위임하는 방식이 된다. 이를 이용해서, 현재 우리는 간편하게 하나의 계정으로도 여러 서비스에 쉽게 가입하고 이용할 수 있는 것이다.

 

용어

먼저, Oauth를 이용하기 위해서는 몇 가지 단어를 알아야 한다.

  • Resource Owner : 데이터 소유자, 주로 사용자를 뜻한다. 위의 티스토리 예시에서는 티스토리에 로그인하려는 '나 자신'이 된다. 이는 당연하게도, 우리 서비스를 이용하려는 사용자를 뜻할 수도 있다.(데이터에 접근할 수 있게 할지 말지 결정 하는 주체 = 사용자)
  • Client : Resource Owner의 데이터를 사용하고자 하는 애플리케이션.
    • 위의 예시에서는 티스토리이다. 주로 서비스를 뜻한다.(처음 공부할 때에 이 부분이 너무 헷갈렸다. 서비스를 소유하는게 Resource Owner이고 Client가 사용자 아닌가? 하는 생각으로 매번 헷갈려 했음) 
  • Resource Server : Resource Owner의 데이터를 가지고 있는 서버이다.
  • Authorization Server : 권한 서버로서, Client의 자격을 확인하고 이에 해당하는 AccessToken을 발급해준다.
    • 위 예시에서 4번 코드를 가지고 티스토리측에서 Authorization Server에 자격증(?)을 보여주면, 서버가 자격을 판단하고 이에 맞는 토큰을 티스토리 측에 전달함. 티스토리 측에서는 이 토큰을 이용하여 Resource Server에서 Resource Owner의 정보를 파악할 수 있음.

사실, 많은 글들에서 Spring Security + Oauth2.0을 이용한 로그인 구현 등은 자세하게 설명이 되어있기 때문에, 나는 이번 포스팅에서는 Spring Security에서 어떠한 원리로 이를 지원하는지 알아보려고 한다.


Spring Security 없는 Oauth 로그인 구현

일단, Oauth를 Spring Security 없이 구현하면서 원리를 알아보자. 인터넷 검색을 좀 해보면, 내가 사용하려는 서비스에 api등록을 하고 api에 맞는 client_id, client_secret, redirect_uri를 받아야 한다고 해서, 등록을 하고 이를 Spring 환경변수를 통해 저장을 했다.(보안을 위해 작성했고, client_secret 같은 경우에는 꼭 숨겨야 한다!)

모든 구현의 기반은 https://api.intra.42.fr/apidoc/guides/web_application_flow 이 문서를 기반으로 진행하겠다.

 

첫 번째로, 사용자를 API authorize url로 보내야 한다. 그럼 이 authorize url은 뭐냐?

기본 url인 https://api.intra.42.fr/oauth/authorize 에다가 파라미터를 추가하여 내 애플리케이션에 맞는 authorize url을 만들라고 한다. 아래 예시에 나와 있는 것과 같이, https://api.intra.42.fr/oauth/authorize?client_id={내 client_id}&redirect_uri={내 redirect_uri}&response_type=code&scope=public&state={내가 만든 state}  이렇게 작성해주면 된다고 한다.

그럼 여기서 한 가지 의문이 생긴다, 그래서 scope랑 state가 뭔데??

scope는 애플리케이션 서버가 어느 정도까지 데이터에 접근할 수 있는지 판단하는 것이다. 즉, "애플리케이션이 서버에 어떤 권한까지 접근할 수 있게 할 것인가?" 에 대한 내용이다. (만약, 내가 주민번호와 핸드폰번호를 Resource Server쪽에 보관하고 있는데, scope의 범위가 핸드폰번호까지라면 주민번호를 알아내는 것을 Resource Server측에서 막는다.)

state는 csrf 공격을 방지하기 위해서 사용하는 것으로, 임의의 엄청 긴 난수를 내가 서버쪽에 보내면 서버쪽 세션에서 이를 보관했다가 코드를 인증하는 과정에서 같은 값을 보냈는지 확인한다고 한다.

 

그럼 일단 코드로 작성하면서 알아보자.

환경변수같은걸로 내 애플리케이션에 대한 정보는 알아서 잘 숨겼을 것이라 생각한다.

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Getter
@Slf4j
public class AuthController {
    @Value("${spring.security.oauth2.client.registration.42Seoul.client-id}")
    private String clientId;

    @Value("${spring.security.oauth2.client.registration.42Seoul.redirect-uri}")
    private String redirectUri;

    @Value("${spring.security.oauth2.client.registration.42Seoul.client-secret}")
    private String clientSecret;

    @GetMapping("/login/42")
    public ResponseEntity<?> FortyTwoOauthLogin() {
        final String authorizeUrl = String.format("%s?client_id=%s&redirect_uri=%s&response_type=code&scope=public&state=veryverylonglongnumber", authorizationUri, clientId, redirectUri);

        log.info("url: {}", URI.create(authorizeUrl));
        return ResponseEntity.status(302).location(URI.create(authorizeUrl)).build();
    }
    
    @GetMapping("/redirect")
    public String FortyTwoCallback(@RequestParam String code) {
        return "hello Oauth2.0!";
    }
}

나 같은 경우는 Redirect URI를 http://localhost:8080/api/v1/auth/redirect 이렇게 간단하게 테스트용으로 만들었고, 이 주소로 가게 되면 그냥 string 하나 리턴하도록 컨트롤러를 작성했다.

내 http://localhost:8080/api/v1/auth/login/42 라는 주소로 접근하게 되면, 아까 빨간 줄로 생성했던 url을 만들고, 이 url로 리다이렉트 시킨다. 여기서 state를 나는 예시로 저렇게 사용했지만, 실제로는 예측할 수 없게 엄청 어렵게 만들어야 한다고 한다.

그럼, 일단 제대로 redirect가 되는지 확인해보자.

오.. 정말로 42서울 로그인 페이지로 이동을 하게 되었다. 그럼 로그인까지 진행을 해보자.

오.. 뭔가 내가 만들어놓은 컨트롤러로 redirect 된 듯 하다. 그리고 주소를 보면 redirect 뒤쪽으로 파라미터가 2개(code, state) 붙은 것을 확인할 수 있다. 그렇다면 docs의 다음 부분을 읽어보자. 앞선 글을 잘 읽어보신 분들은 알겠지만, 코드를 accessToken으로 변경하는 부분이다.

여기서는 https://api.intra.42.fr/oauth/token 이라는 곳에 client_id, client_secret, code, redirect_uri 를 같이 보내야 한다고 한다.

아래 부분을 확인해보면 POST방식인데, grant_type이라는 것과, state도 함께 보내라고 되어있다. grant_type을 Description에 있는 authorization_code를 적도록 하겠다. 이 부분은 먼저 postman으로 확인해보겠다.

accessToken을 문제없이 받은 것을 확인할 수 있다. 만약 state를 다른 문자로 넘긴다면?

invalid하다는 결과를 얻을 수 있다. 이 토큰 얻는 부분을 코드로 작성해보자면

@GetMapping("/redirect")
    public String FortyTwoCallback(@RequestParam String code, @RequestParam String state) throws JsonProcessingException {
        final String getTokenUri = String.format("%s?grant_type=authorization_code&client_id=%s&client_secret=%s&code=%s&redirect_uri=%s&state=%s", tokenUri, clientId, clientSecret, code, redirectUri, state);
        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<String> response = restTemplate.exchange(
                getTokenUri,
                HttpMethod.POST,
                null,
                String.class
        );
        String responseBody = response.getBody();

        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);
        String accessToken = jsonNode.get("access_token").asText();

        return accessToken;
    }

이런 식으로 작성이 된다. 문서에서 Body값이 아닌 Parameter로 넘기라고 하였기 때문에 주소를 생성하여 POST요청을 보냈다.

아마 정상적으로 접근을 했다면, 화면에 accessToken 값이 나왔을 것이다. 당연히 이 accessToken은 화면에 나타내지 말고 숨겨서 알고 있도록 하자.

이제 정말 거의 다 왔다, 문서의 맨 마지막에 보면 42Seoul 엔드포인트에 요청을 보낼 때에, Authorization: Bearer {token} 헤더를 추가해서 보내라고 한다. 유저의 정보 같은 경우에는, htttps://api.intra.42.fr/v2/me 를 이용해서 확인할 수 있다고 한다.

포스트맨으로 한 번 확인해보자.

제대로 토큰을 통해 요청을 보냈다면 해당 유저의 정보를 가지고 올 수 있을 것이다. 이러한 방식을 통해서 Oauth로 로그인하는 유저의 정보 중 필요한 부분만 골라서 DB에 저장해서 사용하는 등의 방식을 진행하면 된다. 이 부분에 대한 코드 작성은 생략하도록 하겠다.

그렇다면, Spring Security를 사용한다면 어떻게 해야할까?


Spring Security를 통한 Oauth 로그인 구현 원리

사실 글을 작성하면서 생각해봤을 때, Spring Security를 이용하기 위한 개념들을 공부하는 것이 문서에 맞춰 구현하는 것보다 조금 더 어려웠던 것 같다고 생각한다. 하지만 이 부분은, 정말 oauth 로그인을 통해서 정보를 가져오는 것 뿐만이 아니라, 확장성(예를 들어 지금은 42Seoul Oauth만 작성하고 있지만, Google, Kakao, Github 등 사용), 보안성 등에 이점이 있다고 한다.

아직 현재 나는, Spring Security를 이용한 Oauth 구현에서 정말 로그인해서 유저 정보를 가져오는 부분만을 작성했지만 여러가지 더 많은 기능들이 있을 것이라고 생각하고 이를 공부해보려고 한다.

 

사족이 길어졌는데, 우선 초기에 설명했듯이, 구현 방식 같은 경우에는 자세하게는 다른 여러 포스팅을 참고해보면 좋을 것 같다.

 

결과적으로 이야기하자면 Spring Security에서 Oauth를 사용하는 방법은 다음과 같다

 

1. application.yml파일(Gradle 기준)에 내가 사용할 Oauth 정보 등록

2. 애플리케이션 api에서 redirect_uri를 도메인/login/oauth2/code/{registrationId}(Google 등) 으로 등록

(ex. http://localhost:8080/login/oauth2/code/Google)

3. 로그인할 때에 도메인/oauth2/authorization/{registrationId} 사용

(ex. http://localhost:8080/oauth2/authorization/Google)

 

이렇게 하면 알아서 Spring Security에서 연결해주고 코드가져와서 토큰 받고, 토큰 이용해서 유저 정보까지 가져오게 된다.

Dependency 등록

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'

Spring Security와 Spring oauth2 client 라이브러리를 등록하자

Provider 작성

Spring Security는 Provider(Google, Kakao, Naver 등..) 정보들을 application.yml파일(Gradle 기준)에 작성된 것을 바탕으로 자동적으로 로그인 페이지, 토큰 가져오기, 유저 정보가져오기 등을 수행한다. 예를 들면 다음과 같다,

Google 등 유명한 Provider는 authorization-uri 등이 자동으로 설정되어 있어서 registration만 작성해주면 된다고 한다. 유명 provider 정보가 적혀있는 파일을 찾고 싶었는데 라이브러리나 깃허브 소스등을 살펴봐도 찾지 못했다.(추후 찾게되면 업데이트 하겠습니다).

일단 내 애플리케이션 정보에서 client_id, client_secret, redirect_uri를 등록해주고, 스프링 프레임워크 내부적으로 등록되지 않은 프로바이더의 경우 authorization-uri, token-uri, user-info-uri, user-name-attribute 등을 등록해주자. user-name-attribute는 유저 정보를 가져올 때에 어떠한 항목을 username으로 사용할 지 지정해주면 된다.

이렇게 하면 기존 프로바이더 등록 혹은 커스텀 프로바이더 등록이 끝난다.

Spring Security 설정

@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.csrf().disable()
            .cors()
                .configurationSource(corsConfigurationSource())
            .and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .formLogin().disable()
            .httpBasic().disable()
            .authorizeRequests(authorize ->
                    authorize.antMatchers("/", "/**").permitAll()
                            .antMatchers("/api/v1/**").permitAll()
                            .antMatchers("/favicon.ico").permitAll()
                            .antMatchers("/oauth2/**").permitAll()
                            .antMatchers("/login/oauth2/**").permitAll()
                            .anyRequest().authenticated())
            .oauth2Login()
            	.successHandler(oAuth2SuccessHandler)
            	.userInfoEndpoint().userService(customOauth2UserService);

    return httpSecurity.build();
}

Spring Security 설정은 다들 다를 것이라 생각하는데, /oauth2 로 시작하는 주소를 spring 프레임워크에서 사용하기 때문에 허용을 해주면 되고, oauth2Login()을 통해 이 설정을 사용한다고 말해주자. 그 이후로 oauth2 로그인이 성공했을 때의 동작을 정의한 successHandler와 유저 정보를 가져오는 userService를 등록해준다.

successHandler을 등록하지 않았다면, Default

UserService 부분에서 토큰을 이용해서 가져온 유저 정보를 애플리케이션 내 Model로 매핑해주는 역할을 한다.


Spring Security + Oauth2.0 작동 원리

일단, 내 기준으로 oauth 관련 파일들이 External Library에서 Gradle: org.springframework.security:spring-security-oauth2-client:5.7.11 라이브러리에 존재하는 것을 알 수 있었다.

 

!! 아래 내용들은 정확하지 않을 수 있습니다. 제가 혼자서 파악해본 부분인데 잘못된 내용이 있다면 수정하도록 하겠습니다.

 

먼저 Spring을 디버그 모드로 실행한 후, filter chain을 확인하기 위해 SecurityConfig쪽에서

@EnableWebSecurity에 @EnableWebSecurit(debug = true)을 통해서 확인을 했다.

여러가지 필터 체인들이 나오는데, 내가 궁금한 건 OAuth2AuthorizationRequestRedirectFilter과 OAuth2LoginAuthenticationFilter 쪽이다.

OAuth2AuthorizationRequestRedirectFilter

org.springframework.security.oauth2.client.web 라이브러리 폴더를 확인하면 OAuth2AuthorizationRequestRedirectFilter파일을 확인할 수 있다.



안쪽 변수들을 확인해보면 여러가지가 있는데, 맨 위의 변수를 보면 DEFAULT_AUTHORIZATION_REQUEST_BASE_URI값을 확인할 수 있다. 라이브러리 위쪽의 설명에 따르자면

"{@code /oauth2/authorization/{registrationId}} using the default" 라는 문구를 볼 수 있는데, 우리가 oauth 인증을 위해서 사용되는 주소를 Default로 /oauth/authorization/Google 등으로 사용한다는 것을 의미하는 것 같다. 아마 내가 결과 3번에서

로그인할 때에 도메인/oauth2/authorization/{registrationId} 사용하는 것과 관련 있는 것 같다. 아래쪽을 더 살펴보면,

이러한 내용이 있다. resolve에 관한 내용은 DefaultOAuth2AuthorizationRequestResolver에서 확인할 수 있는데

이러한 내용으로, 우리가 application.yml파일에 작성한 정보를 바탕으로 클라이언트가 요청한 권한 범위, 리다이렉트 URI, 상태 토큰, 클라이언트 등록 정보 등을 포함하고 있다. 이러한 값이 존재를 한다면 sendRedirectForAuthorization 함수로 이동을 하게 된다. 제대로 된 정보를 입력하지 않았을 때는 아래쪽의 unsuccessfulRedirectForAuthorization쪽으로 빠져서 에러를 발생시킨다.

AuthorizationGrantType안에 AUTHORIZATION_CODE의 경우, 여러 가지 타입을 가지고 있는데 내가 작성한 grant_tyep이 authorization_code라면 그 정보를 저장 후에 redirect를 시키는 것 같다.

org.springframework.security.web 의 DefaultRedirectStrategy를 확인해보면

RedirectStrategy에서 사용하는 함수를 확인할 수 있다. 주소를 인코딩 한 후에 HttpServletResponse의 sendRedirect 기능을 이용해서 redirect한다. (주소 인코딩의 경우 우리가 http://localhost:8080 이라고 작성하지만 URL 인코딩을 하면 http%3a%2f%2flocalhost%3a8080 이러한 형태로 변하게 된다. 유효한 아스키코드 형식을 통해서 똑바로 주소를 보내기 위함)

즉, 우리가 인코딩된 주소를 등록하지 않기 때문에, 주소를 인코딩한 후에 redirect를 진행한다.

이 과정까지 진행하면, OAuth 로그인을 하기 위한 페이지로 이동하게 되는 것이다.

 

로그인에 성공하게 된다면, 이전 글에서 확인할 수 있듯이 ~~~//login/oauth2/code/{registrationId}?code=코드 와 같은 형태의 주소로 이동하게 된다. 그렇다면 OAuth2LoginAuthenticationFilter 부분을 살펴보자

OAuth2LoginAuthenticationFilter

이제 어느 정도 눈에 보일 것 같다. DEFAULT_FILTER_PROCESS_URI가 우리가 redirect_uri로 설정했던 주소를 포함하고 있다.

아래쪽으로 내려가보자.

실제로 우리의 코드를 토큰으로 변경하고, 토큰을 다시 유저 정보로 가져오는 부분이다. 한번 천천히 알아보자

첫 번째로, Response 구성을 하는 것을 MultiMap 형태로 바꾼다고 한다. 이게 정확히 어떤 이점이 있는지는 모르겠다.

아래쪽 부분이다. removeAuthorizationRequest가 어떤 역할을 하는지 몰라서 한 번 찾아봤다.

위치는 org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository 이다.

세션에 대한 지식이 부족해서 확실하게 분석해보지는 못했지만, state의 경우 request와 서버의 state를 비교해서 일치하는지 확인하는 용도이다. 그 용도가 끝나고 나면 불필요해지기 때문에 제거를 하게 되는데 그러한 부분이 구현되어 있는 것으로 생각된다.

더 아래쪽을 보자.

이 부분은, token을 얻어오기 위해서 요청 url을 만드는 부분이다. convert함수 안쪽을 살펴보면

이렇게 구성되어 있다. if(StringUtil.hasText(code)) 쪽을 통해서 우리가 이전에 만들어봤던 요청 url을 만들게 되고 이를 반환한다.

buildDetails의 경우 세부 정보를 설정하는 것 같은데, WebAuthenticationDetails 객체를 생성하는 것으로 보인다.(WebAuthenticationDetails의 경우 공부 필요)

이 부분은, 실질적으로 code를 이용해서 Resource Server로 접근할 수 있는 accessToken을 받아오는 부분이다.

OAuth2LoginAuthenticationToken 이라는 객체를 생성하고, 아래쪽의 (OAuth2LoginAuthenticationToken) this.getAuthenticationManager().authenticate() 함수를 통해서 accessToken 등을 받아오게 된다.

그럼 accessToken을 실질적으로 받아오는 부분은 어디일까? 이 부분은 org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationProvider 파일에서 확인해볼 수 있다.

이 코드에서 중점적으로 볼 부분은, 중간에 try-catch 구분에 있는 authenticate 함수이다. 이 함수를 통해서 accessToken이 담겨있는 객체를 생성하게 되는데, 이 부분은 org.springframework.security.oauth2.client.authentication.OAuth2AuthorizationCodeAuthenticationProvider 파일 내부의

이 부분을 통하게 되고, 이 getTokenResponse의 경우 org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient 파일 내부에 구현되어 있다.

이 과정을 통해 받아온 response의 Body를 반환하고, 이 body값은 authorizationCodeAuthenticationToken 객체에 담긴 후에 Oauth2AccessToken accessToken; 의 형태로 사용된다. 그 아래 부분에서는 우리가 여러 포스팅에서 볼 수 있던 loadUser에 대한 부분이 나온다.

accessToken 및 여러 parameter정보들을 수집하고, 이를 loadUser의 인자로 보내게 된다. 우리는 그러한 인자를 받아서 우리가 원하는대로 Custom loadUser등을 만들어서 데이터를 가공하게 되는 것이었다.


Outro

쓰다보니 글이 엄청 길어지고 조사하는 과정이 만만치 않았다. 하지만 인터넷에서 단순히 사용법만을 가지고는 도대체 어떠한 구조로 이루어져 있는지 파악을 할 수 없어서 나름대로 조사를 해 보았다. 중간 중간 지식이 얕아서 제대로 파악하지 못한 부분들도 존재는 하엿지만, 완벽한 구조 해석보다는 어떠한 식의 flow를 가지고 이어지는 지 파악을 할 수 있었고, 추후 Custom을 하는 과정에서도 더 이해를 잘 한 상태로 사용할 수 있지 않을까 한다.

추가로 공부를 해보고 싶은 부분은, 세션에 관한 부분과 Controller가 없는데 어떻게 요청을 잡아내서 필터로 넘기는지 등에 관한 부분이다. 스프링의 세계는 크고 나는 너무 부족하다는 것을 많이 느낄 수 있었다.

 

긴 글 읽어주셔서 감사합니다.

 

(아마 틀린 지식도 많겠지만 그런 부분들은 항상 지적해주시면 감사하겠습니다.)

댓글