2012年5月18日金曜日

Spring Social - Connection の管理

前回の『Facebook に接続』で、Spring Social を使い Facebook との接続(Connection)を確立しました。今回は、確立した Connection をローカルユーザーアカウントと紐付けて DB で管理する部分を作ります。

ConnectionRepository と UsersConnectionRepository
前回のような直球型では、アプリケーションを起動する度に facebook との“OAuth2 ダンス”が必要になります。一度確立した Connection オブジェクトを永続化し、再利用する仕組みを用意すれば、そうした手間がなくなります。

Spring Social リファレンスの“2.3 Persisting connections”を読むと Connection 管理の中核は、永続化された各(ローカル)ユーザーの Connection オブジェクトに対するデータアクセスインターフェースを提供する ConnectionRepository と、Connection のグローバルストアーとして機能する UsersConnectionRepository であることがわかります。

DB(MySQL)側の準備
今回は、UsersConnectionRepository の実装クラスである JdbcUsersConnectionRepository を使用します。そのためには DB の準備が必要です。“2.3.1 JDBC-based persistence”に倣い、以下のスキーマを考えました。
create table UserConnection (
  userId varchar(127) not null,
  providerId varchar(64) not null,
  providerUserId varchar(64),
  rank int not null,
  displayName varchar(127),
  profileUrl varchar(512),
  imageUrl varchar(512),
  accessToken varchar(127) not null,
  secret varchar(255),
  refreshToken varchar(127),
  expireTime bigint,
  constraint UserConnection_pkc primary key(userId, providerId, providerUserId),
  constraint UserConnection_idx1 unique index(userId, providerId, rank)
  );

@Configuration クラス
4. Connecting to Service Providers”に、Spring Social に標準添付されている ConnectController を利用するための設定手順が記されています。この手順を若干アレンジして、前回作ったコードでも利用できるようにしてみます。とは言ってもほぼ書き写しですが...

SocialConfig.java
まずは外枠の部分です。
package wrider.fb.test.config;

import javax.sql.DataSource;

import org.slf4j.Logger;
    :
import org.springframework.social.facebook.connect.FacebookOAuth2Template;

@Configuration
@ComponentScan(basePackages="wrider")
@ImportResource("/WEB-INF/applicationContext.xml")
@PropertySource("classpath:wrider/fb/test/config/facebook.properties")
public class SocialConfig {
  final static Logger logger = LoggerFactory.getLogger(SocialConfig.class);
    :
}
@Configuration でこれが Spring コンテナーを構成するクラスであることを指示しています。@ComponentScan@ImportResource は、それぞれ <component-scan/>, <import/>と同様に機能するアノテーションです。@PropertySource で、Environment インターフェースに食わせる .properties ファイル(facebook.properties)の所在を指示しています。

続いて諸々の bean の定義です。
@Autowired
  private Environment environment;
  
  @Autowired
  DataSource pooledDataSource;
  
  @Bean
  public TextEncryptor textEncryptor() {
    return Encryptors.noOpText();
  }
  
  @Bean
  public FacebookOAuth2Template facebookOAuth2Template() {
    return new FacebookOAuth2Template(
        environment.getProperty("facebook.clientId"), environment.getProperty("facebook.clientSecret"));
  }
  
  @Bean
  public FacebookConnectionFactory facebookConnectionFactory() {
    return new FacebookConnectionFactory(
        environment.getProperty("facebook.clientId"), environment.getProperty("facebook.clientSecret"));
  }
  

DataSource には『Hibernate でデータアクセス(3)』で定義した“pooledDataSource”を DI しています。本体コード(FacebookTestController.java)での記述を簡素化するために FacebookOAuth2Template および FacebookConnectionFactory のビーンを定義しています。この中に

environment.getProperty("facebook.clientId"), environment.getProperty("facebook.clientSecret"));

という記述がありますが、ここで @PropertySource で指示した場所にあるファイルから値を読み込み設定しています。そのファイル(facebook.properties)の内容は以下のような感じです。

facebook.clientId=123456...
facebook.clientSecret=11aa22bb33cc..

続く ConnectionFactoryLocatorUsersConnectionRepository のビーン定義は、ほぼリファレンスの通りです。
@Bean
  @Scope(value="singleton", proxyMode=ScopedProxyMode.INTERFACES)
  public ConnectionFactoryLocator connectionFactoryLocator() {
    ConnectionFactoryRegistry registry = new ConnectionFactoryRegistry();
    registry.addConnectionFactory(facebookConnectionFactory());
    return registry;
  }
  
  @Bean
  @Scope(value="singleton", proxyMode=ScopedProxyMode.INTERFACES)
  public UsersConnectionRepository usersConnectionRepository() {
    logger.info("**UsersConnectionRepository");
    return new JdbcUsersConnectionRepository(this.pooledDataSource, connectionFactoryLocator(), textEncryptor());
  }

次の ConnectionRepository と Facebook の scope はユーザーからのリクエストに応じて個別に生成されなければならないので "request" になります。

facebook ビーンでは、connectionRepository の findPrimaryConnection()メソッドで Connection の有無を調べ、ローカルユーザーに紐づいた Connection が DB に存在すれば facebook の API を返します。存在しなかった場合、つまり、当該ローカルユーザーが始めて facebook に接続を試みる場合は 未認証の FacebookTemplate を生成し返します。
@Bean
  @Scope(value="request", proxyMode=ScopedProxyMode.INTERFACES)
  public ConnectionRepository connectionRepository() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    if (authentication == null) {
      throw new IllegalStateException("Unable to get a ConnectionRepository: no user signed in");
    }
    logger.info("**ConnectionRepository: {}", authentication.getName());
    return usersConnectionRepository().createConnectionRepository(authentication.getName());
  }
  
  @Bean
  @Scope(value="request", proxyMode=ScopedProxyMode.INTERFACES)
  public Facebook facebook() {
    Connection facebook = connectionRepository().findPrimaryConnection(Facebook.class);
    if (facebook != null) {
      if (!facebook.test()) {
        facebook.refresh();
        logger.info("**facebook connection has been refleshed");
      } else {
        logger.info("**facebook connection alived");
      }
      return facebook.getApi();
    }
    else {
      logger.info("**return FacebookTemplate");
      return new FacebookTemplate();
    }
  }

