search
LoginSignup
46

More than 5 years have passed since last update.

posted at

Spring BootのOAuth2.0機能を利用して、Rest APIの認証・認可フローを実装する

認証方式の選択

Webの認証方式では、OpenID Connectを使う、というのが(Googleの実装などを見ても)ベスト・プラクティスのようですが、皆さん、実際にはどうしてらっしゃるでしょうか。

僕は、Springをサーバサイドで愛用しているのですが、SpringのRestサーバ機能では、まだOpenID connectは実装されていないようです。

セキュリティは安全な方式を選択すべき一方で、サポートされているものから選ぶしかないという現実もありますね。

最も簡単な認証として、未だに広くは、ユーザ登録時に振り出した固定のシークレットキーをそのままGET/POST時に送信する、というシステムも多いようですが、リプレイ攻撃に対しては変化するキーのほうがベター、ということで今回は、Spring Boot(Web service/security)のOAuth2.0機能を利用して、一時的なセッションキーを振り出す方式で実装することにしました。
#本質的に変わらなかったらごめんなさい…

OAuth2.0

OAuth2.0には複数の認可フローがあります。今回使うのは、この中の『Resource Owner Password Credentials』です。
#OAuth1.0/2.0について知りたい方はこちらをご参照ください

IPAのセキュアプログラミングの解説を見ると分かりやすく、かつ「難がある」という記載のある認可フローですが、ここに出てくる「認可サーバ・リソースサーバ・アクセス主体」が自分のアプリケーションである場合には使うことが許容されるという条件で、今回は利用します。
#OAuth2.0の仕様にも、「The credentials should only be used when there is a high degree of trust between the resource owner and the client」という記載があります。

私自身は、「Web API The Good Parts」という書籍を読んで、この方式を知りましたが、実際に、この方式を紹介している記事も他に幾つかあるようです。[1] [2]

環境・ツール

サーバはSpring Bootを使っています。こちらのSpring公式の『Spring Boot and OAuth2』というTutorialに従って、Mavenの依存関係を書きました。

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security.oauth</groupId>
    <artifactId>spring-security-oauth2</artifactId>
</dependency>

#公式ページのOAuth2は、ProviderとしてFacebookやGithubを想定しているので、Resource Owner Password Credentialsとは異なる認可フローの作り方ですが、依存関係は同じもので大丈夫です。

また、クライアントは主に別実装のサーバが多数ありますが、Web画面からも利用していて、そこではAngular2を使用しています。
Web画面の動作確認にはPostmanを併用しました。

参考にしたソースコード

今回は、この記事のサンプルプログラムを参考にしました。

また、OAuth2に関しては、また別の公式の全体的なガイド『OAuth 2 Developers Guide』があります。このページで紹介されているサンプルプログラムをダウンロードしたソースを利用すると、さらに高度な制御ができると思います。

実際の改修

さきほどの記事のサンプルプログラムで、今回のOAuth2の認可フローに必要なのは、結局3つのクラスでした。

  1. AuthorizationServerConfiguration
  2. ResourceServerConfiguration
  3. OAuth2SecurityConfiguration

OAuth2の登場人物(サーバ)では、まず認可してトークンを発行するAuthorizationServerと、それによって実際に、データを提供するResourceServerがありましたが、1番のConfigurationクラスと2番のConfigurationによって、Spring Securityが用意しているサーバ機能の設定を行って、自分の意図どおりに動作させる、という仕組みのようです。

3番のConfigurationクラスは、直接的にOAuth2に関連しているわけではなく、Spring Security自体の設定と動作(例えばAuthenticationManager)に関係していました。

これをそのまま自分のプログラムに適当なフォルダ(package)を作って入れれば、@configurationによってSpringに読み込まれて動かすことができます。(できました。)

一応、ダウンロードしたソース・プログラムも貼っておきます。

AuthorizationServerConfiguration.java

