Apache Shiro を使ってみました

この記事は Java EE Advent Calendar 2014の12/17分の記事です。昨日は
@glory_ofさんのJAX-RSのレスポンスでした。明日は@nagaseyasuhitoさんです。


Java EE8では、『使い方が複雑・各APサーバ固有のレルム設定がよくわからん』とあまり評判のよくないセキュリティ周りの機能の再整理*1が行われようとしています。

しかし、Java EE8の仕様がリリースされるのはだいぶ先の2016年。

そんなに待てないので、Apache Shiroを試してみました。

Apache Shiroの既存の他の記事は部分的なコードの抜粋が多く、動かせるコードがあまり見当たらなかったので、GitHubにサンプルコードとしてまとめてみました。

Apache Shiro とは

Easy To Useを一番の目的にしたJavaのセキュリティフレームワークです。

歴史は古く2003年頃にJSecurityプロジェクトとして始まり、2008年よりApache Software Foundationに移管されました。Shiroは以下のような機能を持ちます。

  • 認証 (Authentication)
    • いわゆる『ログイン』機能です。
  • 認可 (Authorization)
    • アクセス制御。例えば、偉い人だけが人事計画情報が見れるなど。
  • 暗号 (Cryptography)
    • Java標準の暗号APIをラップして、簡単にSHA-256ハッシュが取得できるなど。
  • セッション管理

Apache Shiroを使うと、APサーバごとにレルム設定に悩まなくても同じ設定で認証・認可周りが実装できます。

アーキテクチャと設定

コード例とshiroのコンフィグであるshiro.iniを踏まえて紹介します。
例では、DBMSに認証・認可情報を保存していますが、LDAP連携もできます。

f:id:n_agetsuma:20141214121915p:plain

Subject

ユーザは、このSubjectと呼ばれるインタフェースを通して、認証・認可の判断をShiroに委ねます。Subjectには、以下のようにSecurityUtilsからアクセス可能です。

// Shiroによる認証コード
Subject currentUser = SecurityUtils.getSubject();
UsernamePasswordToken token = new UsernamePasswordToken(email, password);
try {
  currentUser.login(token);
} catch (AuthenticationException ae) {
  // authentication failed.
}
SecurityManager

認証・認可に具体的にどのような仕組みを使うのかを管理しているクラスです。Shiroの設定ファイルであるshiro.iniをクラスパスからロードして、このSecurityManagerを組み立てます。実際のshoro.iniの例を示します。

[main]
# Default SHA-256, 500000 hash iteration, use salt
passwordService = org.apache.shiro.authc.credential.DefaultPasswordService
passwordMatcher = org.apache.shiro.authc.credential.PasswordMatcher
passwordMatcher.passwordService = $passwordService

ds = org.apache.shiro.jndi.JndiObjectFactory
ds.requiredType   = javax.sql.DataSource
ds.resourceName = java:comp/DefaultDataSource

jdbcRealm = org.apache.shiro.realm.jdbc.JdbcRealm
jdbcRealm.authenticationQuery = SELECT password FROM useraccount WHERE email = ?
jdbcRealm.userRolesQuery = SELECT userrole FROM useraccount WHERE email = ?
jdbcRealm.credentialsMatcher = $passwordMatcher
jdbcRealm.dataSource = $ds

securityManager.realms = $jdbcRealm

PasswordMatcherの設定

  • 最初の3行(passwordMatcher)は、パスワードの照合の仕組みを設定
    • Shiroに組み込まれているDefaultPasswordServiceを指定
    • ソルト付き/SHA-256/イテレーション50万回

データソース設定

  • 次の3行(ds)は、jdbcRealmが使うデータベースのアクセス先設定
    • コネクションプールを使いたいのでデータソースjava:comp/DefaultDataSourceを設定

