[Security] OAuth2 - Lab

얄루얄루·2023년 1월 22일
0

Spring

목록 보기
11/14

이전글에서 OAuth 2.0의 개념을 잡았으니 이번글에는 Spring Boot를 이용한 실습을 해보겠다.

목표는 2가지이다.

  • Google을 이용한 로그인.
  • Kakao를 이용한 로그인.

왜 이렇게 2가지냐면, Google은 CommonOAuth2Provider에 정의되어 있다. Kakao는 그렇지 않다.

그렇기 때문에 Kakao를 통한 로그인을 위해서는 몇 가지 추가 설정이 필요할 것이다.

어려웠던 점

글 초기부터 어려웠던 점에 대해 말하는 이유는 내가 생각한 설계구조와 연관되어 있기 때문이다.

먼저 Grant 방식은 Authorization code를 이용하기로 했다.

그러면 인증 순서는 아래와 같다.

  1. (프론트->백) OAuth2 인증 요청 with redirect_url
  2. (백->인증 서버) OAuth2 인증 요청 with redirect_url
  3. (인증 서버->프론트) code 발급
  4. (프론트->백) JWT 요청 with code
  5. (백->인증 서버) JWT 요청 with code
  6. (인증 서버->백) JWT 발급
  7. (백->리소스 서버) 유저 정보 요청 with JWT
  8. (리소스 서버->백) 유저 정보 발급
  9. (백->프론트) JWT_1발급

JWT와 JWT_1은 다르다. JWT는 OAuth2 Provider에서 발급하는 토큰이다. JWT_1은 우리의 백엔드에서 발급하는 토큰이다.

아무튼 내가 생각한 건 이런식이었다. 그런데 여기에 Spring OAuth2-Client가 끼어들면서 체감상의 절차가 상당히 간소화 되었다.

1 - 2~8 - 9의 느낌이다. 2~8단계가 OAuth2-Client에 의해 처리된다.

문제는 나는 이미 로컬 로그인을 지원하고 있는 상황이라 api 엔드포인트를 맞추고 싶었다.

OAuth2-Client가 지원하는 엔드포인트는 oauth2/authorization/{provider}의 형식인데, 내 로컬로그인은 /login 이다. 그러므로 통일성을 생각한다면 /login/social/google, /login/social/kakao 이런식으로 만들고 싶었다는 말이다.

해결하고 나니, 정답은 생각한 것보다 훨씬 간단했다.

정답: 리디렉션을 활용해라

간단하지만 왜 그렇게 해야 하는지 알기 위해서는 OAuth2-Client에 대해 어느 정도 이해할 필요가 있다.

우리는 oauth2/authorization/{provider}라고 하는 단순한 엔드포인트에 접근하지만, 실제로는 자동 설정된 무언가가 이를 컨버팅해서 아래와 같이 바꾸고 있다.

https://kauth.kakao.com/oauth/authorize?
	response_type=code
	&client_id=클라이언트아이디
    &scope=profile_nickname%20account_email
    &state=임의코드
	&redirect_uri=http://localhost:8080/login/oauth2/code/kakao

왜, 어떻게 그런 일이 일어나는지 알아보자.

OAuth2ClientAutoConfiguration

애초에 OAuth2-Client에 의해 모든 게 지원이 된다는 말은 해당 설정을 어딘가에서 관리하고 있다는 말이다. 그걸 관리하는 녀석이 OAuth2ClientAutoConfiguration이다.

이 놈이 하는 일은 이렇다.

  • ClientRegistration으로 구성된 ClientRegistrationRepository을 Bean으로 등록한다.
  • WebSecurityConfigurerAdapter Configuration을 제공하고, httpSecurity.oauth2Login()으로 OAuth2 로그인을 활성화한다.

그러므로 OAuth2-Client의 커스텀화를 하고 싶다면 이 2가지를 건드려야 하는데, 대개의 경우 2번째 조항은 이미 커스텀화가 된 상황일 것이다.

남은 것은 첫번째인데, 아래와 같은 방식으로 직접 Bean을 등록해 줄 수 있다.

@Configuration
public class OAuth2LoginConfig {

    @Bean
    public ClientRegistrationRepository clientRegistrationRepository() {
        return new InMemoryClientRegistrationRepository(this.googleClientRegistration());
    }

