認証方式の選択
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の依存関係を書きました。
<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つのクラスでした。
- AuthorizationServerConfiguration
- ResourceServerConfiguration
- OAuth2SecurityConfiguration
OAuth2の登場人物(サーバ)では、まず認可してトークンを発行するAuthorizationServerと、それによって実際に、データを提供するResourceServerがありましたが、1番のConfigurationクラスと2番のConfigurationによって、Spring Securityが用意しているサーバ機能の設定を行って、自分の意図どおりに動作させる、という仕組みのようです。
3番のConfigurationクラスは、直接的にOAuth2に関連しているわけではなく、Spring Security自体の設定と動作(例えばAuthenticationManager)に関係していました。
これをそのまま自分のプログラムに適当なフォルダ(package)を作って入れれば、@configurationによってSpringに読み込まれて動かすことができます。(できました。)
一応、ダウンロードしたソース・プログラムも貼っておきます。
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");
}
}
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());
}
}
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のデフォルトの環境では使えないのです、ズコーっ!
こちらも、共感頂ける方は、こちらに。
長文お付き合いありがとうございました!
(終わり)