포스트

추가적인 승인 부여 유형과 구현 방법


기본적인 OAuth2의 개념에 대해서는 Oauth 2.0 구현 과정 1 / 개념 포스트에 작성되어 있다.

또한, 프로젝트에서 채택한 승인 부여 유형은 Oauth 2.0 구현 과정 2 / 채택한 승인 부여 유형과 사전 작업에 작성되어 있다.

이외에 직접 구현한 내용은 Oauth 2.0 구현 과정 3 / 구현과 문제해결 포스트에 작성했으며 이번 포스트에서는 추가적으로 스프링 시큐리티 Configuration을 통한 OAuth2 인증 설정, AuthenticationSuccessHandler를 통한 기능 구현, 인증된 Authentication 정보 확인을 위주로 작성할 것이다.


OAuth 2.0의 승인 부여 유형

당시 포스트에서도 언급했지만 승인 부여 유형은 크게 4가지가 있다.

가장 보편적으로 사용되는 유형은 프로젝트에 적용한 방식이고, 나머지 3개의 방식에 대해 짚고 넘어가고자 이번 포스트를 작성한다.

1. Implicit Grant : 암묵적 승인 방식

암묵적 승인 방식의 경우 별도의 Authorization Code 없이 직접적으로 Access Token을 발급하는 방식이다.

이러한 승인 부여 유형은 자격 증명을 안전하게 저장하기 힘든 Client(JS 등 스크립트 언어를 사용하는 브라우저)에 최적화된 방식이다.

Refresh Token 사용이 불가능하고, 이 방식에서 Authorization Server는 Client Secret을 통해 인증 코드를 거치는 클라이언트 인증 과정을 생략한다.

이 방식으로 진행 시 응답 타입(response_type)을 token으로 지정하여 요청해야한다.

순서대로 흐름을 살펴보자.

  1. Resource Owner는 소셜 로그인 버튼을 누르는 등의 서비스 요청을 Client(애플리케이션)에게 전송한다.
  2. Client는 Authorization Server에게 접근 권한을 요청하게 되고, 이 요청과 함께 미리 생성한 Client Id, Redirect URI, 응답 타입을 전송하게 된다.
    • 이 과정은 Authorization Code를 획득하기 위한 요청이 아니다.
  3. Resource Owner는 로그인 페이지를 통해 로그인을 진행한다.
  4. 로그인이 확인되면 Authorization Server는 Client에게 Access Token을 전달한다.
  5. Client는 발급된 Access Token을 사용해 Resource Server에게 Resource를 요청한다.
  6. Access Token을 확인한 후 요청 받은 Resource를 전달한다.

이후 서비스가 진행된다.


2. Resource Owner Password Credential Grant : 자원 소유자 자격 증명 승인 방식

이 방식은 간단하게 로그인 시 필요한 정보(username, password)로 Access Token을 발급 받는 방식이다.

자신의 서비스에서 제공하는 애플리케이션의 경우에만 사용되는 인증 방식으로, Refresh Token의 사용이 가능하다.

“자신의 서비스에서 제공하는 애플리케이션”의 예를 들면, 네이버 계정으로 네이버 웹툰 애플리케이션에 로그인, 혹은 카카오 계정으로 카카오 지도 애플리케이션에 로그인하는 경우가 이 승인 부여 유형에 해당한다.

즉, Authorization Server, Resource Server, Client가 모두 같은 시스템에 속해 있을 때만 사용 가능한 것이다.

순서대로 흐름을 살펴보자.

  1. Resource Owner는 로그인 버튼을 누르는 등의 서비스 요청을 Client(애플리케이션)에 전송한다. 이 때 로그인에 필요한 정보(username, password)를 이용해 요청하게 된다.
  2. Client에서는 Resource Owner에게서 전달 받은 로그인 정보를 통해 Authorization Server에 Access Token을 요청하게 된다.
    • 이 경우 미리 생성한 Client Id, 권한 부여 방식, 로그인 정보를 함께 전달한다.
  3. 요청과 함께 온 정보들을 모두 확인한 후 Client에게 Access Token을 전달한다.
  4. Client는 Access Token을 이용하여 Resource Server에게 Resource를 요청한다.
  5. Access Token을 확인한 후 요청 받은 Resource를 전달한다.

이 방식을 통해 서비스를 사용할 수 있게 된다.


3. Client Credentials Grant : 클라이언트 자격 증명 승인 방식

Client 자신이 관리하는 Resource, 혹은 Authorization Server에 해당 Client를 위한 제한된 Resource 접근 권한이 설정되어 있는 경우 사용할 수 있는 방식이다.

이 방식은 자격 증명을 안전하게 보관할 수 있는 Client에서만 사용되어야 하며, Refresh Token의 사용은 불가능하다.




