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”に詳しく記されています。

0 件のコメント:

コメントを投稿