SocialExceptionResolver.java(抜粋)
FacebookTemplate を返されたユーザーが OAuth2 認証を必要とする処理(例えば、Profile の取得など)を呼び出した場合、NotAuthorizedException が発生します。これの処理方法として例えば、『例外処理』の要領で HandlerExceptionResolver の実装クラスで捕まえ、『Facebook に接続』で作成した FacebookTestController クラスの singnin()メソッドに飛ばす、といった方法が考えられます。
public class SocialExceptionResolver implements HandlerExceptionResolver,
    Ordered {
  final static Logger logger = LoggerFactory.getLogger(SocialExceptionResolver.class);

  private int order = Integer.MAX_VALUE;
  
  public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
    ModelAndView mav = new ModelAndView();
    if (ex instanceof NotAuthorizedException) {
      logger.info("<<NotAuthorizedException>>");
      mav.setViewName("redirect:/facebooktest/signin.html");
      return mav;
    }
    else {
      return null;
    }
  }
  
  public int getOrder() {
    return this.order;
  }
}

ここで request scope のビーンに関する注意点が一つ。もし [servlet-name]-servlet.xml に以下の設定がある場合、

<aop:aspectj-autoproxy proxy-target-class="true"/>

ブート時に怒られるので一先ず proxy-target-class="false" にしておきます。

コントローラーの変更
以上を踏まえて前回のコントローラーを次のように変更しました。

FacebookTestController.java(抜粋)
signin()メソッドはこんな感じです。DI した facebookOAuth2Template の buildAuthorizeUrl()メソッドで authUrl を生成して facebook にリダイレクトしています。
@RequestMapping(value = "facebooktest/signin.html", method = RequestMethod.GET)
  public void signin(HttpServletResponse response) 
    throws IOException {
    
    OAuth2Parameters params = new OAuth2Parameters();
    params.setRedirectUri(callbackUrl);
    params.setScope("read_stream");
    String authUrl = facebookOAuth2Template.buildAuthorizeUrl(GrantType.AUTHORIZATION_CODE, params);
    logger.info("auth url: {}", authUrl);
    response.sendRedirect(authUrl);
    
  }

コールバックを受け取る hello()メソッドです。DI した facebookOAuth2Template の exchangeForAccess()メソッドで認証コードを交換し、生成した Connection が connectionRepository に登録されていなかったら addConnection()メソッドで追加し、登録されていたら updateConnection()メソッドで内容を更新しています。
@RequestMapping(value = "facebooktest/hello.html", method = RequestMethod.GET)
  public ModelAndView hello(@RequestParam(value = "code", required = false) String code) {
    
    ModelAndView mav = new ModelAndView();
    
    if (code == null) {
      mav.setViewName("redirect:signin.html");
      return mav;
    }
    
    AccessGrant accessGrant = facebookOAuth2Template.exchangeForAccess(code, callbackUrl, null);
    Connection<Facebook> connection = facebookConnectionFactory.createConnection(accessGrant);
    
    if (this.connectionRepository.findPrimaryConnection(Facebook.class) == null) {
      this.connectionRepository.addConnection(connection);
      logger.info(">>connection added");
    }
    else {
      this.connectionRepository.updateConnection(connection);
      logger.info(">>connection updated");
    }
    logger.info(">>access token: {}", accessGrant.getAccessToken());
    mav.setViewName("redirect:home/index.html");
    
    return mav;
  }

レファレンスを参考に facebook アカウントとアプリケーションのローカルアカウントを連携させる環境は構築できました。果たしてこのやり方が正しいのか否かは定かではありませんが....

これをベースに簡単なアプリケーションを作ってみましたが、(極)個人的には使えるものになりました。ソーシャルアプリの開発にはまりそうです。

2012年5月16日水曜日

Facebook に接続

今回は Spring Social です。Spring Social のリファレンス“2. Service Provider 'Connect' Framework”には以下のように記されています。
spring-social-core モジュールは、Facebook や Twitter のような SaaS プロバイダーへの接続を管理するサービスプロバイダー‘コネクションフレームワーク’を含む。このフレームワークは、アプリケーションがローカルユーザーアカウントとユーザーが外部のサービスプロバイダーに持っているアカウント間のコネクションを確立することを可能とする
前回までに、アカウント管理の基本的な仕掛けはできたので、これをベースに Facebook に接続したいと思います。

準備
Spring Social から以下のパッケージをダウンロードし、解凍後、/WEB-INF/lib とビルドパスに登録します。

spring-social-1.0.2.RELEASE.zip
spring-social-facebook-1.0.1.RELEASE.zip

また、リファレンス“1.4 Dependencies”を参考に足りないコンポーネントを追加します。私の場合、以下のものを追加しました。

Jackson JSON Processor
jackson-all-1.9.7.jar

App ID の取得
Facebook for Websitesに行き、“Authentication”に記された要領に従いウェブサイトの App ID(と App Secret)を取得します。その際、アプリの「表示名」や「ドメイン」の他、OAuth2 認証時のリダイレクト(コールバック)先となる「サイト URL」を予め決めておく必要があります。

因みに私の場合、localhost で開発しているので図のような感じになります。

権限とポートのマッピング
前回、アプリケーションにアカウントを作成したユーザーに ROLE_USER という権限を割当てる仕掛けを作りこみました。今回の目的は、そうしたアプリ内のローカルユーザーと Facebook アカウントを(access token で)結びつけることです。言い換えれば、ROLE_USER という権限を付与された認証済みのローカルユーザーだけにアプリケーション(facebooktest)の使用を許可するということです。この要件を踏まえて、springSecurityConf.xml の <security:http/> 要素内に以下の行を追加しました。接続は https です。
  <security:http auto-config="true">
    :
    <!-- social service -->
    <security:intercept-url pattern="/facebooktest/**" requires-channel="https" access="ROLE_USER"/>
    :
  </security:http>