    private ClientRegistration googleClientRegistration() {
        return ClientRegistration.withRegistrationId("google")
            .clientId("google-client-id")
            .clientSecret("google-client-secret")
            .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .redirectUriTemplate("{baseUrl}/login/oauth2/code/{registrationId}")
            .scope("openid", "profile", "email", "address", "phone")
            .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
            .tokenUri("https://www.googleapis.com/oauth2/v4/token")
            .userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
            .userNameAttributeName(IdTokenClaimNames.SUB)
            .jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
            .clientName("Google")
            .build();
    }
}

위 코드를 대강 해석해보면 Bean으로 등록된 InMemoryClientRegistrationRepository 타입의 ClientRegistrationRepository안에 ClientRegistration들이 담겨 있다는 소리다.

저기서 ClientRegistration를 가지고 올 수 있다면 리디렉션 할 URL을 쉽게 만들 수 있을 것 같다.

구글, 페이스북, 깃허브 등의 CommonOAuth2Provider를 사용하는 경우에 한해 위 코드는 아래와 같이 대체될 수 있다.

✨CommonOAuth2Provider: 구글, 페이스북, 깃허브 등을 통한 로그인 구현 할 때 redirect_url이라던가 하는 잡다한 것들을 넣지 않게 해주는 녀석이다.

@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
    return new InMemoryClientRegistrationRepository(this.googleClientRegistration());
}

@Bean
public OAuth2AuthorizedClientService authorizedClientService(
        ClientRegistrationRepository clientRegistrationRepository) {
    return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository);
}

@Bean
public OAuth2AuthorizedClientRepository authorizedClientRepository(
        OAuth2AuthorizedClientService authorizedClientService) {
    return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
}

private ClientRegistration googleClientRegistration() {
    return CommonOAuth2Provider.GOOGLE.getBuilder("google")
        .clientId("google-client-id")
        .clientSecret("google-client-secret")
        .build();
}

네이버, 카카오 등과 같은 한국의 OAuth2 제공자들은 CommonOAuth2Provider가 아니기 때문에 큰 의미는 없어보인다.

조금 더 알아보자.

HttpSecurity.oauth2Login()

HttpSecurity.oauth2Login()메소드로 여러 속성을 재정의 할 수 있다고 한다.

@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .clientRegistrationRepository(this.clientRegistrationRepository())
                .authorizedClientRepository(this.authorizedClientRepository())
                .authorizedClientService(this.authorizedClientService())
                .loginPage("/login")
                .authorizationEndpoint(authorization -> authorization
                    .baseUri(this.authorizationRequestBaseUri())
                    .authorizationRequestRepository(this.authorizationRequestRepository())
                    .authorizationRequestResolver(this.authorizationRequestResolver())
                )
                .redirectionEndpoint(redirection -> redirection
                    .baseUri(this.authorizationResponseBaseUri())
                )
                .tokenEndpoint(token -> token
                    .accessTokenResponseClient(this.accessTokenResponseClient())
                )
                .userInfoEndpoint(userInfo -> userInfo
                    .userAuthoritiesMapper(this.userAuthoritiesMapper())
                    .userService(this.oauth2UserService())
                    .oidcUserService(this.oidcUserService())
                    .customUserType(GitHubOAuth2User.class, "github")
                )
            );
    }
}

위의 리디렉션 엔드포인트는 Authorization code를 발급 받는 용도이다. 내가 필요한 리디렉션은 커스텀 OAuth2 로그인 엔드포인트를 OAuth2-Client가 지원하는 인증 엔드포인트로 넘기는 것이다.

그보다 .loginPage가 눈에 띈다.

해당 항목은 디폴트 로그인 페이지를 설정하는데, 기본값은 OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI + "/{registrationId}"이고 우리가 흔히 쓰던 /oauth2/authorization/{provider}이다.

아래와 같은 방식으로 변경이 가능하다고 한다.

@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .loginPage("/login/oauth2")
                ...
                .authorizationEndpoint(authorization -> authorization
                    .baseUri("/login/oauth2/authorization")
                    ...
                )
            );
    }
}

그런데 이렇게 할 거면 해당 로그인 페이지를 렌더링 할 @Controller로 만들어야 한다고 한다.

HttpSecurity.userInfoEndPoint()

내 서비스 접근용 JWT를 발급할 때 이용할 수도 있으니 살펴보자.

사용자가 인증을 하면 OAuth2User.getAuthorities()를 통해 사용자 권한을 받아올 수 있으며, 이는 GrantedAuthority로 매핑돼 OAuth2AuthenticationToken 생성에 사용될 수 있다.

