ラベル DAO の投稿を表示しています。 すべての投稿を表示
ラベル DAO の投稿を表示しています。 すべての投稿を表示

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 テーブルを活用したいと考えているので JdbcDaoImpl を userDetailsService として登録します。
<!-- 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 は以下のように至ってシンプル、引数として受け取った email と password で UsernamePasswordAuthenticationToken インスタンスを生成して 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 オブジェクトから email と password を取り出して、上記 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年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.java の getUserProfile()メソッドは以下のようになります。

使用前
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 のようなフレームワークを使うことの本質を垣間見たような気がします。