facebook に接続 - 直球型
リファレンス“2.2.1 OAuth2 service providers”に OAuth2 のフローが記されています。その下にあるのが Facebook に接続するコード例です。これに倣い、アプリケーションの認証からアクセストークンを受け取るまでのコードを作ってみます。

まずは、Facebook にアプリケーションの認証要求を送信する部分。フローは以下の通りです。
  1. FacebookConnectionFactory のコンストラクター引数に Facebook から発行された App Id と App Secret をセットしてインスタンス化し
  2. それから OAuth2 フローを制御するサービスインターフェース OAuth2Operationsを取得します。
  3. OAuth2Parameters オブジェクトに Facebook に登録したサイト URL(コールバックURL)をセットし
  4. OAuth2Operations の buildAuthorizeUrl() メソッドで Facebook にアプリケーションを認証してもらうための URL を生成します。
  5. 生成した URL にリダイレクトします。

以下が実際のコードです。facebooktest/signin.html を呼び出すと Facebook との“OAuth2 ダンス”が始まります。callbackUrl は、コールバック URL です。
@Controller
public class FacebookTestController {

  private OAuth2Operations oauthOperations;
    :
  @RequestMapping(value = "facebooktest/signin.html", method = RequestMethod.GET)
  public void signin(HttpServletResponse response) 
    throws IOException {
    FacebookConnectionFactory connectionFactory = new FacebookConnectionFactory("[App Id]", "[App Secret]");
    oauthOperations = connectionFactory.getOAuthOperations();
    OAuth2Parameters params = new OAuth2Parameters();
    params.setRedirectUri(callbackUrl);
    String authorizeUrl = oauthOperations.buildAuthorizeUrl(GrantType.AUTHORIZATION_CODE, params);
    logger.info("auth url: {}", authorizeUrl);
    response.sendRedirect(authorizeUrl);
  }
}

生成した authorizeUrl の内容はこんな感じです。

https://graph.facebook.com/oauth/authorize?client_id=[App Id]&response_type=code&redirect_uri=https%3A%2F%2Flocalhost%3A8443%2Fishtar%2Ffacebooktest%2Fhello.html

Facebook にログインしていない状態で facebooktest/signin.html にアクセスすると下図の画面が現れます。


Facebook にログインするとコールバック URLにリダイレクトされてきます。それを受け止めるのが以下のコードです。
  @RequestMapping(value = "facebooktest/hello.html", method = RequestMethod.GET)
  public ModelAndView hello(@RequestParam(value = "code", required = true) String code) {
    
    ModelAndView mav = new ModelAndView();
    
    if (code == null) {
      mav.setViewName("redirect:signin.html");
      return mav;
    }
    
    AccessGrant accessGrant = oauthOperations.exchangeForAccess(code, callbackUrl, null);
    Connection<Facebook> connection = facebookConnectionFactory.createConnection(accessGrant);
    logger.info(">>access token: {}", accessGrant.getAccessToken());
    
    Facebook fb = connection.getApi();
    FacebookProfile fbProfile = fb.userOperations().getUserProfile();
    mav.addObject("fbProfile", fbProfile);
    mav.setViewName("home/index");
    
    return mav;
  }
  1. まず、リクエストパラメーターにFacebookからの認証コード“code”が含まれていなかったら前述の signin.html にリダイレクトします。
  2. OAuth2Operations の exchangeForAccess()メソッドで Facebook から受け取った認証コードを送り返し、AccessGrantを受け取ります。
  3. 受け取った AccessGrant に含まれている Access Token を使って Connection オブジェクトを生成します。
  4. Facebook インターフェースの userOperations()メソッドを介して Facebook に登録しているプロフィールを取得し、ModelAndView にセットして View を呼び出しています。

以上で、Facebook に接続するという目的は達成です。

2012年5月10日木曜日

DAO認証

今回は「Hibernate でデータアクセス(1)」以降で作成したアカウント管理の仕掛けに Spring Security の認証機能を取り入れます。

<authentication-manager/>への登録
まず「Anonymous 認証とポートマッピング」で触れた <authentication-manager/>に DaoAuthenticationProvider を登録します。
<!-- Authentication Manager-->
  <security:authentication-manager alias="authenticationManager">
    <security:authentication-provider ref="anonymousAuthenticationProvider"/>
    <security:authentication-provider ref="daoAuthenticationProvider"/>
  </security:authentication-manager>

daoAuthenticationProvider の定義
DaoAuthenticationProvider は、AuthenticationProviderインターフェースの実装クラスの一つで、クライアントから来た認証要求と、UserDetailsService を介してロードした UserDetails を基づき認証処理を行います。

従って DaoAuthenticationProvider を使うためには、UserDetailsService の実装クラスを指定する必要があります。今回は、既にデータベースに作成してある UserProfile テーブルを活用したいと考えているので JdbcDaoImpluserDetailsService として登録します。
<!-- DAO Authentication -->
  <bean id="daoAuthenticationProvider"
    class="org.springframework.security.authentication.dao.DaoAuthenticationProvider">
    <property name="userDetailsService" ref="userDetailsService"/>
  </bean>

userDetailsService の定義
この JdbcDaoImpl の役割は、DB から JDBC 経由で認証に必要なデータを持ってくることで API ドキュメントにある通りデフォルトのスキーマが定義されています。もちろん、それに合わせて DB に新しいテーブルを作成するというのも一つの選択肢ではあります。しかし、JdbcDaoImpl やそのサブクラスである JdbcUserDetailsManager には DB へのクエリー文字列をカスタマイズできるメソッドが用意されています。今回は既存テーブルに対する若干の変更とクエリー文字列のカスタマイズという戦略を採ることにします。
<bean id="userDetailsService"
    class="org.springframework.security.core.userdetails.jdbc.JdbcDaoImpl">
    <property name="dataSource" ref="pooledDataSource"/>
    <property name="usersByUsernameQuery"
      value="select email as username, password, enabled from UserProfile where email = ?"/>
    <property name="authoritiesByUsernameQuery"
      value="select email as username, authority from Authorities where email = ?"/>
  </bean>