스프링 시큐리티 설정을 사용한 자동 OAuth 2.0 구현 방법

프로젝트에서 진행한 방법은 로그인 이후 Authorization Code를 활용하여 Access Token 을 발급 받고, 이를 통해 사용자의 이메일 주소를 얻어내는 과정을 직접 구현했다.

하지만 구현 포스트 중 Frontend와 Backend 간의 OAuth 2.0 인증 처리 흐름 에서도 말했듯이 OAuth2 프로토콜을 사용해서 소셜 서버와 통신하는 부분을 스프링 시큐리티에 맡길 수 있다.

즉, 로그인 성공 이후 인증 코드 ~ 액세스 토큰 발급 ~ 유저 정보 요청까지의 과정을 로그인 성공 “핸들러”를 통해 처리할 수 있다.

하지만 프로젝트 진행 당시 구현 방법에 대해 잘 모르고 있었고, 흐름 자체를 파악하기 위해서 해당 플로우를 직접 구현했었다.

이번에는 로그인 성공 핸들러를 활용한 방법을 제시해, 추후 사용할 수 있도록 기록해둘 것이다.

AuthenticationSuccessHandler 구현

당연할 얘기지만 지금까지 거쳐왔던 사전 작업, Spring Security 설정 등은 기본적으로 되어 있어야하며, JWT 인증 방식과 융합한 방식이 기본이기 때문에 JWT도 마찬가지로 구현이 끝나있어야 한다.

또한 로그인 성공 이후 처리할 “핸들러”를 구현하는 것이기 때문에 Security Configuration 클래스에서도 약간의 변경이 있다.

우선 핸들러를 살펴보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
public class OAuth2MemberSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {   // (1)
    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;
    private final MemberService memberService;

    // (2)
    public OAuth2MemberSuccessHandler(JwtTokenizer jwtTokenizer,
                                      CustomAuthorityUtils authorityUtils,
                                      MemberService memberService) {
        this.jwtTokenizer = jwtTokenizer;
        this.authorityUtils = authorityUtils;
        this.memberService = memberService;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        var oAuth2User = (OAuth2User)authentication.getPrincipal();
        String email = String.valueOf(oAuth2User.getAttributes().get("email")); // (3)
        List<String> authorities = authorityUtils.createRoles(email);           // (4)

        saveMember(email);  // (5)
        redirect(request, response, email, authorities);  // (6)
    }

    private void saveMember(String email) {
        Member member = new Member(email);
        member.setStamp(new Stamp());
        memberService.createMember(member);
    }

    private void redirect(HttpServletRequest request, HttpServletResponse response, String username, List<String> authorities) throws IOException {
        String accessToken = delegateAccessToken(username, authorities);  // (6-1)
        String refreshToken = delegateRefreshToken(username);     // (6-2)

        String uri = createURI(accessToken, refreshToken).toString();   // (6-3)
        getRedirectStrategy().sendRedirect(request, response, uri);   // (6-4)
    }

    private String delegateAccessToken(String username, List<String> authorities) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", username);
        claims.put("roles", authorities);

        String subject = username;
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());

        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }

    private String delegateRefreshToken(String username) {
        String subject = username;
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        return refreshToken;
    }

    private URI createURI(String accessToken, String refreshToken) {
        MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
        queryParams.add("access_token", accessToken);
        queryParams.add("refresh_token", refreshToken);

        return UriComponentsBuilder
                .newInstance()
                .scheme("http")
                .host("localhost")
//                .port(80)
                .path("/receive-token.html")
                .queryParams(queryParams)
                .build()
                .toUri();
    }
}

SimpleUrlAuthenticationSuccessHandler를 상속 받은 OAuth2MemberSuccessHandler이다.

이 핸들러는 OAuth2 인증에 성공하면 호출되어 JWT 발급과 Redirect 등 인증 이후의 작업을 진행한다.

즉, 인증 이후 프론트엔드 애플리케이션 쪽으로 JWT를 전송하는 역할을 담당한다.

