ルーク・テイラー「Google App EngineでSpring Securityを使う」
ちょーっと古い記事なのだけれど(実際今どきだったらOAuthとかだよね)、Spring Source社のブログ記事に“Spring Security in Google App Engine”というのがあって、気になってはいたけれどブックマークでとっておいてそのままになっていた。
今回はその記事を訳出してみた。Google社やSpring Source社のドキュメントは、他のプロジェクトとくらべて、使用する単語や時制、構文に気をつけていて、非常に読みやすい。それでもまぁ訳しておいた方がのちのち参照しやすいのもたしか。その程度の意図で。
原典は、“Spring Security in Google App Engine”(SpringSource Blog)。ルーク・テイラー氏の投稿です。
* * *
Google App EngineでSpring Securityを使う
Spring Securityは優れたカスタマイズ性で知られています。そこでまずGoogle App Engine(GAE)上でこのフレームワークを動かしてみることを考えました。シンプルなアプリケーションをつくり、Spring Securityのコアとなるいくつかのインターフェースを実装してGAEの機能を使う試みをしてみるのです。この記事では以下のような事項をどのように実現するのかを見ていくことになります:
- Googleアカウントを使った認証の方法
- ユーザが保護されたリソースにアクセスしたときに”オンデマンド”に実施される認証機能を実装する方法
- Googleアカウントの情報とともにアプリケーション固有のロール情報を供給する方法
- ネイティブなAPIを使用してGAEのデータストアにユーザアカウントのデータを保存する方法
- ユーザに適用されたロールに基づくアクセスコントロール制御の実現方法
- アクセスを阻止するために特定ユーザのアカウントを使用不可能にする方法
この記事を読むまえに、GAE上にアプリケーションを構築する方法について知っている必要があります。基本的なアプリケーションをつくって動かしてみるのに時間はかかりません。有益な情報はGAEのWebサイト上でたくさん見つかるはずです。
サンプル・アプリケーション
このアプリケーションは非常にシンプルなもので、Spring MVCを使って構築されています。アプリケーション・ルートにはウェルカム・ページが用意されており、そこから“ホームページ”へと進むことができますが、これができるのは認証と登録が済んでからです。こちらのURLで実際にGAE上にデプロイされたもので試してみることができます。
登録されたユーザはGAEのデータストアのエンティティとして保存されます。最初の認証で、新規のユーザは名前を入力する登録ページへとリダイレクトされます。一度登録が済んだら、ユーザアカウントはデータストア内で“使用不可能”のアカウントとしてフラグ設定することができるようになります。こうするとユーザはアプリケーションを使用できなくなりますが、いずれにせよユーザはGAEを通じて認証されます。
Spring Securityの裏側
ここでは読者のあなたがSpring Securityの名前空間コンフィギュレーションについてすでにご存じであり、また理想的には、あなたがSpring Securityの中核となるインターフェース群とそれらインターフェース同士が相互にどのように関係しているかについてある程度知識をお持ちであるという想定でお話をしていきます。これら基礎的な情報についてはリファレンス・マニュアルの技術的な概観〔翻訳版〕のチャプターで解説されています。もしあなたがSpring Securityの内側についてご存じであるなら、フォーム・ベース・ログインのようなWeb認証のメカニズムが、サーブレット・フィルタとAuthenticationEntryPointインターフェース実装により実現されていることもご存じでしょう。AuthenticationEntryPointは、匿名のユーザが保護されたリソースへアクセスしたとき認証プロセスを起動させ、続くリクエスト(ログイン・フォームからの認証情報の送信のような)からフィルタが認証情報を抽出、ユーザを認証したあとユーザ・セッションのために保護されたコンテクストを構築します。
フィルタは認証の可否を、AuthenticationProviderの一覧により設定されるAuthenticatonManagerに委譲します。AuthenticationProviderは、いずれもユーザ認証を行って、認証失敗のおりには例外をスローします。
フォーム・ベース認証の場合、AuthenticationEntryPointは単純にユーザをログイン・ページへと遷移させます。認証フィルタ(この場合、UsernamePasswordAuthenticationFilter)はユーザ名とパスワードをPOSTリクエストから取り出します。それらはAuthenticationオブジェクトに格納され、AuthenticationProviderに渡されます。プロバイダは多くの場合、データベースやLDAPサーバに格納された情報と(Authenticationオブジェクトの情報を)比較することになります。
これがSpring Securityのコンポーネント間で行われる基本的なやり取りです。では、これらをどのようにしてGAEアプリケーションに適用するのでしょう?
Googleアカウント認証
もちろんGAE上(当然JDBCサポートはありませんよ?)でも、Spring Securityを使った標準的なアプリケーションの構築ができることに変わりはありません。しかしもしあなたがGAEが提供するAPI──これによりユーザはいつも使っているGoogleログインの機能でもって認証を行うことができます──を用いてアプリケーションを構築しようとしたらどうでしょう? 実際のところことはとても簡単で、ほとんどの仕事はGAEのUserServiceインターフェース実装が済ませてくれます。UserServiceは外部ログイン・ページURLを生成するためのメソッドを持っています。あなたは、ユーザが認証を済ませ、アプリケーションの使用を継続することができるようになったあとに戻ることになる遷移先を提供します。これはWebページ上にログイン・リンクを生成するのに使うことができますが、独自実装したAuthenticationEntryPointで、ユーザを直接ログイン画面にリダイレクトさせることもできます〔ユーザにあえてログイン・リンクをクリックしてもらわなくても、それこそオンデマンドに、それが必要なときには機械的にユーザをログイン・ページに遷移させることもできる、ということ。〕:
import com.google.appengine.api.users.UserService; import com.google.appengine.api.users.UserServiceFactory; public class GoogleAccountsAuthenticationEntryPoint implements AuthenticationEntryPoint { public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { UserService userService = UserServiceFactory.getUserService(); response.sendRedirect(userService.createLoginURL(request.getRequestURI())); } }
これを有効にするためにSpring Security名前空間で提供されている特別なフック(仕掛け)を使う場合、コンフィギュレーション・ファイルは以下のようになります:
<b:beans xmlns="http://www.springframework.org/schema/security" xmlns:b="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security-3.1.xsd"> <http use-expressions="true" entry-point-ref="gaeEntryPoint"> <intercept-url pattern="/" access="permitAll" /> <intercept-url pattern="/**" access="hasRole('USER')" /> </http> <b:bean id="gaeEntryPoint" class="samples.gae.security.GoogleAccountsAuthenticationEntryPoint" /> ... </b:beans>
ここではアプリケーションのルートを除く、すべてのURLで、“USER”ロールが必要となるように設定しました。ユーザはルート以外のページに最初にアクセス使用としたとき、Googleアカウントのログイン画面へとリダイレクトされるでしょう:
さて今度は、ユーザがGoogleアカウント・ログインを済ませて〔もとの、アクセスしようとしていたページへ〕リダイレクトされたとき、保護されたコンテキスト〔Security Context〕を構築するためのフィルタを追加しましょう。認証フィルタのコードは以下の通りです:
public class GaeAuthenticationFilter extends GenericFilterBean { private static final String REGISTRATION_URL = "/register.htm"; private AuthenticationDetailsSource ads = new WebAuthenticationDetailsSource(); private AuthenticationManager authenticationManager; private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler(); public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null) { // まだ認証が済んでいない。Googleアカウント・ユーザかチェックする。 User googleUser = UserServiceFactory.getUserService().getCurrentUser(); if (googleUser != null) { // ユーザはGAEのAPIで認証を済ませている。続いてSpring Security側で認証をする必要がある。 PreAuthenticatedAuthenticationToken token = new PreAuthenticatedAuthenticationToken(googleUser, null); token.setDetails(ads.buildDetails(request)); try { authentication = authenticationManager.authenticate(token); // 保護されたコンテキストを構築する。 SecurityContextHolder.getContext().setAuthentication(authentication); //新規ユーザを登録ページに遷移させる。 if (authentication.getAuthorities().contains(AppRole.NEW_USER)) { ((HttpServletResponse) response).sendRedirect(REGISTRATION_URL); return; } } catch (AuthenticationException e) { //認証情報がAuthenticationManagerにより拒絶された。 failureHandler.onAuthenticationFailure((HttpServletRequest)request, (HttpServletResponse)response, e); return; } } } chain.doFilter(request, response); } public void setAuthenticationManager(AuthenticationManager authenticationManager) { this.authenticationManager = authenticationManager; } public void setFailureHandler(AuthenticationFailureHandler failureHandler) { this.failureHandler = failureHandler; } }
今回われわれは、理解しやすいように、また既存クラスからの継承関係から来る難解さを避けるために、フィルタ・インターフェースを一から実装しました。もしユーザが(Spring Securityの視点から見て)認証を済ませていない場合、フィルタはGAEユーザ情報(GAEのUserServiceを介して作成している)の存否を検証します。もし存在するなら、引き続き認証トークン・オブジェクトのなかにそれを包み込んで(今回は簡便さのためSpring SecurityのPreAuthenticatedAuthenticationTokenオブジェクトを使用しています)、AuthenticationManagerに渡してSpring Securityの機構によって認証を行うようにしています。ここで、新規ユーザは登録画面へとリダイレクトされます。
独自のAuthenticationProvider
この一連の流れのなかでは、われわれは従来のようなかたちの認証──ユーザが自ら称するところの人物であるかどうかを判定する認証──を実施していません。〔従来型のユーザ認証については〕Googleアカウントが代わりに面倒を見てくれるので、われわれのほうでは、ただ単にわれわれのアプリケーションの視点から見てユーザが適格であるかどうか、これ判定することだけに関心を向けています。これはCASもしくはOpenIDのようなシングル・サインオン・システムとともにSpring Securityを使用する状況に似ています。AuthenticationProviderはユーザ・アカウントのステータスをチェックし、その他の情報(アプリケーション独自のロール情報など)を読み込みます。サンプルコードでは、これまでわれわれのアプリケーションを使用したことのない“未登録”のユーザという概念が登場します。もしそのユーザがアプリケーションにとって未知ユーザであった場合、一時的に“NEW_USER”ロールが割り振られます。このロールは、登録ページへのみアクセス可能なものになります。登録が済んだら、彼らには“USER”ロールが割り振られます。
AuthenticationProviderインターフェース実装はGaeUserオブジェクトの保存と取得のためにUserRegistryと協働します(どちらもこのサンプル・アプリのために作成したものです):
public interface UserRegistry { GaeUser findUser(String userId); void registerUser(GaeUser newUser); void removeUser(String userId); } public class GaeUser implements Serializable { private final String userId; private final String email; private final String nickname; private final String forename; private final String surname; private final Set<AppRole> authorities; private final boolean enabled; // Constructors and accessors omitted ...
userIdはGoogleアカウントにより割り振られた一意性のID。EmailとnicknameはGAEユーザ情報から取得したもの。forenameとsurnameは登録フォームで入力されたもの。enabledフラグはGAEデータストアの管理コンソールから直接変更を行わない限りはtrueが設定されています。AppRoleはSpringSecurityのGrantedAuthorityインターフェースをenumとして実装したものです:
public enum AppRole implements GrantedAuthority { ADMIN (0), NEW_USER (1), USER (2); private int bit; AppRole(int bit) { this.bit = bit; } public String getAuthority() { return toString(); } }
ロールは上述のように割り振られています。AuthenticationProviderは以下のようになります:
public class GoogleAccountsAuthenticationProvider implements AuthenticationProvider { private UserRegistry userRegistry; public Authentication authenticate(Authentication authentication) throws AuthenticationException { User googleUser = (User) authentication.getPrincipal(); GaeUser user = userRegistry.findUser(googleUser.getUserId()); if (user == null) { // このユーザはまだ登録されていない。登録の必要あり。 user = new GaeUser(googleUser.getUserId(), googleUser.getNickname(), googleUser.getEmail()); } if (!user.isEnabled()) { throw new DisabledException("Account is disabled"); } return new GaeUserAuthentication(user, authentication.getDetails()); } public final boolean supports(Class<?> authentication) { return PreAuthenticatedAuthenticationToken.class.isAssignableFrom(authentication); } public void setUserRegistry(UserRegistry userRegistry) { this.userRegistry = userRegistry; } }
GaeUserAuthenticationクラスはSpring SecurityのAuthenticationインターフェースの非常にシンプルな実装で、プリンシパル〔認証対象となり、アクセス権付与の対象となるもの〕としてGaeUserをとります。
Spring Securityをカスタマイズしたことがあるなら、なぜどこにもUserDetailsServiceの実装が登場しないのか、またなぜプリンシパルがUserDetailsインスタンスではないのかと訝しんでいるかもしれませんね。簡単に答えると、それは別に必須ではないから、ということです。──Spring Securityは一般的に対象のオブジェクトの型がどうであるかは気にかけません。それで、今回われわれはもっとも簡単な選択肢として、AuthenticationProviderを実装する方法を選んだわけです。
GAEデータストアのユーザ・レジストリ
さて、GAEデータストアを使ってUserRegistryを実装する必要があります。
import com.google.appengine.api.datastore.*; import org.springframework.security.core.GrantedAuthority; import samples.gae.security.AppRole; import java.util.*; public class GaeDatastoreUserRegistry implements UserRegistry { private static final String USER_TYPE = "GaeUser"; private static final String USER_FORENAME = "forename"; private static final String USER_SURNAME = "surname"; private static final String USER_NICKNAME = "nickname"; private static final String USER_EMAIL = "email"; private static final String USER_ENABLED = "enabled"; private static final String USER_AUTHORITIES = "authorities"; public GaeUser findUser(String userId) { Key key = KeyFactory.createKey(USER_TYPE, userId); DatastoreService datastore = DatastoreServiceFactory.getDatastoreService(); try { Entity user = datastore.get(key); long binaryAuthorities = (Long)user.getProperty(USER_AUTHORITIES); Set<AppRole> roles = EnumSet.noneOf(AppRole.class); for (AppRole r : AppRole.values()) { if ((binaryAuthorities & (1 << r.getBit())) != 0) { roles.add(r); } } GaeUser gaeUser = new GaeUser( user.getKey().getName(), (String)user.getProperty(USER_NICKNAME), (String)user.getProperty(USER_EMAIL), (String)user.getProperty(USER_FORENAME), (String)user.getProperty(USER_SURNAME), roles, (Boolean)user.getProperty(USER_ENABLED)); return gaeUser; } catch (EntityNotFoundException e) { logger.debug(userId + " not found in datastore"); return null; } } public void registerUser(GaeUser newUser) { Key key = KeyFactory.createKey(USER_TYPE, newUser.getUserId()); Entity user = new Entity(key); user.setProperty(USER_EMAIL, newUser.getEmail()); user.setProperty(USER_NICKNAME, newUser.getNickname()); user.setProperty(USER_FORENAME, newUser.getForename()); user.setProperty(USER_SURNAME, newUser.getSurname()); user.setUnindexedProperty(USER_ENABLED, newUser.isEnabled()); Collection<? extends GrantedAuthority> roles = newUser.getAuthorities(); long binaryAuthorities = 0; for (GrantedAuthority r : roles) { binaryAuthorities |= 1 << ((AppRole)r).getBit(); } user.setUnindexedProperty(USER_AUTHORITIES, binaryAuthorities); DatastoreService datastore = DatastoreServiceFactory.getDatastoreService(); datastore.put(user); } public void removeUser(String userId) { DatastoreService datastore = DatastoreServiceFactory.getDatastoreService(); Key key = KeyFactory.createKey(USER_TYPE, userId); datastore.delete(key); } }
すでに申し上げたとおり、このサンプルではアプリケーション・ロールのために列挙型を用いています。ユーザに割り振られたロール(権限)はEnumSetとして保存されます。EnumSetはとても便利なオブジェクトで、ユーザのロールは単一のlong値として保存されます。これによりデータストアAPIとのやり取りがシンプルなものになります。各ロールには独立したビット値を割り当てています。
ユーザ登録
ユーザ登録コントローラは登録フォームから送信されたデータを処理するためのメソッドとして以下のような実装を持ちます。
@Autowired private UserRegistry registry; @RequestMapping(method = RequestMethod.POST) public String register(@Valid RegistrationForm form, BindingResult result) { if (result.hasErrors()) { return null; } Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); GaeUser currentUser = (GaeUser)authentication.getPrincipal(); Set<AppRole> roles = EnumSet.of(AppRole.USER); if (UserServiceFactory.getUserService().isUserAdmin()) { roles.add(AppRole.ADMIN); } GaeUser user = new GaeUser(currentUser.getUserId(), currentUser.getNickname(), currentUser.getEmail(), form.getForename(), form.getSurname(), roles, true); registry.registerUser(user); // 完全な認証完了をもってコンテキストを更新する。 SecurityContextHolder.getContext().setAuthentication(new GaeUserAuthentication(user, authentication.getDetails())); return "redirect:/home.htm"; }
与えられたforenameとsurname、新たに作成されたロールセットを使用してユーザが作成されます。もしGAEがこのユーザがアプリケーションの管理者であると判定したなら、“ADMIN”ロールも付与されます。ユーザ・レジストリに登録されると、保護されたコンテキスト(security context)には更新されたAuthenticationオブジェクトが投入されます。これによりSpring Securityは新しいロール情報を認識しており、これにしたがってアクセス・コントロールを適用することが確認されます。
最終的なアプリケーション設定
この保護されたアプリケーション〔security application〕のコンテキスト・ファイルは次のようになりました:
<http use-expressions="true" entry-point-ref="gaeEntryPoint"> <intercept-url pattern="/" access="permitAll" /> <intercept-url pattern="/register.htm*" access="hasRole('NEW_USER')" /> <intercept-url pattern="/**" access="hasRole('USER')" /> <custom-filter position="PRE_AUTH_FILTER" ref="gaeFilter" /> </http> <b:bean id="gaeEntryPoint" class="samples.gae.security.GoogleAccountsAuthenticationEntryPoint" /> <b:bean id="gaeFilter" class="samples.gae.security.GaeAuthenticationFilter"> <b:property name="authenticationManager" ref="authenticationManager"/> </b:bean> <authentication-manager alias="authenticationManager"> <authentication-provider ref="gaeAuthenticationProvider"/> </authentication-manager> <b:bean id="gaeAuthenticationProvider" class="samples.gae.security.GoogleAccountsAuthenticationProvider"> <b:property name="userRegistry" ref="userRegistry" /> </b:bean> <b:bean id="userRegistry" class="samples.gae.users.GaeDatastoreUserRegistry" />
custom-filter名前空間要素(XMLタグ)により、先ほどのフィルタ実装が挿入されていることや、プロバイダとユーザ・レジストリが紐づけられていることが見て取れるでしょう。登録コントローラのためにURLも追加されています。これは新規ユーザのアクセスを助けるものです。
結論
Spring Securityは長年にわたり、多くの異なるシナリオで役立つその完全な柔軟性を示してきましたが、Google App Engine上でのアプリケーション構築においてもそれは変わりありません。加えて記憶しておいてほしいのは、これまで見てきたいくつかのインターフェース実装は、既存クラスの使用する試みよりも、よりよいアプローチであったということです。結果としてあなたのアプリケーションの要件によりよくマッチした、クリーンなソリューションが得られました。
この記事ではSpring Securityが機能するアプリケーションにおいて、Google App EngineのAPIをどのように使用するかに焦点をおいてきました。アプリケーションがどのように動くのかについて他の多くの詳細は網羅されていません。しかしわたしは皆さんがコードに目を向ける手助けができたと思います。もしあなたGAEのエキスパートであるならば、改善のためのご提案をいつもで歓迎いたします。
サンプル・コードはバージョン3.1のSpring Securityコードに基づくものです。サンプルコードはGitリポジトリからチェックアウトできます。Spring Security 3.1の最初のマイルストーンは今月の終わりころにリリースされることになるはずです。〔この記事は2010年8月2日にルーク・テイラーにより執筆されました。〕
* * *
(原典:Luke Taylor, 2010/08/02,“Spring Security in Google App Engine” / SpringSource Blog)