既存のテーブル"UserProfile"にはemail, passworod, firstName, lastName, penName, ..といったカラムがあります。JdbcDaoImpl はデフォルトでは DEF_USERS_BY_USERNAME_QUERY で定義されているクエリーを発行するので、上図のように <property/> を使って既存テーブルに合わせます。

DB 側の調整
enabled については、既存テーブルになかったので alter table UserProfile .. で追加しました。一方、DEF_AUTHORITIES_BY_USERNAME_QUERY については、authorities というテーブルは無かったため、以下のような感じで作成しました。

create table Authorities(
  email varchar(255) not null,
  authority varchar(255) default 'ROLE_USER',
  constraint Authorities_fk1 foreign key(email)
    references UserProfile(email)
    on delete cascade
    on update cascade
  );

この authorities は、UserProfile テーブルに対して n:1 の関係になりますが、アカウント作成時にデフォルトのロール"ROLE_USER"が一つ、自動的にできているという状況を作るために次のようなトリガーを定義しました。

delimiter //
create trigger reflectNewUser after insert
  on UserProfile for each row begin
      insert into Authorities set email = new.email;
      insert into Cookies set pid = new.pid;
  end//
delimiter ;

別の方法として、例えば登録ユーザーの権限は無条件で ROLE_USER ただ一つで追加・変更を行わないというのであれば DEF_AUTHORITIES_BY_USERNAME_QUERY のクエリーを
select email, 'ROLE_USER' from UserProfile where email = ?
とすれば、新しいテーブルを必要がなくなります。

<security:http/>の変更
以上の作業で登録ユーザーには ROLE_USER という権限が割り当てられるようになったので、<security:http/>の内容も少し変えることにしました。/account のメニューには今のところ、ログイン画面、ログアウト後のさよなら画面、登録情報変更画面があります。また、/welcome 下には種々の登録者向けメニューを用意する予定です。これらを考慮して変更を加えたのが下図です。

今回から、http と https で別の cookie を発行するようにしましたが、<security:logout/>の設定で、ログアウト時にこれらを削除するよう指定できるのは便利だと思いました。ここで使った各種エレメントの詳細は“The Security Namespace”に記載されています。
<security:http auto-config="true">
    
    <!-- account service -->
    <security:intercept-url pattern="/account/**" requires-channel="https" access="IS_AUTHENTICATED_ANONYMOUSLY"/>
    <security:intercept-url pattern="/account/myprofile.html" access="ROLE_USER"/>
    
    <security:form-login login-page="/account/login.html"/>
    <security:logout logout-url="/account/logout.html" logout-success-url="/account/seeyou.html"
      delete-cookies="NSID, SSID" invalidate-session="true"/>
    
    <!-- welcome service -->
    <security:intercept-url pattern="/welcome/**" requires-channel="http" access="ROLE_USER"/>
    
    <!-- http/https port mappings -->
    <security:port-mappings>
      <security:port-mapping http="8080" https="8443"/>
    </security:port-mappings>
  </security:http>


DAO レイヤー
インターフェースを定義して、実装クラスを作成という基本に則って DAO レイヤー、サービスレイヤーを作ります。今回使いたいのは認証だけなので DAO レイヤーの実装クラス AuthManagerImpl は以下のように至ってシンプル、引数として受け取った emailpasswordUsernamePasswordAuthenticationToken インスタンスを生成して daoAuthenticationProvider の authenticate() メソッドに渡すだけです。これで認証が失敗したら AuthenticationException がスローされるので「例外処理」の要領で作った ExceptionResolver で特定のページ(例えば、ログイン画面)に飛ばすことができます。

AuthManagerImpl.java(抜粋)
package wrider.dao;
    :

@Repository
public class AuthManagerImpl implements AuthManager {
 
 @Autowired
 private DaoAuthenticationProvider daoAuthenticationProvider;
 
 public Authentication authenticate(String email, String password) {
  return this.daoAuthenticationProvider.authenticate(new UsernamePasswordAuthenticationToken(email, password));
 }

}

サービスレイヤー
サービスレイヤーでは、コントローラーから受け取った IdCard オブジェクトから emailpassword を取り出して、上記 authManager を呼び出しているだけです。認証が成功した場合は、DAO から返された Authetication オブジェクトをそのまま、呼び出し元に返すだけです。

AuthServiceImpl.java(抜粋)
package wrider.service;
    :
    
@Service
public class AuthServiceImpl implements AuthService {
  
  @Autowired
  private AuthManager authManager;
  
  public Authentication authenticate(IdCard idCard) {
    return authManager.authenticate(idCard.getEmail(), idCard.getPassword());
  }

}

コントローラー
コントローラー AccountController では、ログインフォームに入力されたデータのバリデーションと認証結果(Authentication オブジェクトとして返ってくる)の SecurityContextHolder への保存を行っています。

AccountController.java(抜粋)
:
@Controller
@SessionAttributes({"idCard", "userProfile", "confirmationUserProfile"})
public class AccountController {
  
  @Autowired
  private AuthService authService;
    :
  @RequestMapping(value="/account/login.html", method=RequestMethod.POST)
  public ModelAndView login(
      @ModelAttribute("userProfile") UserProfile userProfile,
      @Valid IdCard idCard, BindingResult br,
      HttpServletRequest request, HttpServletResponse response, HttpSession hsession) {
    
    ModelAndView mav = new ModelAndView();
    
    if (br.hasErrors()) {
      mav.getModel().putAll(br.getModel());
      mav.setViewName("account/login");
    }
    else {
      SecurityContextHolder.getContext().setAuthentication(this.authService.authenticate(idCard));
      userProfile = accountService.getProfile(idCard);
      setCookies(response, request, hsession, userProfile);
      
      mav.addObject("userProfile", userProfile);
      mav.setViewName("redirect:../welcome/home.html");
      
    }
    return mav;
  }
    :
}