매핑 방법에는 2가지가 있다.

  • GrantedAuthoritiesMapper
  • OAuth2UserService

공식 레퍼런스 사이트에서는 위가 더 쉽다고 말하는데, 별로 큰 차이는 없어보인다.

그보다는 두 번째 방법이 확장성이 더 좋기 때문에 두 번째 방법만을 살펴보자.

@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .oidcUserService(this.oidcUserService())
                    ...
                )
            );
    }

    private OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService() {
        final OAuth2UserService delegate = new DefaultOAuth2UserService();

        return (userRequest) -> {
        	OAuth2User oAuth2User = delegate.loadUser(userRequest);
            String userNameAttributeName = userRequest.getClientRegistration()
            									.getProviderDetails()
            									.getUserInfoEndpoint()
                                                .getUserNameAttributeName();

            OAuth2AccessToken accessToken = userRequest.getAccessToken();
            Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

            // TODO
            // 1) accessToken을 이용해 권한 정보를 받아옴
            // 2) 권한 정보를 GrantedAuthority로 매핑하고 mappedAuthorities에 추가

            // 3) OAuth2User의 기본 구현체인 DefaultOAuth2User으로 반환
            return new DefaultOAuth2User(mappedAuthorities, oAuth2User.getAttributes(), 
            								userNameAttributeName);
        };
    }
}

oAuth2User.getAttributes()로 사용자 정보를 조회할 수 있다.

Provider에 따라 형식은 다양한 편인데, 어쩌면 CommonOAuth2Provider 사이에서는 동일할 수도 있다.

네이버와 카카오는 확실히 다르다.

  • 구글의 응답
  • 카카오의 응답

카카오의 경우에는 보다시피 Map안에 Map이 또 있고, 그 Map 안에 Map이 또 있는 구조니까 사용자 정보를 받을 때 조금 귀찮다.

DefaultOAuth2UserOAuth2User의 attributes를 가지고 있으므로, 해당 객체만 확보하면 정보 조회는 할 수 있을 것이다. 하지만 CustomOAuth2User를 만들면 어떨까?

말했다시피 카카오의 응답은 depth가 꽤 있는 편이다. 저걸 하나하나 다시 받아오고 있으면 내 복장이 터질지도 모르기 때문에 애초에 필요한 정보들(nickname, email, profile_picture) 등을 CustomOAuth2User가 바로 가지고 있는 것도 괜찮을 것 같다.

CustomOAuth2User

아래와 같은 형식으로 CustomOAuth2User 타입을 사용하게 할 수 있다.

@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .customUserType(GitHubOAuth2User.class, "github")
                    ...
                )
            );
    }
}

GitHubOAuth2User의 구성은 아래와 같다.

public class GitHubOAuth2User implements OAuth2User {
    private List<GrantedAuthority> authorities =
        AuthorityUtils.createAuthorityList("ROLE_USER");
    private Map<String, Object> attributes;
    private String id;
    private String name;
    private String login;
    private String email;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public Map<String, Object> getAttributes() {
        if (this.attributes == null) {
            this.attributes = new HashMap<>();
            this.attributes.put("id", this.getId());
            this.attributes.put("name", this.getName());
            this.attributes.put("login", this.getLogin());
            this.attributes.put("email", this.getEmail());
        }
        return attributes;
    }

    public String getId() {
        return this.id;
    }

    public void setId(String id) {
        this.id = id;
    }

