JSF2.0でボタンの2度押しチェックをする

この記事は Java EE Advent Calendar 2012*1 の12/18分の記事です。

昨日は@yumix_hさんの JAX-RSでファイルアップロード! です。
明日は@den2snさんです。

今回は、ボタンの2度押しチェックについて考えてみたいと思います。

1. ボタンの2度押しとは

2度押しと書くと、直感的にSubmitボタンを連打されることが思い浮かびますが、Webアプリケーションでよくある問題として、画面遷移後にF5(更新)された場合も意図しない多重POSTが発生します。以下の図をみてください。

f:id:n_agetsuma:20121211215212p:plain

JSF2.0を使う場合に、それぞれの2度押しにどう対処するかを以下に示します。特に2つ目に紹介するトークンの実装は手間がかかったので、もっとシンプルな方法があれば大歓迎です。

2. Post-Redirect-Getによって、ブラウザ更新による再POSTを防ぐ

Post-Redirect-Get(以下、PRG)については、wikipedia(http://en.wikipedia.org/wiki/Post/Redirect/Get)の図がわかりやすかったのでおすすめです。簡単にいうと、リダイレクトさせることでブラウザバーのURLを遷移後の画面を示すHTMLに変更させ、F5を押しても遷移先のページに対してGETされるようにする対策です。

JSF2.0では標準仕様に含まれる機能でPRGパターンを実装することができます。
実装は簡単で、遷移先画面のページ名の後に『?faces-redirect=true』を付与するだけです。

/** JSFのアクションメソッド */
public String buy() {
    // なんらかの購入処理がここに入る
    // 結果画面にリダイレクトさせる
    return "result.xhtml?faces-redirect=true"
}

3. トークンを実装して、同じ処理が2回実行されるのを防ぐ

リダイレクトの設定のみでは、レスポンスが返ってくる前にSubmitボタンを連打されることが防げません。
JavaScriptによってボタンを無効化が簡単な対処ですが、ブラウザ側でJavaScriptが無効であった場合を考えて、サーバ側でも何らかの対策を実施する必要があると思います。

JBossSeamにおいては<s:token>タグとして実装が提供されていますが、JSF2.0標準の範囲ではトークン機能はないようです。JSF2.2に向けて提案*2はされているようですが、チケットは依然オープンです。

以下に紹介する実装案は、SAStrutsで実装したkuwalabさんのブログ*3および、CSRF対策を目的とした固定トークンを用いた実装例を紹介しているEiseleさんのブログ*4を参考にしています。

率直にいうと、かなりパクっています。上記ブログで紹介されている実装例ののいいとこ取りして、JSF2.0で動くようにしてみました。全体はGitHub(https://github.com/n-agetsu/ExtValSample)に掲載しています。

3-1. 使い方

(1) トークンの保存

トークンの保存はアノテーションでできるようにします。自作アノテーションである@SaveTokenを付与すると、メソッド実行後にトークンがセッションに保存されます。Struts1.xのAction#saveToken()のイメージです。

/** @SaveToken 使用例 */
@SaveToken
public String action() {
    return "next.xhtml"
}

(2) トークンの検証

送信されたトークンが正しいかの検証もアノテーションでできるようにします。自作アノテーションである@CheckTokenを付与すると、メソッド実行前にトークンの検証を行います。チェックエラー時のデフォルトの挙動はリクエスト元画面の再表示です。パラメータにerrorPageを設定することで、チェックエラー時の遷移先を明示できるようにします。

/** @CheckToken 使用例 */
@CheckToken
//@CheckToken(errorPage="error.xhtml")
public String action() {
    // 複数回実行されてはいけない大事な処理
}

トークンチェックのエラー時の動作は、デフォルト(errorPageが設定されていない場合)はリクエスト元の画面を再表示します。errorPageが設定されている場合は、設定されたページに画面遷移します。

トークンチェックのエラー時に画面遷移だけではなく、なんらかの処理(addMessage()など)を行いたい場合は、@CheckTokenではく、シングルトンのTokenクラスを直接インジェクションして検証APIであるToken#isTokenValid()を呼び出します。

@Model
public class SampleController {

    @Inject Token token;

    public String action() {
        if (!token.isTokenValid()) {
            // トークンチェックエラー時の処理をここに書く
            // Struts1.xを同じイメージ
        }
    }

(3) ページへのトークン埋め込み

Struts1.xの場合、<html:form>タグの実装であるFormTag#renderToken()において、セッションにトークンが存在する場合、自動的にhiddenに出力する機能があります。JSF2.0においても、カスタムタグの実装によって同様のタグは作成可能です。

<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:s="http://agetsuma.net/facelets"
      xml:lang="ja" lang="ja">

    <s:tokenForm>
      <h:inputText value="#{sampleController.name}"/>
      <h:commandButton value="submit" action="#{sampleController.action}"/>
    </s:tokenForm>
</html>

javax.faces.component.html.HtmlFormクラス(<h:form>)を継承して、タスタムタグ <s:tokenForm> を作成します。<s:tokenForm> タグは、セッションにトークンが存在する場合、トークンをhiddenフィールドに出力します。

3-2. 実装する

(1) トークンクラスの作成

まずはTokenクラスです。Struts1.xのTokenProcessorクラスに相当するものがJSFにはないので、真似してみます。TokenProcessorクラスを参考にJSFで使いやすいように、saveToken()とisTokenValid()ではHttpServletRequestオブジェクトを引数ではなくFacesContextから取得しています。ちょっと長いコードですが、縦にスクロールして全体が見れます。

package token;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;

import javax.servlet.http.HttpSession;
import javax.faces.context.FacesContext;
import javax.inject.Singleton;

@Singleton
public class Token {
    public void saveToken() {
        HttpSession session  = getCurrentSession();
        session.setAttribute(TokenInput.TOKEN_NAME, generateToken());
    }

    public boolean isTokenValid() {
        // 現在のコンテキストのセッションを取得
        HttpSession session = getCurrentSession();
        if (session == null) {
            return false;
        }
		
        // isTokenValid()を並行で実行した場合、本来は1つのトークンは
        // 1回の判定にのみ有効だが、スレッドコンテキストによっては、
        // 複数のリクエストが許可されてしまう可能性がある。
        // そのため、セッションをロックオブジェクトとし、同一セッションによる
        // 複数並行リクエストを調停する。
        synchronized (session) {
            // セッションに保存されたトークンを取得
            String saved = (String) session
                                       .getAttribute(TokenInput.TOKEN_NAME);

            if (saved == null) {
                return false;
            }

            // チェック成功の有無に関わらず、トークンをリセット
            session.removeAttribute(TokenInput.TOKEN_NAME);

           // リクエストスコープから送信されたトークンを取得
            String token = getRequestScopeToken();	

            if (token == null) {
                return false;
            }
		
            return saved.equals(token);
        }
    }
	
    private String generateToken() {
        // TODO 乱数生成方法はもう少し考える必要あり
        // 以下のコードは下記サイトからの引用
        // http://www.javapractices.com/topic/TopicAction.do?Id=56
        // CSRF対策はJSFのVIEWSTATEに任せ、このトークンはあくまで2度押し用なので、
        // System.nanoTime()などの現在時刻をそのままトークンにしてもいいかも。
        try {
            SecureRandom prng = SecureRandom.getInstance("SHA1PRNG");

            //generate a random number
            String randomNum = new Integer( prng.nextInt() ).toString();

            //get its digest
            MessageDigest sha = MessageDigest.getInstance("SHA-1");
            byte[] result =  sha.digest(randomNum.getBytes());
		    
            return toHex(result);
        } catch (NoSuchAlgorithmException e) {
                return null;
        }
    }
		
    private String toHex(byte[] buffer) {
        StringBuffer sb = new StringBuffer(buffer.length * 2);

        for (int i = 0; i < buffer.length; i++) {
            sb.append(Character.forDigit((buffer[i] & 0xf0) >> 4, 16));
            sb.append(Character.forDigit(buffer[i] & 0x0f, 16));
        }

        return sb.toString();
    }
	
    private HttpSession getCurrentSession() {
        return (HttpSession) FacesContext.getCurrentInstance()
            .getExternalContext().getSession(true);
    }

    private String getRequestScopeToken() {		
        return FacesContext.getCurrentInstance().getExternalContext()
            .getRequestParameterMap().get(TokenInput.TOKEN_NAME);
    }

}

(2) トークンを保存するインターセプタの作成

次にトークンを保存するインターセプタを、CDIの機能を使って実装します。

まずは、@SaveTokenの作成です。
JavaEE6から加わったCDIには、インターセプタ・バインディングという機能があり、自作アノテーションとインターセプタ実装を括り付けることができます。自作アノテーションには@InterceptorBindingを付けるのがルールです。

@Documented
@InterceptorBinding
@Retention(RUNTIME)
@Target({METHOD, TYPE})
public @interface SaveToken { }

インターセプタ実装クラスにも、自作アノテーションである@SaveTokenを付けます。
先ほど紹介したTokenクラスのsaveToken()を実行して、トークンをセッションに保存します。

@SaveToken
@Interceptor
public class SaveTokenInterceptor {
    /** トークンを管理するシングルトン */
    @Inject Token token;

    @AroundInvoke
    public Object saveToken(InvocationContext ic) throws Exception {
        try {
            return ic.proceed();
        } finally {
            // トークンを新たに生成し、セッションに保存する。
            // メソッドを実行した後にトークンを設定しているのは、
           // @SaveTokenが付けられたメソッドが連続したときに、
           // isTokenValid()を使ってアクションで検証する前に、
           // トークンが変更されるのを防ぐため。
           token.saveToken();
        }
    }
}

(3) トークンを検証するインターセプタの作成

トークンを検証する機能も、同様にCDIのインターセプタ・バインディング機能を使って作成します。
まずは、自作アノテーションである@CheckTokenの作成です。

@Documented
@InterceptorBinding
@Retention(RUNTIME)
@Target({METHOD, TYPE})
public @interface CheckToken {
    /**
      * Nonbindingを設定しないと、errorPage要素を設定した場合に、
      * インターセプトされなくなる。
      * 要素の有無を含めて厳格にバインドされているため。
      * 
      * @return トークンチェックエラー時の遷移先ページ
      */
    @Nonbinding String errorPage() default "";
}

インターセプタ実装はSaveTokenの考え方と同じです。
ちょっとだけ困ったのは、@CheckTokenのerrorPageのデフォルトにはnullが設定できないため、インターセプタにおいて空文字""の設定をnullに変換してreturnしています。チェックエラー時はInvocationContext#proceed()がスキップされるため、JSFのアクションクラスにおいてreturn nullを実行することと同じような動作がインターセプタで実現できます。

@CheckToken
@Interceptor
public class CheckTokenInterceptor {

    private static final String DEFAULT_ERROR_PAGE = "";
	
    /** トークンを管理するシングルトン */
    @Inject Token token;

    @AroundInvoke
    public Object checkToken(InvocationContext ic) throws Exception {
        if (token.isTokenValid()) {
            // トークンが正しい場合は、アクションを実行
            return ic.proceed();
        }

        CheckToken checkToken = ic.getMethod().getAnnotation(CheckToken.class);
        String errorPage = checkToken.errorPage();

        // @CheckTokenのerrorPage要素がデフォルトの場合は、現在の画面を再表示
        if (DEFAULT_ERROR_PAGE.equals(errorPage)) {
            return null;
        }

        // デフォルトではない場合は、指定されたページに遷移
        return errorPage;
    }
}

(4) 作成したインターセプタをbeans.xmlに登録

CDIのインターセプタでは、EJBのインターセプタと異なり、beans.xmlに対象のインターセプタ実装を定義する必要があります。1つのメソッドに複数のインターセプタが設定された場合、このbeans.xmlに定義された順番で呼び出されます。
トークンのチェックは、トークンの新規セーブよりも先に実行してほしいため、チェックするインターセプタを先に定義します。

<interceptors>
  <class>token.CheckTokenInterceptor</class>
  <class>token.SaveTokenInterceptor</class>
</interceptors>

XMLに設定するのは少し面倒に感じるかもしれませんが、Weldのマニュアル*5によると、優先順位の定義だけでなく、デプロイ時にインターセプタの有効と無効を切り替えやすくさせることが、XMLを使うメリットのようです。

(5) トークンをHTML hidden属性に出力するカスタムコンポーネント

最後に、トークンを出力するカスタムタグを作ります。

カスタムタグは、Formに相当するTokenFormと、トークンが埋め込まれるhiddenフィールドに相当するTokenInputの2クラスを作成します。
まずは、TokenFormからです。

@FacesComponent("sample.TokenForm")
public class TokenForm extends HtmlForm {

    @Override
    public void encodeBegin(FacesContext context) throws IOException {
        HttpSession session = (HttpSession) context.getExternalContext().getSession(false);

        // トークンが未設定であった場合は、hidden要素は追加しない
        if (session != null) {
            String token = (String) session.getAttribute(TokenInput.TOKEN_NAME);
            if (token != null) {
                TokenInput tokenInput =  new TokenInput();
                tokenInput.setId(getClientId() + "_Token");

                // Formの子要素として、トークンを持つhidden要素を追加
                getChildren().add(tokenInput);
            }
        }	
		
        super.encodeBegin(context);
    }
}

@FacesComponentに設定した"sample.TokenForm"は、あとでタグ定義XMLファイルを作成するときに使うコンポーネント名を示します。Struts1.xと同じように、セッションにトークンが存在する場合のみ、後述するTokenInputをTokenFormの子要素として追加します。

次にTokenInputクラスです。セッションからトークンを取得し、hiddenフィールドのvalueに設定しています。

@FacesComponent("sample.CSRFTokenInput")
public class TokenInput extends UIComponentBase {

    public static final String TOKEN_NAME = "DoubleClickPreventToken";

    @Override
    public void encodeEnd(FacesContext context) throws IOException {
        HttpSession session = (HttpSession)context.getExternalContext().getSession(false);

        // セッションからトークンを取得
        String token = (String) session.getAttribute(TokenInput.TOKEN_NAME);

        // input type="hidden" としてトークンを出力する
        ResponseWriter responseWriter = context.getResponseWriter();
        responseWriter.startElement("input", null);
        responseWriter.writeAttribute("type", "hidden", null);
        responseWriter.writeAttribute("name", TOKEN_NAME, "cliendId");
        responseWriter.writeAttribute("value", token, "CSRFTOKEN_NAME");
        responseWriter.endElement("input");
    }

    @Override
    public String getFamily() {
        return null;
    }
}

最後にカスタムタグを定義するXMLの作成と、web.xmlへの適用を行います。
カスタムタグの定義ファイルは、web.xmlと同じディレクトリであるWEB-INF直下に置きました。

<namespace>タグには、tokenFormやtokenInputを使用するXHTMLのhtml要素に定義するネームスペースを定義します。<tag-name>タグには、タグ名を定義します。<component-type>タグには先ほど@FacesComponentで定義したコンポーネント名を指定します。

<?xml version="1.0" encoding="UTF-8"?>
<facelet-taglib version="2.0"
    xmlns="http://java.sun.com/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
    http://java.sun.com/xml/ns/javaee/web-facelettaglibary_2_0.xsd">
    <namespace>http://agetsuma.net/facelets</namespace>
    <tag>
        <tag-name>tokenForm</tag-name>
        <component>
            <component-type>sample.TokenForm</component-type>
        </component>
    </tag>
    <tag>
        <tag-name>tokenInput</tag-name>
        <component>
            <component-type>sample.TokenInput</component-type>
        </component>
    </tag>
</facelet-taglib>

web.xmlに、カスタムタグ定義ファイルの置き場所を追加します。

<context-param>
   <param-name>javax.faces.FACELETS_LIBRARIES</param-name>
   <param-value>/WEB-INF/token_taglib.xml</param-value>
</context-param>

以上がJSF2.0における、ボタン2度押しの実装です。

最後に

今回の実装は、画面遷移を伴わないAjaxによるSubmitを行うとチェックがうまく働きません。
トークンチェック時にトークンを必ずリセットしているため、Ajaxによって同じトークンが2回送信されると、2回目はエラーになります。

やりたいことは2度押しチェックだけなのですが、長々しくなってしまいました。もっとシンプルな実装を考える必要がありそうです。

*1:ATND http://atnd.org/events/33783

*2:http://java.net/jira/browse/JAVASERVERFACES_SPEC_PUBLIC-559

*3:http://d.hatena.ne.jp/kuwalab/20080217/1203253826

*4:http://blog.eisele.net/2011/02/preventing-csrf-with-jsf-20.html

*5:http://docs.jboss.org/weld/reference/1.1.5.Final/en-US/html/interceptors.html#d0e3558