以上のコードを実行した結果得られた SecurityContext の一例が以下です。
..core.context.SecurityContextImpl@85a9d015:
  Authentication:
   ..authentication.UsernamePasswordAuthenticationToken@85a9d015:
    Principal: ..core.userdetails.User@23506b1: Username: xxx@yyyy.jp; 
    Password: [PROTECTED]; 
    Enabled: true; 
    AccountNonExpired: true; 
    credentialsNonExpired: true; 
    AccountNonLocked: true; 
    Granted Authorities: ROLE_USER; 
    Credentials: [PROTECTED]; 
    Authenticated: true; 
    Details: null; 
    Granted Authorities: ROLE_USER

作りこみ次第でもっと様々な情報を保存することもできます。既存システムとの統合も考慮された Spring Security は「全てを我々に合わせて作り変えろ」ではなく「必要な部分だけ使ってね」といった感じの距離感を持ったフレームワークだと思いました。

尚、Spring Security の認証メカニズムについては、リファレンスの“Technical Overview”や“7.1 The AuthenticationManager, ProviderManager and AuthenticationProviders”に詳しく記されています。

2012年5月7日月曜日

Anonymous 認証とポートマッピング

ある URL では http、別の URL では https で接続したいときがあります。今回は Spring Security を使って、その辺の機能を追加します。

準備 - Spring Security
Spring Security のサイトから spring-security-[version].RELEASE.zip をダウンロード後、解凍し、必要なファイルを /WEB-INF/lib にコピーし、ビルドパスにも追加します(現時点の最新バージョンは spring-security-3.1.0.RELEASE です)。

今回 cas, ldap, openid, remoting は明らかに使いませんし、samples も必要ないので、それら以外を上記の要領で環境に追加します。

名前空間
まず“Security Namespace Configuration”に従い、コンテキストファイルに Spring Security の設定を記述するための名前空間を追加します。私の場合 springSecurityConf.xml というファイルを作成し、/WEB-INF/conf に置くことにしました。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xmlns:security="http://www.springframework.org/schema/security"
     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">

Authentication Manager の構成
Spring Security の機能を使うためには Authentication Manager が構成されていなければなりません。今回は Anonymous 認証で行きたいと考えているので“Anonymous Authentication”の解説と名前空間リファレンス <security:authentication-manager/> を参考に Authentication Manager を構成します。

すると、こんな感じです。
  <!-- Authentication Manager-->
  <security:authentication-manager alias="authenticationManager">
    <security:authentication-provider ref="anonymousAuthenticationProvider"/>
  </security:authentication-manager>
  
  <!-- Anonymous Authentication -->
  <bean id="anonymousAuthenticationProvider"
    class="org.springframework.security.authentication.AnonymousAuthenticationProvider">
    <property name="key" value="psid"/>
  </bean>
  
  <bean id="anonymousAuthenticationFilter"
    class="org.springframework.security.web.authentication.AnonymousAuthenticationFilter">
    <property name="key" value="psid"/>
    <property name="userAttribute" value="anonymousUser, ROLE_ANONYMOUS"/>
  </bean>

Anonymous Authentication
これでも動きます。ただ、AnonymousAuthenticationFilter に関しては、API ドキュメントを見ると setKey と setUserAttribute は Deprecated use constructor injection instead となっています。気になったので以下のように constructor injection を使う方法でも試してみたら <property/> で設定した時と「同じように」動きました。
  <bean id="anonymousAuthenticationFilter"
    class="org.springframework.security.web.authentication.AnonymousAuthenticationFilter">
    <constructor-arg index="0" type="java.lang.String" value="psid"/>
  </bean>

リファレンスと GrepCode を見ると Spring Security の Anonymous 認証では、..Filter が ..Token の生成と SecurityContextHolder への追加、..Provider が ..Token の認証を行います(..の箇所は"AnonymousAuthentication")。つまり、SecurityContextHolder を見れば Anonymous 認証の内容が確かめられるということになります。で、確かめてみました。

 ..core.context.SecurityContextImpl@90572420:
  Authentication:
   ..authentication.AnonymousAuthenticationToken@90572420:
    Principal: anonymousUser; 
    Credentials: [PROTECTED]; 
    Authenticated: true; 
    Details:
     ..web.authentication.WebAuthenticationDetails@255f8:
      RemoteIpAddress: 127.0.0.1; 
      SessionId: A1B2C3...XYZ; 
      Granted Authorities: ROLE_ANONYMOUS


<property/>, <constructor-arg/> いずれの方法でも上と同じような内容になりました。

<security:http/> の設定
Authentication Manager が構成できたら、いよいよ本丸です。<security:http/> を参考に、URL パターンとポートをマッピングします。シナリオは

 ・/welcome は http
 ・/account は https

という単純なものにしました。
  <security:http pattern="/style/**" security="none"/>
  
  <security:http auto-config="true">
    
    <!-- account service -->
    <security:intercept-url pattern="/account/**" requires-channel="https" access="IS_AUTHENTICATED_ANONYMOUSLY"/>
    
    <!-- welcome service -->
    <security:intercept-url pattern="/welcome/**" requires-channel="http" access="IS_AUTHENTICATED_ANONYMOUSLY"/>
    
    <!-- http/https port mappings -->
    <security:port-mappings>
      <security:port-mapping http="8080" https="8443"/>
    </security:port-mappings>
    
  </security:http>

<security:intercept-url/>pattern で URL パターン、それに対応するチャネル(http/https)とアクセス属性をそれぞれ requires-channel, access で指定しています。

そして <security:port-mappings/> で、http, https のそれぞれについて使用するポート番号を指定します。

これで完了です。私の場合、残る作業は、作成した springSecurityConf.xml を以下のように web.xml に登録するだけです。
  <context-param>
    <param-name>contextConfigLocation</param-name>
    <param-value>
    /WEB-INF/applicationContext.xml
    /WEB-INF/conf/springSecurityConf.xml
    </param-value>
  </context-param>

