JSF2.0でボタンの2度押しチェックをする
この記事は Java EE Advent Calendar 2012*1 の12/18分の記事です。
昨日は@yumix_hさんの JAX-RSでファイルアップロード! です。
明日は@den2snさんです。
今回は、ボタンの2度押しチェックについて考えてみたいと思います。
1. ボタンの2度押しとは
2度押しと書くと、直感的にSubmitボタンを連打されることが思い浮かびますが、Webアプリケーションでよくある問題として、画面遷移後にF5(更新)された場合も意図しない多重POSTが発生します。以下の図をみてください。
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