    @Override
    public String getName() {
        return this.name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getLogin() {
        return this.login;
    }

    public void setLogin(String login) {
        this.login = login;
    }

    public String getEmail() {
        return this.email;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

갑자기 웬 Github이냐면 공식 레퍼런스 사이트가 예시로 든 코드가 그거라 이걸로 했다.

애초에 위와 같은 식으로 하면 DefaultOAuth2UserService를 사용하게 되는데 OAuth2UserService도 커스텀화가 가능하다.

그리고 그 편이 확장성이 훨씬 좋다.

OAuth2UserService는 엑세스 토큰으로 사용자 정보를 가져오는 일을 한다. 그리고 이를 OAuth2User타입의 AuthenticatedPrincipal을 반환한다.

이걸 SecurityContextHolder에 넣고, 컨트롤러에서 바로 받을 수 있다면 JWT 발급과의 연계가 무척이나 쉬워질 것 같다.

OAuth 2.0 Client

OAuth 2.0을 위한 클라이언트 역할을 하는 녀석이다.

아래와 같은 컴포넌트들을 관리하고 있다.

@EnableWebSecurity
public class OAuth2ClientSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Client(oauth2 -> oauth2
                .clientRegistrationRepository(this.clientRegistrationRepository())
                .authorizedClientRepository(this.authorizedClientRepository())
                .authorizedClientService(this.authorizedClientService())
                .authorizationCodeGrant(codeGrant -> codeGrant
                    .authorizationRequestRepository(this.authorizationRequestRepository())
                    .authorizationRequestResolver(this.authorizationRequestResolver())
                    .accessTokenResponseClient(this.accessTokenResponseClient())
                )
            );
    }
}

Spring Security가 언제나 그렇듯이 Manager와 Provider에 의해 동작한다.

각각 OAuth2AuthorizedClientManager OAuth2AuthorizedClientProvider 되시겠다.

아래는 4가지 타입의 인증을 지원하는 Provider를 생성하고 해당 Provider를 포함하는 Manager를 Bean으로 등록하는 코드다.

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

    OAuth2AuthorizedClientProvider authorizedClientProvider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                    .authorizationCode()
                    .refreshToken()
                    .clientCredentials()
                    .password()
                    .build();

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

    return authorizedClientManager;
}

OAuth 2.0 클라이언트의 핵심 컴포넌트들에 대해 알아보자.

ClientRegistration

위에서 살펴봤던 OAuth2의 공급자에 등록한 클라이언트 정보를 가지고 있다.

Client_id Client_secret Redirect_url Scope 등의 정보를 가지고 있어서 이 녀석에 접근할 수 있으면 엄청 쉽게 리디렉션 URL을 만들 수 있을 것 같다고 말했었다.

// 탐나는 정보들
public final class ClientRegistration {
	// ClientRegistration 식별용의 ID
    private String registrationId;
    // 내 클라이언트 ID
    private String clientId;
    // 내 클라이언트 Secret
    private String clientSecret;
    // 클라이언트를 인증 할 때 사용할 Http Method. Basic, Post, None 지원.
    private ClientAuthenticationMethod clientAuthenticationMethod;
    // Authorization Grant 방식이다. 웹 어플리케이션은 대개 authorization_code를 사용.
    private AuthorizationGrantType authorizationGrantType;
    // 인증 후에 유저의 user-agent를 리디렉트 시킬 url
    private String redirectUriTemplate;
    // 요청할 정보들
    private Set<String> scopes;
    private ProviderDetails providerDetails;  
    // 클라이언트 이름
    private String clientName;

    public class ProviderDetails {
    	// 인증 엔드포인트 uri
        private String authorizationUri;
        // 토큰 엔드포인트 uri
        private String tokenUri;
        private UserInfoEndpoint userInfoEndpoint;
        // JSON Web key 셋용 uri. 토큰의 signature가 있다.
        private String jwkSetUri;
        // OpenId provider 설정 정보
        private Map<String, Object> configurationMetadata;

        public class UserInfoEndpoint {
        	// 사용자 정보 엔드포인트 uri
            private String uri;
            // 사용자 정보 받을 때 쓸 Method. header, form, query 지원.
            private AuthenticationMethod authenticationMethod;
            // 사용자 정보 속성에 있는 이름. 사용자의 이름이나 id 등에 접근할 때 이용.
            private String userNameAttributeName;

        }
    }
}

ClientRegistrationRepository

ClientRegistration의 저장소이다.

우리가 spring.security.oauth2.client.registration.[registrationId] 하위에 속성들을 등록하면 ClientRegistration 인스턴스에 바인딩해 ClientRegistrationRepository 안에 집어넣는다.

그렇기 때문에 우리가 따로 무언가를 하지 않고 application.yml/properties에 속성을 작성해 놓으면 알아서 잘 동작하는 것.

중요한 점은 ClientRegistrationRepository 또한 Bean이기 때문에 의존성을 주입받을 수 있다는 것이다.

@Controller
public class OAuth2ClientController {

    @Autowired
    private ClientRegistrationRepository clientRegistrationRepository;

    @GetMapping("/")
    public String index() {
        ClientRegistration oktaRegistration =
            this.clientRegistrationRepository.findByRegistrationId("okta");
            
        ...

        return "index";
    }
}