今回は割りと単純な例でしたが、Spring Sucurity にはまだまだ沢山の機能があります。単純でも一度設定作業に慣れておけば、そうした機能を使いたい時の閾がある程度は低くなると思います。個人的には DB との連動や Spring Social を使ったソーシャルメディアへの接続に興味が沸いてきました。いずれ...というより、(おそらく)次回からは、その辺に挑戦してみたいと思います。

2012年4月28日土曜日

例外処理

データベースアクセスの際に使われる決まり文句にはトランザクション境界を明示する begin, commit/rollback の他、例外を捕まえるための tyr {..} catch {..} [final {..}] があります。前者については @Transactional アノテーションや『トランザクションを“Declarative”に管理する』で紹介した“Declarative transaction management(宣言的トランザクション管理)”が使えます。一方、例外処理においても Spring Framework には便利な機能が用意されています。

HandlerExceptionResolver
その一つが HandlerExceptionResolver です。リファレンスの“16.11 Handling exceptions”には「Spring の HandlerExceptionResolver 実装は、コントローラー実行中に発生した予測不能の例外を処理する」とあります。

このインターフェースを使えば、Spring に DAO レイヤーから上げられた例外を特定のビューに解決させることができます。例えば以下のような感じです。

HibernateExceptionResolver.java
HandlerExceptionResolver を実装したカスタム例外リゾルバーです。同インターフェースに定義された resolveException() メソッドを実装しています。このメソッド内では、受け取った例外を instanceof で判別し、戻り値として ModelAndView に特定のビューをセットしています。必要とあらば例外情報をログに記録するなどの処理を挟むこともできます。このクラスでタッチしない例外の場合は null を返します。

尚、このクラス(HibernateExceptionResolver)では、抽象クラス AbstractHandlerExceptionResolver の作法を参考に HandlerExceptionResolver の他、Ordered を実装しています。order の値が大きいほど優先順位が低くなります。デフォルトとして Integer.MAX_VALUE を設定しています。
package wrider.controller;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.hibernate.HibernateException;
import org.springframework.core.Ordered;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;

public class HibernateExceptionResolver 
    implements HandlerExceptionResolver, Ordered {
  
  private int order = Integer.MAX_VALUE;
  
  public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
    ModelAndView mav = new ModelAndView();
    
    if (ex instanceof HibernateException) {
      mav.addObject("pageTitle", "Sorry");
      mav.setViewName("ex/dae");
      return mav;
    }
    else {
      return null;
    }
  }
  
  public int getOrder() {
    return this.order;
  }

}

上記リゾルバーを applicationContext.xml に登録します。
<!-- Exception Resolver -->
  <bean class="wrider.controller.HibernateExceptionResolver"/>

SimpleMappingExceptionResolver
もしも、これといった例外処理は必要なく、単純に特定のビューに解決するだけでいいのであれば SimpleMappingExceptionResolver が簡単です。以下のような感じで applicationContext.xml に SimpleMappingExceptionResolver を登録し、例外クラスとビュー名を設定するだけです。
<!-- Exception Resolve & Translate -->
  <bean class="org.springframework.web.servlet.handler.SimpleMappingExceptionResolver">
    <property name="exceptionMappings">
      <map>
        <entry key="org.hibernate.HibernateException" value="ex/dae"></entry>
      </map>
    </property>
  </bean>

@ExceptionHandler
また、コントローラークラス/メソッドに @ExceptionHandler アノテーションを付ける方法もあります。以下のような感じです。

AccountController.java(抜粋)
HibernateException が上がってきたら HibernateExceptionHandler()メソッドで処理するように定義しています。
@ExceptionHandler(org.hibernate.HibernateException.class)
  public ModelAndView HibernateExceptionHandler(org.hibernate.HibernateException ex, HttpServletRequest request) {
    ModelAndView mav = new ModelAndView();
    mav.setViewName("ex/dae");
    return mav;
  }

DAO メソッドのスリム化
トランザクションや例外に関する設定、処理を本体コードの外側に定義することで、本体コードを大幅にスリム化できます。例えば、以前作った ProfileManagement.javagetUserProfile()メソッドは以下のようになります。

使用前
public UserProfile getUserProfile(IdCard idCard) {
    
    final String HQL = 
      "from UserProfile as up " + 
      "where up.email = :email and up.password = :password";
    
    /*
     * Boilerplate pattern
     */
    
    UserProfile result = null;
    Session session = txManager.getSessionFactory().getCurrentSession();
    Transaction tx = session.getTransaction();
    
    try {
      tx.begin();
      
      result = (UserProfile)session.createQuery(HQL)
            .setParameter("email", idCard.getEmail())
            .setParameter("password", idCard.getPassword())
            .uniqueResult();
      
      tx.commit();
      
    } catch (Exception e) {
      tx.rollback();
      System.out.println("error occured: " + e.toString());
    }
    
    return result;
    
  }

使用後
決まり文句の排除に加え、setParameter()メソッドで個々にパラメータをセットする代わりに、setProperties()メソッドを使い HQL のプレースホルダーに、IdCard オブジェクトのフィールド値を割り当てています。
public UserProfile getUserProfile(IdCard idCard) {
    
    final String HQL = 
      "from UserProfile as up " + 
      "where up.email = :email and up.password = :password";
    
    return (UserProfile)txManager.getSessionFactory().getCurrentSession()
            .createQuery(HQL)
            .setProperties(idCard)
            .uniqueResult();
  }

実験
試しに上記コードの where up.email = :email .. の部分を up.mail に変えて例外を発生させてみました。すると上のほうで紹介したどの方法でも、図のような「ごめんなさい画面」が表示されました。

ある処理に関連する処理を一つのコードにまとめて書くより、特定の処理を専門的に扱うコードの連携として設計した方が、一つ一つのコードの目的が明確になりますし、重複やミス、見落としの確率も下がることを実感しました。何より、作りたい本来の機能に集中できます。Spring のようなフレームワークを使うことの本質を垣間見たような気がします。

2012年4月27日金曜日

トランザクションを“Declarative”に管理する

