2016년 12월 12일 월요일

spring security를 이용한 oauth 2.0 구현


spring security를 이용한 대부분의 예제들이 spring boot를 이용하여 구현이 되어 있다.
하지만 꼭 boot로 구동을 해야 되는 건 아니니 프로젝트별 구성에 따라 맞게 구현을 하는게 좋을 것이다.

본인의 경우 boot를 지향하지 않기 때문에 tomcat 기반으로 구성을 진행했다.

기존에 spring security를 이용하여 maven 프로젝트를 구성하고 있다면 아래의 dependency만 추가해 주면 된다.
사실 oauth를 구성하는데 org.springframework.security.oauth 해당 라이브러리만 있으면 되는 것이나 관련 dendency추가해준다.
commons-lang3는 scope나 grant-types에 대해서 ,를 구분자로 리스트를 입력되어 있는 경에 대한 StringUtil이나 token발급에 필요한 util등을 사용한다.

================POM.xml===========================
                     <!-- spring security-->
                      <dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
<version>${springframework.security-version}</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
<version>${springframework.security-version}</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
<version>${springframework.security-version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
<version>${springframework.security-version}</version>
</dependency>

       <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>${commons-lang3.version}</version>
        </dependency>
        <!-- oauth -->
        <dependency>
            <groupId>org.springframework.security.oauth</groupId>
            <artifactId>spring-security-oauth2</artifactId>
            <version>${spring-security-oauth2-version}</version>
        </dependency>
        <dependency>
            <groupId>com.jayway.restassured</groupId>
            <artifactId>rest-assured</artifactId>
            <version>${rest-assured.version}</version>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <artifactId>commons-logging</artifactId>
                    <groupId>commons-logging</groupId>
                </exclusion>
            </exclusions>
        </dependency>
===================================================

실제 Oauth의 구현은 간단하다.
org.springframework.security.oauth라이브러리 내에 기능 구현이 거의 대부분이 되어 있다고해도 과언이 아니다.
customizing을 진행하기 위해서는 기능에 대한 구현체에 대한 확인이 필요하다.
하지만 그 일이 만만치가 않다. interface기반으로 실제 구현체들이 주입이 되는 방식으로 코드들이 숨겨져 있어서 일일이 찾아서 확인해야 한다.

https://projects.spring.io/spring-security-oauth/docs/oauth2.html에 설명된 아래의 3개의 configurer를 중심을 trace를 해 간다면 좀 더 쉽게 이해가 되었다.

  • ClientDetailsServiceConfigurer: a configurer that defines the client details service. Client details can be initialized, or you can just refer to an existing store.
  • AuthorizationServerSecurityConfigurer: defines the security constraints on the token endpoint.
  • AuthorizationServerEndpointsConfigurer: defines the authorization and token endpoints and the token services.

ClientDetailsServiceConfigurer
 - clientDetailsService에 대한 config를 설정
 - clientDetails에 대한 clientId나 grantType,scopes,TokenValiditySeconds등에 대해서 설정을 로직에 대해서 주입
 - inMemory/jdbc/withClientDetails 3가지 방식을 제공하는 것으로 보아도 무방하다.
 - inMemory source에서 clientDetails에 대해서 정의를 하고 말그대로 메모리에 올려서 처리하는 방식
 - jdbc jdbcTemplate(spring jdbcMapper) 방식의 구현체
 - withClientDetails bean로 ClientDetailsService구현체를 주입을 하던 custom ClientDetailsService의 구현체를 작성하여 주입이 가능/ 본인의 경우 해당 부분을 구현하여 처리

===================OAuth2AuthorizationServerConfig.java=============
@Override
    public void configure(final ClientDetailsServiceConfigurer clients) throws Exception {// @formatter:off
// clients
// .jdbc(dataSource).withClient("sampleClientId").authorizedGrantTypes("implicit")
// .scopes("read", "write", "foo", "bar").autoApprove(false).accessTokenValiditySeconds(3600)
//
// .and().withClient("fooClientIdPassword").secret("secret")
// .authorizedGrantTypes("password", "authorization_code", "refresh_token","client_credentials").scopes("foo", "read", "write")
// .accessTokenValiditySeconds(3600) // 1 hour
// .refreshTokenValiditySeconds(2592000) // 30 days
//
// .and().withClient("barClientIdPassword").secret("secret")
// .authorizedGrantTypes("password", "authorization_code", "refresh_token").scopes("bar", "read", "write")
// .accessTokenValiditySeconds(3600) // 1 hour
// .refreshTokenValiditySeconds(2592000) // 30 days
// ;
        //clients.jdbc(dataSource).;
clients.withClientDetails(clientDetailsService);
}


AuthorizationServerSecurityConfigurer
- token endpoint에 대한 대한 제약을 config
- 접근제어 및 접근 제어 방식에 대한 정의

===================OAuth2AuthorizationServerConfig.java=============
 @Override
    public void configure(final AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
        //oauthServer.passwordEncoder(passwordEncoder)
        oauthServer.tokenKeyAccess("permitAll()").checkTokenAccess("isAuthenticated()");
     
    }



AuthorizationServerEndpointsConfigurer
-

===================OAuth2AuthorizationServerConfig.java=============
@Override
    public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        // @formatter:off
final TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(tokenEnhancer()));
endpoints.approvalStore(approvalStore).tokenStore(tokenStore).authorizationCodeServices(authorizationCodeServices())
.tokenEnhancer(tokenEnhancerChain).authenticationManager(authenticationManager);
// @formatter:on
    }