주석으로 적은 번호 순서대로 살펴보자.

  1. SimpleURLAuthenticationSuccessHandler를 상속하면 Redirect를 손쉽게 할 수 있는 getRedirectStrategy().sendRedirect()와 같은 API를 사용할 수 있다.

  2. 다음으로는 필요한 객체를 DI 주입 받는다.

  3. Authentication 객체로부터 얻어낸 OAuth2User 객체로부터 Resource Owner의 이메일 주소를 얻고 있다.

  4. CustomAuthorityUtils를 사용해 권한 정보를 생성한다.

  5. Resource Owner의 이메일 주소를 DB에 저장한다.
    • 이 방식은 Resource Owner의 Credential을 백엔드 애플리케이션에서 관리하지는 않지만 백엔드 애플리케이션의 기능을 사용하기 위해 Resource와 연관 관계를 맺어야한다.
    • 즉, 프로젝트 당시에도 활용했듯이 Resource Owner를 회원으로써 등록하고, 해당 회원의 기능 사용 이력을 남겨야 서비스를 사용할 수 있기 때문에 최소한의 정보를 백엔드 애플리케이션 쪽에서 관리하는 것이다.
  6. Access Token과 Refresh Token을 생성해서 프론트엔드 애플리케이션에 전달하기 위해 Redirect 한다.
    • 6.1, 6.2 : 여기서 JWT Access Token와 Refresh Token을 생성한다.
    • 6.3 : createURI() 메서드에서 URIComponentsBuilder를 사용하여 Access Token과 Refresh Token을 포함한 URL을 생성 후 담는다.
    • 6.4 : 작업이 완료되면 SimpleUrlAuthenticationSuccessHandler에서 제공하는 sendRedirect() 메서드를 이용해 프론트엔드 애플리케이션 쪽으로 리다이렉트 한다.

프로젝트 구현 당시에 URL에 토큰을 담아 보내는 것은 이 내용 중에서 차용한 것이다.

그렇다고 하더라도 보안이 약한 http 프로토콜을 사용할 필요는 없기 때문에 변경할 예정이다.


인증 성공 핸들러 사용 시 SecurityConfiguration 설정

위와 같은 핸들러를 사용하게 되면 OAuth2 로그인 성 후 해당 핸들러를 호출하겠다는 설정을 해주어야 한다.

해당 설정은 당연히 스프링 시큐리티 설정 클래스인 SecurityConfiguration에 적용해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@Configuration
public class SecurityConfiguration {
    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;
    private final MemberService memberService;

    public SecurityConfiguration(JwtTokenizer jwtTokenizer,
                                   CustomAuthorityUtils authorityUtils,
                                   MemberService memberService) {
        this.jwtTokenizer = jwtTokenizer;
        this.authorityUtils = authorityUtils;
        this.memberService = memberService;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers().frameOptions().sameOrigin()
            .and()
            .csrf().disable()
            .cors(withDefaults())
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .formLogin().disable()
            .httpBasic().disable()
            .exceptionHandling() 
            .authenticationEntryPoint(new MemberAuthenticationEntryPoint())
            .accessDeniedHandler(new MemberAccessDeniedHandler())
            .and()
            .apply(new CustomFilterConfigurer())
            .and()
                .authorizeHttpRequests(authorize -> authorize
                  // 생략
                        .anyRequest().permitAll()
            )
            .oauth2Login(oauth2 -> oauth2
                    .successHandler(new OAuth2MemberSuccessHandler(jwtTokenizer, authorityUtils, memberService))  // (1)
            );

        return http.build();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("GET","POST", "PATCH", "DELETE"));
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("\**", configuration);

        return source;
    }
    
    // 추가
    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
        @Override
        public void configure(HttpSecurity builder) throws Exception {
            JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils);
            
            builder.addFilterAfter(jwtVerificationFilter, OAuth2LoginAuthenticationFilter.class); // (2)
        }
    }
}

이 설정에서 볼 수 있듯이

1
2
.oauth2Login(oauth2 -> oauth2
  .successHandler(new OAuth2MemberSuccessHandler(jwtTokenizer, authorityUtils, memberService))  // (1)

OAuth2 Login 활성 옵션에 인증 성공 후 호출될 핸들러를 적용해주어야 한다.

또한 객체를 인스턴스화하여 필요한 의존 객체를 DI하고 있는데, 인스턴스화 하고 주입하지 않으면 순환참조 문제가 발생한다.

이를 회피하기 위해서 객체를 생성해서 생성자 주입을 하던지, 아니면 각 기능을 분리해서 추상 클래스 혹은 인터페이스로 구조를 변경해야한다.

또한 (2)에서 볼 수 있듯이 인증 필터를 추가했다.


이러한 방법을 거치면 인증 성공 이후 해당 정보를 바탕으로 JWT를 발급하여 우리의 서비스를 사용할 수 있도록 할 수 있다.

추후 이 방식으로 “샐로그 프로젝트”도 리펙터링할 예정이다.





두 가지를 알아보았다.

승인 부여 유형 나머지 부분과 인증 성공 핸들러를 통한 OAuth2 구현 방식이다.

이와 같이 OAuth2를 구현하는 방법은 굉장히 많고 그 중에 어떤 것을 취사선택하는지는 온전히 개발자의 몫이다.

이번 기회를 통해 OAuth2 인증 프로토콜의 흐름에 대해 많이 알게되었고, 아직 보안 관련한 지식이 부족하다는 것이 느껴졌다.

이 블로그는 저작권자의 CC BY 4.0 라이센스를 따릅니다.