データベースにアクセスする際の“boilerplate(決まり文句)”にはうんざりさせられます。しかし、begin, commit/rollback といった決まり文句は、トランザクションの境界線をはっきりさせるために必要です。また、例外を捕まえて rollback するために try {..} catch {..}を使います。これもまた面倒です。

Declarative Transaction Management
Spring Framework はそうした面倒を緩和してくれる仕掛けを持っています。“Declarative Transaction Management(宣言的トランザクション管理)”もその一つです。これを使うと、「どの例外が発生したら rollback するか(declarative rollback rules)」とか「ダーティリードやファントムリードを許すか(isolation level)」とか「現在のトランザクションをサスペンドして、新しいトランザクションを生成するか(transaction propagation)」といったことを“宣言”できます。

宣言は XML で記述する方法と、@Transactional アノテーションの属性として記述する方法があります。今回、もちろん @Transactional は使いますが、種々の設定は XML で行うことにします。

その前に『Hibernate でデータアクセス(3)』で作成した hibernate.cfg.xml から、以下の行を削除しておきます。

<property name="hibernate.current_session_context_class">thread</property>

というか、JTA を使わないのであればこの項目は厳禁みたいです。以下のような書き込みが初歩的な相談事として巷に溢れていました。

宣言
リファレンス“11.5.1 Understanding the Spring Framework's declarative transaction implementation”に、Spring の宣言的トランザクションは AOP Proxy を介して実現されているとあります。実際、AOP と同様に AdvicePointcut を定義して、それらを Advisor で結び付けるというのが基本になります。

では、11.5.2 Example of declarative transaction implementation 以降の例と説明に従いながらトランザクションを宣言していきます。

名前空間
まずは、<tx:advice/> などの要素を利用するための名前空間を追加します。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    :
  xmlns:tx="http://www.springframework.org/schema/tx"
  xmlns:aop="http://www.springframework.org/schema/aop"
    :
  xsi:schemaLocation="
    :
  http://www.springframework.org/schema/tx 
  http://www.springframework.org/schema/tx/spring-tx-3.0.xsd
  http://www.springframework.org/schema/aop 
  http://www.springframework.org/schema/aop/spring-aop-3.0.xsd">
    :

<tx:advice/>
この advice にトランザクションに対する各種属性を記述していきます。トランザクション属性を適用したいメソッド名を <tx:method/> タグの name で指定します。デフォルト設定のままでいい場合は、その他の記述は必要ありません。例えば <tx:method name="*"/> みたいな感じです。
read-only
トランザクションがリードオンリーの場合は true にします。
timeout
トランザクションがタイムアウトするまでの時間を秒数で指定します。
rollback-for
ロールバックの切欠となる例外クラスを指定します。複数の場合はカンマ区切り。@Transactional アノテーションの説明には、Throwable のサブクラスでなければならないと記述されています。FQCN(完全修飾クラス名)で書いた方が無難かも。
no-rollback-for
rollback-for の逆の意味です。
propagation
トランザクションの伝播(propagation)に関する設定です。11.5.7 Transaction propagationREQUIRED, REQUIRES_NEW, NESTED の動きが解説されています。

また、IBM developerWorks の記事“Transaction strategies: Understanding transaction pitfalls”は、propagation と他の設定を組み合わせた際の動作を知る参考になると思います。同じ read-only + propagation.REQUIRED でも JDBC と JPA では内部の動きが違うんですね。

因みにこの記事にもある Unit of Work の考え方については Hibernate Core リファレンス“13.1.1. Unit of work”が参考になります。
isolation:
トランザクションの分離(isolation)レベルに関する設定です。コミット前のデータでもかまわない場合は READ_UNCOMMITTED, 最低限コミットされていなければならない場合は READ_COMMITTED, トランザクション内での繰り返しリードでデータが変わると困る場合は REPEATABLE_READ, 他のトランザクションの影響は絶対に許さない場合は SERIALIZABLE, その辺のことはデータソースに丸投げで構わない場合は DEFAULT という感じになると思います。ロックやパフォーマンスにも関わる部分なので慎重に考えたい項目です。

以上を勘案して次のように設定してみました。「change」で始まるメソッドでは、全ての例外が rollback のトリガーとなるよう設定していますが、もっと条件を細かくしてもいいかもしれません。
:
  <bean id="txManager"
    class="org.springframework.orm.hibernate4.HibernateTransactionManager"
    p:sessionFactory-ref="sessionFactory">
  </bean>
  
  <tx:advice id="noRollBackTxAdvice" transaction-manager="txManager">
    <tx:attributes>
      <tx:method name="get*" read-only="true" isolation="READ_UNCOMMITTED" propagation="REQUIRED"/>
    </tx:attributes>
  </tx:advice>
  <tx:advice id="rollBackTxAdvice" transaction-manager="txManager">
    <tx:attributes>
      <tx:method name="change*" read-only="false" isolation="READ_COMMITTED" propagation="REQUIRES_NEW"
        rollback-for="java.lang.Throwable"/> 
      <tx:method name="*"/>
    </tx:attributes>
  </tx:advice>
    :

<aop:pointcut/>
pointcut と次の advisor は <aop:config/> 内に定義します。記述方法は AOP の規則に則ります。

以下のコードでは更新系トランザクションメソッドの pointcut に「wriderChangeOperation」、参照系に「wriderGetOperation」、ストアドプロシージャを呼び出すメソッドの pointcut に「wriderMakeOperation」という ID を割り当てています。

<aop:config>
    <aop:pointcut 
      expression="execution(* wrider.service.*.get*(..))" 
      id="wriderGetOperation"/>
    <aop:pointcut 
      expression="execution(* wrider.service.*.make*(..))" 
      id="wriderMakeOperation"/>
    <aop:pointcut 
      expression="execution(* wrider.service.*.change*(..))" 
      id="wriderChangeOperation"/>

<aop:advisor/>
advisor で上記 advice と pointcut を紐付けます。

<aop:advisor advice-ref="noRollBackTxAdvice" pointcut-ref="wriderGetOperation"/>
    <aop:advisor advice-ref="rollBackTxAdvice" pointcut-ref="wriderMakeOperation"/>
    <aop:advisor advice-ref="rollBackTxAdvice" pointcut-ref="wriderChangeOperation"/>
  </aop:config>