JDBCレルム設定

  • 次の5行(jdbcRealm)は、認証・認可時にDBMSよりユーザ情報を取得するJdbcRealmを設定
    • デフォルトの設定はソースコードより確認できます
    • authenticationQueryは、認証時にパスワードを取得するSQL
      • デフォルトselect password from users where username = ?
    • userRolesQueryは、認可時にロール情報を取得するSQL
      • デフォルトselect role_name from user_roles where username = ?
    • jdbcRealm.credentialsMatcherに先ほど設定したpasswordMatcherを指定
      • 認証時に平文パスワードをShiroに渡すと、PasswordMatcherの設定に基づきハッシュ化してDBMS上に保存しているハッシュパスワードと照合される
    • dataSourceに先ほど設定ししたdsを指定
      • SQL実行時にデータソースが使われる

SecurityManager設定

  • 最後の securityManager.realms = $jdbcRealmで、セキュリティマネージャにレルムとして今まで設定してきたjdbcRealmを使うと設定
Realm

どこから認証・認可情報を取得するのかを示します(DBMS/LDAP/静的ファイル)。
公式リファレンスではDAO(Data Access Object)のようなものだとのこと。

前述の設定例にて、JdbcRealmの例を示しました。

使い方

サンプルコードに実例が色々と含まれていますが、簡単にまとめます。

pom.xml

shiro-coreが本体で、shiro-webがカスタムJSPタグや、Servletフィルタの実装が入っています。WebアプリケーションでShiroを使う場合は両方とも必要です。

<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-core</artifactId>
  <version>1.2.3</version>
</dependency>
<dependency>
   <groupId>org.apache.shiro</groupId>
   <artifactId>shiro-web</artifactId>
   <version>1.2.3</version>
</dependency>

設定ファイル (shiro.ini)

内容については前述の通りです。
Webアプリケーション(.war)の場合、WEB-INFの直下、またはクラスパスのルートmain/src/resources/shiro.iniに置きます。

API

パスワードのハッシュ化

Shiroは『ユーザ作成機能』を持っていません。
その代わり、ユーザ作成に便利なパスワードハッシュ機能を持っています。

Shiroが用意するDefaultPasswordServiceクラスではを使うと、ソルト付き/SHA-256/イテレーション50万回のパスワードハッシュが2行のコードで取得できます。

PasswordService ps = new DefaultPasswordService();
String encryptedPassword = ps.encryptPassword(yourPassword);

ハッシュ化されたパスワードは以下のような形式です。

将来的にSHA-256でも弱くなってきたとき、パスワードフォーマットにハッシュアルゴリズム名が入っているので、SHA-256が含まれるパスワードを持つユーザ全てにパスワード再設定依頼のメールを送るなどの使い方ができます。

$shiro1$SHA-256$500000$DaYxi7JkOR5asUxrMJVZiQ==$ELBJExhsFc+RiiV8uqGF5z/8DvoPZzzo8mXZHkWGVh0=

フォーマット
$shiro1$ハッシュアルゴリズム名$イテレーション回数$base64エンコードされたソルト$base64エンコードされたパスワード

このパスワード化されたハッシュをJPAなどを使ってDBMSに保存します。

DefaultPasswordServiceを使ってハッシュ化されたパスワードを生成した場合は、設定例で紹介したようにshiro.iniには認証時に使うハッシュとしてDefaultPasswordServiceを設定しなければいけません。ハッシュパスワード生成時と、認証処理の照合時で同じアルゴリズムでないと同一性が判断できないためです。

パスワードの中にソルト入ってて大丈夫?
安全性とプログラミングの煩雑さのトレードオフだと、徳丸先生からのアドバイスがあったので、扱う情報に応じてDefaultPasswordServiceではなくて、独自に実装したものを使うと良いと思います。

認証・認可

認証・認可はとても簡単にできます。

認証
UserNamePasswordTokenクラスにユーザIDと平文パスワードの組を設定し、Subjectに渡して判定します。
このloginメソッドの内部で、shiro.iniで設定したレルム設定に基づき、引数パスワードのハッシュ化と、DBMSからハッシュパスワードの取得および照合を行います。

// Shiroによる認証コード (再掲)
Subject currentUser = SecurityUtils.getSubject();
UsernamePasswordToken token = new UsernamePasswordToken(email, password);
try {
  currentUser.login(token);
} catch (AuthenticationException ae) {
  // authentication failed.
}

認証に成功すると、これ以降にsubject.isAuthenticated()を実行するとtrueが返るようになります。未認証の場合はfalseです。