여기서 중요한 포인트는 EndPoint라는 건데 결론적으로는 controller와 동일한 기능을 처리 한다.

FrameworkEndpointHandlerMapping라는 클래스가 RequestMappingHandlerMapping를 상속 받아서 @FrameworkEndpoint라는 SpringSecurity에서 생성한 인터페이스 기반으로 Endpoint를 조회해서 requestMapping을 등록하는 역활을 한다.
즉 @FrameworkEndpoint를 붙혀주면 해당 클래스는 FrameworkEndpointHandlerMapping를 통해서 controller와 같이 동작한다고 보면 된다.

기존에 라이브러리에서 제공되는 FrameworkEndpoint의 리스트 아래와 같다.
AuthorizationEndpoint
 - authorize_code 발급을 위한 로직
 - /oauth/authorize api에 대한 기능 구현
CheckTokenEndpoint
 - accessToken에 대한 체크 로직
 - /oauth/check_token api에 대한 기능 구현
TokenEndpoint
 - accessToken를 발급하는 로직
 - /oauth/token api에 대한 기능 구현
TokenKeyEndpoint
 - JWT관련된 tokenKey 관련 기능 (사용안함)
 - /oauth/token_key api에 대한 기능 구현
WhitelabelApprovalEndpoint
 - authorize_code 발급시 scope에 대한 approval 를 정의하는 UI페이지에 대한 처리
 - /oauth/confirm_access 페이지에 대한 기능 구현
WhitelabelErrorEndpoint
 - error페이지에 대한 기능
 - /oauth/error 페이지에 대한 기능 구현


아래는 필요한 api에 대하서 작성한 예제이다.
 - 물론 해당 API를 controller로 구현하여도 무방하다.
 - 하지만 기존 Oauth코드와 통일성을 유지하기 위하여 custom API에 대해서 동일하게 FrameworkEndpoint 형태로 개발 진행
===============ClientManageEndpoint .java=============
@FrameworkEndpoint
public class ClientManageEndpoint {
    @Autowired
    private ClientDetailsService clientDetailsService;
    @Autowired
    private ClientRegistrationService clientRegistrationService;
 
    protected final Log logger = LogFactory.getLog(getClass());

    private WebResponseExceptionTranslator exceptionTranslator = new DefaultWebResponseExceptionTranslator();

    public ClientManageEndpoint(ClientDetailsService clientDetailsService) {
        this.clientDetailsService = clientDetailsService;
    }
 
    /**
     * @param exceptionTranslator the exception translator to set
     */
    public void setExceptionTranslator(WebResponseExceptionTranslator exceptionTranslator) {
        this.exceptionTranslator = exceptionTranslator;
    }