찾고 찾던 기능이 여기 있었다 ㅋㅋ!! 그러나 이보다 더 편한 방법을 찾음...

OAuth2AuthorizedClient

인증된 클라이언트를 의미하는 클래스이다. 유저가 인증을 통해 클라이언트에 사용자 정보 접근 권한을 부여하면 해당 클라이언트를 인증된 클라이언트로 간주한다.

이 녀석은 OAuth2AccessTokenOAuth2RefreshToken(있으면)을 ClientRegistration, Principal과 함쳐 종합선물세트로 만들어 준다.

OAuth2AuthorizedClientRepository / OAuth2AuthorizedClientService

OAuth2AuthorizedClientRepository는 다른 웹 요청이 와도 동일한 OAuth2AuthorizedClient를 유지하는 역할을 한다.

OAuth2AuthorizedClientService는 어플리케이션 레벨에서 OAuth2AuthorizedClient를 관리한다.

중요한 점은, 둘 다 OAuth2AccessToken에 접근 가능하다는 점이다.

@Controller
public class OAuth2ClientController {

    @Autowired
    private OAuth2AuthorizedClientService authorizedClientService;

    @GetMapping("/")
    public String index(Authentication authentication) {
        OAuth2AuthorizedClient authorizedClient =
            this.authorizedClientService.loadAuthorizedClient("okta", authentication.getName());

        OAuth2AccessToken accessToken = authorizedClient.getAccessToken();

        ...

        return "index";
    }
}

OAuth2AuthorizedClientService의 기본 구현체는 InMemoryOAuth2AuthorizedClientService이다. 이름만 봐도 알 수 있듯이 이 녀석을 정보를 메모리에 저장한다.

메모리에 저장하는 방법 외에 db를 사용하는 방법도 있다.

JdbcOAuth2AuthorizedClientService를 설정해주면 된다.

저장되는 내용에 대해서는 여기를 참조하자.

현업에서는 Redis를 이용해 저장하는 경우가 가장 많다고 한다.

OAuth2AuthorizedClientManager / OAuth2AuthorizedClientProvider

OAuth2AuthorizedClientManagerOAuth2AuthorizedClient를 관리하는 인터페이스이다.

다음과 같은 일을 한다.

  • OAuth2AuthorizedClientProvider를 통해 클라이언트에 권한 부여
  • OAuth2AuthorizedClientService / OAuth2AuthorizedClientRepositoryOAuth2AuthorizedClient 저장을 위임
  • 클라이언트 권한 부여에 성공하면 OAuth2AuthorizationSuccessHandler에 처리 위임
  • 실패하면 OAuth2AuthorizationFailureHandler에 위임

언제나 그렇듯 실질적인 권한 부여 방법 등은 OAuth2AuthorizedClientProvider에 존재한다.

결국 전체적인 동작 방식은 아래와 같다.

  1. 인증에 성공하면 OAuth2AuthorizationSuccessHandler로 처리 위임.
  2. 해당 핸들러는 OAuth2AuthorizedClientRepositoryOAuth2AuthorizedClient를 저장.
  3. 실패하면 RemoveAuthorizedClientOAuth2AuthorizationFailureHandlerOAuth2AuthorizedClientRepository에 있는 OAuth2AuthorizedClient를 삭제.

SuccessHandler와 FailureHandler는 커스텀 가능하다.

.setAuthorizationSuccessHandler(OAuth2AuthorizationSuccessHandler)
.setAuthorizationFailureHandler(OAuth2AuthorizationFailureHandler)

OAuth2AuthorizedClientManager는 추가적으로 OAuth2AuthorizeRequest의 속성들을 Map<String, Object> 타입의 Map에 매핑한다. 매핑된 값은 OAuth2AuthorizationContext에 담긴다. 보통 password 인증 방식처럼 provider에게 특정 정보를 전달해야 할 때 종종 이용된다.

구현

찾아 볼 만큼 본 것 같으니 구현을 해보자.

OAuth2 로그인을 위해서는 일단 사용할 공급자 플랫폼에서 OAuth2 인증용 어플리케이션을 만들어줘야 하는데, 이 부분은 워낙 많은 블로그에 자세히 나와있으니 생략한다.

본 프로젝트는 이동욱님의 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스'에 나온 코드에서 내가 필요한 부분만 추출해 변경했다.

JDK는 17을 사용했는데, 8로 해도 문제는 없을 듯.