@Transactional アノテーションの有効化
リファレンス“11.5.6 Using @Transactional”に従って @Transactional を利用できるようにします。

<tx:annotation-driven transaction-manager="txManager"/>

次回は DAO レイヤーの boilerplate な部分を排除します。

2012年4月23日月曜日

Hibernate でデータアクセス(3)

前回、前々回に続き Hibernate と c3p0 を使うための設定を行います。

リファレンス“11.3 Understanding the Spring Framework transaction abstraction”の説明と例に倣いながら Data Source, Session Factory, Transaction Manager を applicationContext.xml に定義します。まずは Data Source から。

Data Source
Data Source には c3p0 コネクションプールを使用します。

pooledDataSource ビーン
applicationContext.xml 内に“pooledDetaSource”というビーンを定義し、class 属性で ComboPooledDataSource を指定しています。

このビーン定義内では基本的なプロパティ driverClass, jdbcUrl そしてdescription を設定しているだけです。詳細な設定はクラスパスに配置した c3p0-config.xml に記述しています(もちろん上記ビーン定義内に記述することも可能です)。
<!-- 'Pooled' DataSource Configuration -->
  <bean id="pooledDataSource" 
  class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close"
    p:driverClass="com.mysql.jdbc.Driver"
    p:jdbcUrl="jdbc:mysql://[host]/[db]?useUnicode=true&characterEncoding=utf-8"
    p:description="C3P0 Pooled Data Source">
  </bean>

c3p0-config.xml
データベース接続に関するプロパティ(user, password)とコネクションプールの上限、下限に関するプロパティ(maxPoolSize, minPoolSize)を設定しています。maxStatements は、グローバル PreparedStatement キャッシュの上限です。
<?xml version='1.0' encoding='utf-8'?>
<c3p0-config>
  <default-config>
    <property name="user">demo</property>
    <property name="password">demo</property>
    <property name="initialPoolSize">2</property>
    <property name="minPoolSize">2</property>
    <property name="maxPoolSize">5</property>
    <property name="acquireIncrement">1</property>
    <!-- Global PreparedStatement Cache -->
    <property name="maxStatements">200</property>
  </default-config>
</c3p0-config>

設定できるプロパティの詳細は c3p0 サイトの“Appendix A: Configuration Properties”に記載されています。

Session Factory
Hibernate セッションを取得するための Session Factory を構成します。class 属性にLocalSessionFactoryBean を指定しています。

sessionFactory ビーン
DataSource に前述の pooledDataSource を指定しています。Hibernate 関連の詳しいプロパティを <property name="hibernateProperties"> で設定することもできますが、今回は configLocation で示したファイル(/WEB-INF/conf/hibernate.cfg.xml)に記述します。
<bean id="sessionFactory" scope="singleton"
    class="org.springframework.orm.hibernate4.LocalSessionFactoryBean"
    p:dataSource-ref="pooledDataSource"
    p:configLocation="/WEB-INF/conf/hibernate.cfg.xml">
  </bean>

hibernate.cfg.xml
SQL の方言(dialect)を吸収する hibernate.dialect に MySQLDialect を指定しています。また hibernate.show_sql を true にすることで Hibernate が発行した SQL 文をコンソールに出力させます。今回はアノテーションで OR マッピングを定義しているので <mapping/>要素には、対象クラス(UserProfile と IdCard)を登録しているだけです。
<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE hibernate-configuration PUBLIC
    "-//Hibernate/Hibernate Configuration DTD 3.0//EN"
    "http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
  <session-factory>
    <!-- Hibernate properties -->
    <property name="hibernate.dialect">org.hibernate.dialect.MySQLDialect</property>
    <property name="hibernate.show_sql">true</property>
    <property name="hibernate.current_session_context_class">thread</property>
    <!-- Mapping -->
    <mapping class="wrider.model.UserProfile"/>
    <mapping class="wrider.model.IdCard"/>
  </session-factory>
</hibernate-configuration>

Transaction Manager
Spring トランザクションの基点となる Transaction Manager の定義です。

txManager ビーン
HibernateTransactionManager を使います。sessionFactory プロパティは前述の sessionFactory ビーンを参照しています。
<bean id="txManager"
    class="org.springframework.orm.hibernate4.HibernateTransactionManager"
    p:sessionFactory-ref="sessionFactory">
  </bean>

ビュー
最後にビューです。ログインフォーム(login.jsp)では <form:form/> 要素の modelAttribute 属性で“idCard”、アカウント作成フォーム(registrationForm.jsp)では“userProfile”を指定しています。

login.jsp(抜粋)
:
  <form:form action="login.html" method="POST" modelAttribute="idCard">
    <fieldset>
    <legend>ログイン</legend>
    <div class="items">
      <label class="item" for="email">メールアドレス</label>
      <div class="item_body">
        <input type="text" id="email" name="email" value="${idCard.email}"/>
        <form:errors path="email" cssClass="red"/>
      </div>
    </div>
    :

registrationForm.jsp(抜粋)
:
  <form:form action="register.html" method="POST" modelAttribute="userProfile">
    :

図のログイン画面から認証情報を送信すると コンソールに Hibernate セッションで発行された SQL 文が Hibernate: select userprofil0_.pid as pid0_, ... from UserProfile userprofil0_ where userprofil0_.email=? and userprofil0_.password=? のような感じで表示されます。認証が成功すると welcome/home.html に遷移します。アカウント作成が成功した場合も同様です。

尚、MySQL との接続の様子はコマンドラインで
mysqladmin -u [user] -p extended-status | egrep "connect"
あるいはMySQL クライアントから
show status like '%connect%';

のように打ち込めば確かめられます。

以上でミッション終了です。一旦データベースへの接続環境が整えば、Spring (や Hibernate)が持つ色々な機能が利用できるようになります。個人的には AOP の仕掛けを利用したトランザクション管理に興味が沸きました。そうしたものを含めていつか試したいと思います。