// 認証済みかの判定
Subject currentUser = SecurityUtils.getSubject();
if (currentUser.isAuthenticated()) {
    // already login
} else {
   // not logged in
}

認証に失敗した場合は、AuthenticationExceptionの子クラス例外がエラー要因に応じて投げれられます。

あまり認証エラー理由を細かくユーザにフィードバックするのも良くないので、AuthenticationExceptionをキャッチしても良いと思います。

認可
認可もかんたんです。hasRoleメソッドで、現在アクセスしているユーザが引数のロールを持っているか判定します。

Subject currentUser = SecurityUtils.getSubject();
if (currentUser.hasRole("yourRoleName")) {
    // hasRole
} else {
    // not hasRole
}

このhasRoleメソッドの内部でも同様に、shiro.iniで設定したレルム設定に基づいてロール情報をDBMSから取得して照合しています。

ログアウト
ログアウトもシンプルで、logoutメソッドを実行するだけです。

Subject currentUser = SecurityUtils.getSubject();
currentUser.logout();
セッション操作

ServletのセッションAPIに似ていますが、セッションの終了だけはsession.invalidate()ではなく、session.stop()です。

Subject current = SecurityUtils.getSubject();
// セッションの取得。既存セッションが無ければ新規生成。
Session session = current.getSession();
// 既存セッションがなければnullを返す。
Session session1 = current.getSession(false);
// 属性の取得・設定
int cnt = (Integer)session.getAttribute("counter");
session.setAttribute("counter", 1);
// セッションの終了
session.stop();

Shiroはスタンドアロン環境でも使えるようにShiro固有のセッション管理機構を持っていますが、Servletコンテナで動作させた場合はデフォルトでServletコンテナのセッションが使われます。

このため、セッションを取得するといつも通り、クッキー名JSESSIONIDがSet-Cookieヘッダに載ってきます。

f:id:n_agetsuma:20141215225920p:plain

Webアプリで使う

フィルタの設定

ApacheShiroはスタンドアロンのアプリでも使えるらしいですが(試してない)、Webアプリで使う場合は、web.xmlに以下の設定が必要です。

<filter>
  <filter-name>ShiroFilter</filter-name>
  <filter-class>org.apache.shiro.web.servlet.ShiroFilter</filter-class>