Spring Boot 2.7.8 버전인가를 사용했고, Spring Security도 아직 WebSecurityConfigurerAdapterantMatchers가 존재하는 버전이다.

만약 Spring Security 6.0 이상으로 구현하고 싶다면, WebSecurityConfigurerAdapter의 대체 구현에 대해 찾아보길 바라고 antMatchersrequestMatchers였나? 이걸로 통합됐을 거다.

Gradle

	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    compileOnly 'org.springframework.boot:spring-boot-starter-mustache'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

application.yml

spring:
  datasource:
    username: sa
    password:
    url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create
  security:
    oauth2:
      client:
        registration:
          google:
            clientId: 클라ID
            clientSecret: 클라Secret
            scope: profile, email
          kakao:
            client-name: 클라이름
            client-id: 클라ID
            client-secret: 클라Secret
            client-authentication-method: POST
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/kakao
            scope:
              - profile_nickname
              - account_email
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id

index.mustache

<!DOCTYPE HTML>
<html>
<head>
    <title>소셜로그인 테스트</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />

    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>
<h1>과연 될 것인가</h1>

<div class="col-md-12">
    <div class="row">
        <div class="col-md-6">
            {{#userName}}
                Logged in as: <span id="user">{{userName}}</span>
                <a href="/logout" class="btn btn-info active" role="button">Logout</a>
            {{/userName}}
            {{^userName}}
                <a href="/login/social/google" class="btn btn-success active" role="button">Google Login</a>
                <a href="/oauth2/authorization/kakao" class="btn btn-secondary active" role="button">Kakao Login</a>
            {{/userName}}
        </div>
    </div>
    <br>
</div>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>

</body>
</html>

FrontController

프론트 파트는 만들기 싫으니 뷰 리졸버를 통해 위의 index.mustache와 함께 일하며 화면을 렌더링 해 줄 컨트롤러이다.

import com.example.oauth2practice2.domain.dto.SessionUser;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
@RequiredArgsConstructor
public class FrontController {
    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model) {
        SessionUser user = (SessionUser) httpSession.getAttribute("user");
        if(user != null){
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }
}

LoginController

로그인 관련한 컨트롤러이다.

OAuth2-Client 내부 컨트롤러로 리디렉션 해주고, 인증에 성공하면 토큰 발급도 해준다.
가입 절차까지도 이쪽에서 처리하고 싶다면 대충 CustomOAuthUser 객체 던지고 이리로 와서 따로 처리해도 된다.

import java.io.IOException;
import javax.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/login")
public class LoginController {

    @GetMapping("/social/{provider}")
    public void login(HttpServletResponse response, @PathVariable String provider) throws IOException {
    	// 내가 찾은 답은 이거였다 ㅋㅋ 허무...
        response.sendRedirect("/oauth2/authorization/" + provider);
    }
    
    @GetMapping("/authorized")
    public ResponseEntity<String> authorized(@AuthenticationPrincipal CustomOAuth2User user) {
    	// 디버깅해서 확인해보면 scope 내에 있던 정보들 다 들어가 있는 거 볼 수 있다
    	OAuthAttributes attributes = user.getOAuthAttributes();
        return ResponseEntity.ok("대충 토큰 발급했다고 치자.");
    }
    
}

User Entity

평범한 Entity

import com.example.oauth2practice2.domain.type.Role;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class User{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String email;

    private String picture;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    public User update(String name, String picture) {
        this.name = name;
        this.picture = picture;

        return this;
    }

    public String getRoleKey() {
        return this.role.getKey();
    }
}

Role

위의 Entity에서 써먹을 Role이다.

@Getter
@RequiredArgsConstructor
public enum Role {

    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String title;

}

UserRepository

이중가입을 하면 안되니까 확인용으로 사용할 조회 쿼리 생성기

import com.example.oauth2practice2.domain.entity.User;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

SecurityConfig

다른 거 다 별로 안 중요하고,

혹시 모를 csrf 검증에 대비한 csrf 비활성화,
로그인 관련 컨트롤러에도 접근 못하면 안되니까 접근 허용 url 설정,
OAuth2Login 설정과 UserInfo를 받고 난 후 사용할 OAuth2UserService 설정,
성공했을 시에 토큰 발급 절차를 위해 접근할 api 엔드포인트 url 설정

이렇게가 중요하다.

import com.example.oauth2practice2.service.CustomOAuth2UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .headers().frameOptions().disable()
            .and()
            .authorizeRequests()
            .antMatchers("/**", "/login/**", "/error").permitAll()
            .anyRequest().authenticated()
            .and()
            .logout().logoutSuccessUrl("/")
            .and()
            .exceptionHandling()
            .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
            .and()
            .oauth2Login().userInfoEndpoint().userService(customOAuth2UserService)
            .and()
            .defaultSuccessUrl("/login/authorized");
    }
}