    @RequestMapping(value = "/oauth/clients/{clientId}",  method = RequestMethod.GET)
    @ResponseBody
    public Object checkToken(@PathVariable("clientId") String clientId) {

        ClientDetails client = clientDetailsService.loadClientByClientId(clientId);
        if (client == null) {
            throw new InvalidClientException("Client was not recognised");
        }
        return client;
    }
    ...중략

Oauth를 위한 Service를 구성하는 있어서 springSecurity에서 제공하는 jdbc 방식을 이용하여 구현한다면 개발 공수는 거의 없다고 할수있다. 하지만 customizing을 하는데 있어 제약이 많은 관계로 요구사항이 많은 프로젝트라고 한다면 Service를 구현하여  customizing하는 것을 추천한다.

저는 기존에 사용하던 Mybatis를 기반으로 구현을 진행하였다.

=================clientDetailMapper.groovy

@Mapper
public interface ClientDetailsMapper {

    @Results(id="ClientDetailsResult", value = [
        @Result(property = "clientId", column = "client_id"),
        @Result(property = "clientSecret", column = "client_secret"),
        @Result(property = "resourceIds", column = "resource_ids", typeHandler=CommaDelimitedListTypeHandler.class),
        @Result(property = "scope", column="scope", typeHandler=CommaDelimitedListTypeHandler.class),
        @Result(property = "authorizedGrantTypes", column = "authorized_grant_types", typeHandler=CommaDelimitedListTypeHandler.class),
        @Result(property = "registeredRedirectUris", column = "web_server_redirect_uri",typeHandler=CommaDelimitedListTypeHandler.class),
        @Result(property = "authorities", column = "authorities", jdbcType=JdbcType.VARCHAR, typeHandler=AuthorityListTypeHandler.class),
        @Result(property = "accessTokenValiditySeconds", column = "access_token_validity"),
        @Result(property = "refreshTokenValiditySeconds", column = "refresh_token_validity"),
        @Result(property = "additionalInformation", column = "additional_information", typeHandler=JsonMappedTypeHandler.class),

        @Result(property = "autoApproveScopes", column = "autoapprove",typeHandler=CommaDelimitedListTypeHandler.class)
    ])
    @Select("""<script>
        SELECT client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove
        FROM oauth_client_details
        WHERE client_id = #{clientId}
    </script>""")
    public BaseClientDetails selectClientDetails(String clientId);
 
    @Insert("""<script>
        INSERT INTO oauthdb.oauth_client_details(
            client_id
            ,client_secret
            ,resource_ids
            ,scope
            ,authorized_grant_types
            ,web_server_redirect_uri
            ,authorities
            ,access_token_validity
            ,refresh_token_validity
            ,additional_information
            ,autoapprove
        ) VALUES (
            #{clientId}
            ,#{clientSecret}
            ,#{resourceIds,jdbcType=VARCHAR, typeHandler=com.oneplat.oap.oauth.common.mybatis.handler.CommaDelimitedListTypeHandler}
            ,#{scope,jdbcType=VARCHAR, typeHandler=com.oneplat.oap.oauth.common.mybatis.handler.CommaDelimitedListTypeHandler}
            ,#{authorizedGrantTypes,jdbcType=VARCHAR, typeHandler=com.oneplat.oap.oauth.common.mybatis.handler.CommaDelimitedListTypeHandler}
            ,#{registeredRedirectUri,jdbcType=VARCHAR, typeHandler=com.oneplat.oap.oauth.common.mybatis.handler.CommaDelimitedListTypeHandler}
            ,#{authorities,jdbcType=VARCHAR, typeHandler=com.oneplat.oap.oauth.common.mybatis.handler.AuthorityListTypeHandler}
            ,#{accessTokenValiditySeconds}
            ,#{refreshTokenValiditySeconds}
            ,#{additionalInformation,jdbcType=VARCHAR, typeHandler=com.oneplat.oap.oauth.common.mybatis.handler.JsonMappedTypeHandler}
            ,#{autoApproveScopes,jdbcType=VARCHAR, typeHandler=com.oneplat.oap.oauth.common.mybatis.handler.CommaDelimitedListTypeHandler}
        )
    </script>""")
    public int insertClientDetails(ClientDetails clientDetails);
 
    @ResultMap("ClientDetailsResult")
    @Select("""<script>
        SELECT client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove
        FROM oauth_client_details
    </script>""")
    public List<ClientDetails> selectClientDetailsList();
 
}


================ClientDetailServiceImpl.java===========
@Service("ClientDetailServiceImpl")
public class ClientDetailServiceImpl implements ClientDetailsService, ClientRegistrationService {
    @Autowired
    ClientDetailsMapper clientDetailsMapper;