</filter>
<filter-mapping>
  <filter-name>ShiroFilter</filter-name>
  <url-pattern>/*</url-pattern>
  <dispatcher>REQUEST</dispatcher>
  <dispatcher>FORWARD</dispatcher>
  <dispatcher>INCLUDE</dispatcher>
  <dispatcher>ERROR</dispatcher>
</filter-mapping>
<listener>
   <listener-class>org.apache.shiro.web.env.EnvironmentLoaderListener</listener-class>
</listener>

ShiroFilterの設定が漏れていると、SecurityUtils.getSubject()を実行したときに現在のコンテキストを取得できないと以下のような例外が返ってきます。dispatcherの部分はforwardなどのリクエストを伴わない遷移時にもフィルタが有効になることを意図しています。

org.apache.shiro.UnavailableSecurityManagerException: No SecurityManager accessible to the calling code, either bound to the org.apache.shiro.util.ThreadContext or as a vm static singleton.  This is an invalid application configuration.
	at org.apache.shiro.SecurityUtils.getSecurityManager(SecurityUtils.java:123)
	at org.apache.shiro.subject.Subject$Builder.<init>(Subject.java:627)
	at org.apache.shiro.SecurityUtils.getSubject(SecurityUtils.java:56)

EnvironmentLoaderListenerは、アプリ起動時にshiro.iniをロードしています。

カスタムJSPタグ

認証OK/NGでログインフォームへのリンクを表示するか、Webcome ユーザ名を表示するか分けたい、ロールに応じて出力情報を変えたいなど、認証・認可とテンプレートは密接な関係があります。

ShiroではJSPを対象にカスタムタグを用意しています。GitHub上では、Thymeleaf向けの拡張を作っている人もいるみたいです。

認証タグ

<shiro:authenticated>は、認証済みの場合のみ表示されます。
<shiro:principal />で認証済のユーザ名を取得することもできます。
<shiro:notAuthenticated>は未認証の場合のみ表示です。

<%@taglib prefix="shiro" uri="http://shiro.apache.org/tags" %>
<shiro:authenticated>
    <h3>Hello, <shiro:principal/></h3>
</shiro:authenticated>
<shiro:notAuthenticated>
    <h3>Hello, Guest</h3>
</shiro:notAuthenticated>

認可タグ
<hasRole>タグでは、ロール名を指定して表示可否を設定できます。

<shiro:hasRole name="MANAGER">
    <!-- 大事な情報 -->
</shiro:hasRole>
Remember me

ログインフォームには、以下のようにブラウザを閉じた後でも状態を保持するようにkeep me signed inのようなチェックボックスがあると思います。

f:id:n_agetsuma:20141215231013p:plain

ShiroではRemember me機能として、この機能の仕組みを提供しています。

このRemember meを有効にするためには、ログイン時のUsernamePasswordTokenのコンストラクタの引数にtrue/falseを追加するだけです。trueを設定するとRemember meが有効になります。

Subject currentUser = SecurityUtils.getSubject();
// remember me の有効化
UsernamePasswordToken token = new UsernamePasswordToken(email, password, true);
try {
  currentUser.login(token);
  ...

remember meを有効にした状態でログインすると、ユーザにはクッキー名rememberMeが返されます。ブラウザを閉じても、再度アクセスしたときにこのrememberMeクッキーが送られることで、状態を保持しています。

f:id:n_agetsuma:20141215231931p:plain

ログアウトすると、サーバよりMax-Age=0が設定されたrememberMeが返され、クッキーは削除されます。

f:id:n_agetsuma:20141215232111p:plain

注意が必要なのは、認証済み状態と、Remember Me状態は異なることです。例えば、JSPタグ<shiro:authenticated>で囲った部分はRemember Me状態では表示されません。

Remember Me状態は、Amazonでいうと買い物かごの操作はできるが、購入処理ができない状態を示しており、購入処理をするためには再度認証処理が必要です。

Remember Me状態は、APIではSubject.isRemembered()で判定できます。

Subject currentUser = SecurityUtils.getSubject();
if (currentUser.isRemembered()) {
   ...

JSPカスタムでは、<shiro:user>タグで囲ってある範囲が、Remember me状態で表示されます。

<shiro:user>
  <h3>Welcome back <shiro:principal/> !</h3>
  <p>Not <shiro:principal/> ?</p>
  <p>Please signin <a href="#">here</a></p>
</shiro:user>
その他

サンプルコードでは使っていないため詳細は紹介しませんが、form認証のように設定ファイルに認証対象のパスを書いて宣言的に設定する機能や、ロールよりももっと詳細に認可管理できるPermission機能など、必要そうな機能は一通り揃っています。

サンプルコード

以下のような画面を持つサンプルを作って公開してみました。上記で紹介した色々な機能がこのコードに盛り込まれています。ぜひShiroを使ってみようと思っている方は参考にしてみてください。

f:id:n_agetsuma:20141214010825p:plain

  • 左上のSign Up
    • ShiroのPasswordService機能を使って、入力されたパスワードをハッシュ化して、DBに保存しています
  • 右上のSign In
    • ShiroのAPIを使ってログインしています。ブラウザを閉じた後もセッションを保持するRemember meも実装しています。
  • 下のAutholizationのセクション
    • managerロールを持つアカウントでログインすると、登録されているユーザ一覧が表示されます。

まとめ

  • Apache Shiroは比較的シンプルに使えるセキュリティ管理ライブラリです
    • とはいえ、shiro.iniはそれなりに設定が必要です
    • APサーバの固有設定に依存せずにレルム設定できるのは嬉しいところ
  • ここまで書いておいて、そもそも認証・認可を各アプリで作り込むべきか?
    • まだ追いつけてませんが、OpenID Connectのような仕組みを使って、認証をアプリ外に委譲するのが、社内システムでも一般的になっていくのでしょうか
  • え? Java EEじゃない? サンプルコードの環境にGlassFish4を使ってるのでお許しを