CustomOAuth2UserService

엑세스 토큰을 기반으로 사용자 정보를 받은 다음 권한 및 사용자 정보를 필요한 형태로 가공/매핑한다.

import com.example.oauth2practice2.domain.UserRepository;
import com.example.oauth2practice2.domain.dto.OAuthAttributes;
import com.example.oauth2practice2.domain.dto.SessionUser;
import com.example.oauth2practice2.domain.entity.User;
import java.util.Collections;
import javax.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private final UserRepository userRepository;
    private final HttpSession httpSession;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
            .getUserInfoEndpoint().getUserNameAttributeName();

        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

		// 이 부분, 연결되는 컨트롤러에서 받아서 처리해도 무방
        User user = saveOrUpdate(attributes);
        // index.mustache에서 세션을 체크하고 있어서 넣었는데, 딱히 필요없음
        httpSession.setAttribute("user", new SessionUser(user));

        return new CustomOAuth2User(
            Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
            attributes.getAttributes(),
            attributes.getNameAttributeKey(),
            attributes);
    }


    private User saveOrUpdate(OAuthAttributes attributes) {
        User user = userRepository.findByEmail(attributes.getEmail())
            .map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
            .orElse(attributes.toEntity());

        return userRepository.save(user);
    }
}

CustomOAuth2User

간단하게 DefaultOAuth2User를 상속받아 구현했다.
또 depth가 깊은 attribute 받아서 매핑하는 짓을 반복하고 싶지는 않았기 때문.

import java.util.Collection;
import java.util.Map;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;


@Getter
public class CustomOAuth2User extends DefaultOAuth2User {

    private OAuthAttributes oAuthAttributes;

    public CustomOAuth2User(
        Collection<? extends GrantedAuthority> authorities,
        Map<String, Object> attributes, String nameAttributeKey,
        OAuthAttributes oAuthAttributes) {
        super(authorities, attributes, nameAttributeKey);
        this.oAuthAttributes = oAuthAttributes;
    }
}

OAuthAttributes

JSON으로 덩어리 째 날아온 사용자 정보를 매핑한다.

import com.example.oauth2practice2.domain.entity.User;
import com.example.oauth2practice2.domain.type.Role;
import java.util.Map;
import lombok.Builder;
import lombok.Getter;

@Getter
public class OAuthAttributes {

    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String name;
    private String email;
    private String picture;

    @Builder
    public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name,
        String email, String picture) {
        this.attributes = attributes;
        this.nameAttributeKey = nameAttributeKey;
        this.name = name;
        this.email = email;
        this.picture = picture;
    }

    public static OAuthAttributes of(String registrationId, String userNameAttributeName,
        Map<String, Object> attributes) {
        if ("kakao".equals(registrationId)) {
            return ofKakao("id", attributes);
        }

        return ofGoogle(userNameAttributeName, attributes);
    }

    private static OAuthAttributes ofGoogle(String userNameAttributeName,
        Map<String, Object> attributes) {
        return OAuthAttributes.builder()
            .name((String) attributes.get("name"))
            .email((String) attributes.get("email"))
            .picture((String) attributes.get("picture"))
            .attributes(attributes)
            .nameAttributeKey(userNameAttributeName)
            .build();
    }

    private static OAuthAttributes ofKakao(String userNameAttributeName,
        Map<String, Object> attributes) {
        Map<String, Object> kakaoAccount = (Map<String, Object>)attributes.get("kakao_account");
        Map<String, Object> kakaoProfile = (Map<String, Object>)kakaoAccount.get("profile");

        return OAuthAttributes.builder()
            .name((String) kakaoProfile.get("nickname"))
            .email((String) kakaoAccount.get("email"))
            .attributes(attributes)
            .nameAttributeKey(userNameAttributeName)
            .build();
    }

    public User toEntity() {
        return User.builder()
            .name(name)
            .email(email)
            .picture(picture)
            .role(Role.GUEST)
            .build();
    }
}

Github

Repo

References

profile
시간아 늘어라 하루 48시간으로!

0개의 댓글