package com.websystique.springmvc.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.approval.UserApprovalHandler;
import org.springframework.security.oauth2.provider.token.TokenStore;

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {

    private static String REALM="MY_OAUTH_REALM";

    @Autowired
    private TokenStore tokenStore;

    @Autowired
    private UserApprovalHandler userApprovalHandler;

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

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {

        clients.inMemory()
            .withClient("my-trusted-client")
            .authorizedGrantTypes("password", "authorization_code", "refresh_token", "implicit")
            .authorities("ROLE_CLIENT", "ROLE_TRUSTED_CLIENT")
            .scopes("read", "write", "trust")
            .secret("secret")
            .accessTokenValiditySeconds(120).//Access token is only valid for 2 minutes.
            refreshTokenValiditySeconds(600);//Refresh token is only valid for 10 minutes.
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(tokenStore).userApprovalHandler(userApprovalHandler)
                .authenticationManager(authenticationManager);
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
        oauthServer.realm(REALM+"/client");
    }

}

ResourceServerConfiguration.java

package com.websystique.springmvc.security;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.error.OAuth2AccessDeniedHandler;

@Configuration
@EnableResourceServer
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

    private static final String RESOURCE_ID = "my_rest_api";

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        resources.resourceId(RESOURCE_ID).stateless(false);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.
        anonymous().disable()
        .requestMatchers().antMatchers("/user/**")
        .and().authorizeRequests()
        .antMatchers("/user/**").access("hasRole('ADMIN')")
        .and().exceptionHandling().accessDeniedHandler(new OAuth2AccessDeniedHandler());
    }

}

OAuth2SecurityConfiguration.java

package com.websystique.springmvc.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.approval.ApprovalStore;
import org.springframework.security.oauth2.provider.approval.TokenApprovalStore;
import org.springframework.security.oauth2.provider.approval.TokenStoreUserApprovalHandler;
import org.springframework.security.oauth2.provider.request.DefaultOAuth2RequestFactory;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore;

@Configuration
@EnableWebSecurity
public class OAuth2SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private ClientDetailsService clientDetailsService;

    @Autowired
    public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
        .withUser("bill").password("abc123").roles("ADMIN").and()
        .withUser("bob").password("abc123").roles("USER");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
        .csrf().disable()
        .anonymous().disable()
        .authorizeRequests()
        .antMatchers("/oauth/token").permitAll();
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }


    @Bean
    public TokenStore tokenStore() {
        return new InMemoryTokenStore();
    }

    @Bean
    @Autowired
    public TokenStoreUserApprovalHandler userApprovalHandler(TokenStore tokenStore){
        TokenStoreUserApprovalHandler handler = new TokenStoreUserApprovalHandler();
        handler.setTokenStore(tokenStore);
        handler.setRequestFactory(new DefaultOAuth2RequestFactory(clientDetailsService));
        handler.setClientDetailsService(clientDetailsService);
        return handler;
    }

    @Bean
    @Autowired
    public ApprovalStore approvalStore(TokenStore tokenStore) throws Exception {
        TokenApprovalStore store = new TokenApprovalStore();
        store.setTokenStore(tokenStore);
        return store;
    }

}


これで、OAuth2のサーバ機能としては動かせるのですが、クライアント機能と合わせると、まだまだ、はまりどころがあったので、少しご紹介しておきます。
(長いので、別記事にしました)

この後のはまりどころ(1)HTTP OPTIONSの送信

最初のPOSTを送信する前に、Angular2側から動かしたところ、HTTP POSTの代わりにHTTP OPTIONSが送信され、エラーに…

ナニコレ、と共感頂ける方は、こちらに

この後のはまりどころ(2)sync/await機能の導入

①access_token取得後にトークンを保存し、即時に、②そのトークンを使ってサーバにリクエストを投げる処理を書きたいところですが、Angular2を使うと、http通信は非同期処理で、戻り値はObservableまたは変換したPromiseオブジェクトだと思います。

そうすると、①でトークンを保存する前に、②でトークンを参照してしまい、エラーになる、という状態に(そのままだと)なってしまいます。

こういうときは待ち合わせ機能使いたい…Typescriptではsync/await機能が用意されていますので、これを使いたい!よし!…と思ったら、Angular2のデフォルトの環境では使えないのです、ズコーっ!

こちらも、共感頂ける方は、こちらに

長文お付き合いありがとうございました!
(終わり)

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
46