    @Override
    public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
        // TODO Auto-generated method stub
        return clientDetailsMapper.selectClientDetails(clientId);
    }
 
    @Override
    public void addClientDetails(ClientDetails clientDetails) throws ClientAlreadyExistsException {
        // TODO Auto-generated method stub
        clientDetailsMapper.insertClientDetails(clientDetails);
     
    }

    @Override
    public void updateClientDetails(ClientDetails clientDetails) throws NoSuchClientException {
        // TODO Auto-generated method stub
     
    }

    @Override
    public void updateClientSecret(String clientId, String secret) throws NoSuchClientException {
        // TODO Auto-generated method stub
     
    }

    @Override
    public void removeClientDetails(String clientId) throws NoSuchClientException {
        // TODO Auto-generated method stub
     
    }

    @Override
    public List<ClientDetails> listClientDetails() {
        // TODO Auto-generated method stub
        return clientDetailsMapper.selectClientDetailsList();
    }
}


=====================OAuth2AuthorizationServerConfig.java

@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private Environment env;
    
    @Autowired
    DataSource dataSource;

    @Autowired
    @Qualifier("authenticationManagerBean")
    private AuthenticationManager authenticationManager;

    @Value("classpath:schema.sql")
    private Resource schemaScript;
    
    @Autowired PasswordEncoder passwordEncoder;
    
    @Autowired
    ApprovalStore approvalStore;
    
    @Autowired
    TokenStore tokenStore;
    
    @Autowired
    @Qualifier("ClientDetailServiceImpl")
    ClientDetailsService clientDetailsService;
    
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    
    @Override
    public void configure(final AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
        //oauthServer.passwordEncoder(passwordEncoder)
        oauthServer.tokenKeyAccess("permitAll()").checkTokenAccess("isAuthenticated()");
        
    }

    @Override
    public void configure(final ClientDetailsServiceConfigurer clients) throws Exception {// @formatter:off
// clients
// .jdbc(dataSource).withClient("sampleClientId").authorizedGrantTypes("implicit")
// .scopes("read", "write", "foo", "bar").autoApprove(false).accessTokenValiditySeconds(3600)
//
// .and().withClient("fooClientIdPassword").secret("secret")
// .authorizedGrantTypes("password", "authorization_code", "refresh_token","client_credentials").scopes("foo", "read", "write")
// .accessTokenValiditySeconds(3600) // 1 hour
// .refreshTokenValiditySeconds(2592000) // 30 days
//
// .and().withClient("barClientIdPassword").secret("secret")
// .authorizedGrantTypes("password", "authorization_code", "refresh_token").scopes("bar", "read", "write")
// .accessTokenValiditySeconds(3600) // 1 hour
// .refreshTokenValiditySeconds(2592000) // 30 days
// ;
        //clients.jdbc(dataSource).;
clients.withClientDetails(clientDetailsService);
} // @formatter:on
    @Override
    public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        // @formatter:off
final TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(tokenEnhancer()));
endpoints.approvalStore(approvalStore).tokenStore(tokenStore).authorizationCodeServices(authorizationCodeServices())
.tokenEnhancer(tokenEnhancerChain).authenticationManager(authenticationManager);
// @formatter:on
    }
//    @Bean
//    public ClientDetailsService clientDetailsService() {
//        return new JdbcClientDetailsService(dataSource);
//    }
//    @Bean
//    public ApprovalStore approvalStore() {
//        return new JdbcApprovalStore(dataSource);
//    }
    @Bean
    public AuthorizationCodeServices authorizationCodeServices() {
        return new JdbcAuthorizationCodeServices(dataSource);
    }
    
    @Bean
    @Primary
    public DefaultTokenServices tokenServices() {
        final DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setTokenStore(tokenStore);
        defaultTokenServices.setSupportRefreshToken(true);
        return defaultTokenServices;
    }
    
    @Bean
    public TokenEnhancer tokenEnhancer() {
        return new CustomTokenEnhancer();
    }
    
//    @Bean
//    public TokenStore tokenStore() {
//        return new JdbcTokenStore(dataSource);
//    }
    
    //신규 EndPoint 등록
    @Bean
    public ClientManageEndpoint clientManageEndpoint() {
        return new ClientManageEndpoint(clientDetailsService);
    }

}


댓글 없음:

댓글 쓰기