0.はじめに
最近は自分の中で、Javaおよびそのエコシステム再評価路線です。
特に、Apache系のプロジェクトは比較的歴史もあり、枯れて(安定して)る。
Apache Zeppelinが認証にApache Shiroを使っていて、面白そうだなぁと思ったので、
JavaのマイクロフレームワークSparkFramwork
に認証機能を持たせるべく、認証・認可フレームワークApache Shiro
を組み入れたので、そのメモ。
0.1 結論
- SparkFrameworkとApache Shiro統合する場合は、SparkFramework内蔵Jettyではなく、自前でJettyをセットアップしShiroのイベントリスナーとSparkFrameworkを追加する
- URLリライトセッションは無効にする
- 固定セッション攻撃対策をする場合は、Apache Shiroの認証フィルタをカスタマイズする
1.動機
- 毎回、自分で認証・認可まわりを実装するのが面倒。セキュリティは自分で実装するのはリスクも高い。
- ID/PWだけでなくLDAPとかの認証方式を、サクッと利用できるようにしたい。
- Apacheプロジェクト色々見て回っていて、Apache Shiroを使ってみたかった。
2.準備
今回はJava11で実装。
2.1 build.gradle
//中略
dependencies {
//後々、SparkFrameworkにApache Shiroを統合させる
implementation 'com.sparkjava:spark-core:2.9.3'
//テンプレートエンジンはなんでもいいが今回はThymeleaf
implementation 'com.sparkjava:spark-template-thymeleaf:2.7.1'
//この辺は本筋ではないDIやDBまわり
implementation group: 'com.google.inject', name: 'guice', version: '5.0.1'
implementation group: 'com.h2database', name: 'h2', version: '1.4.200'
implementation group: 'com.zaxxer', name: 'HikariCP', version: '5.0.0'
//これも本筋ではない。ログまわりの依存。Apache Shiroがcommons.loggingを要求するので、jcl-over-slf4j入れている
implementation group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.14.1'
implementation group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.14.1'
implementation group: 'org.slf4j', name: 'jcl-over-slf4j', version: '1.7.32'
//Apache Shiro本体とWebへ統合する機能
implementation group: 'org.apache.shiro', name: 'shiro-core', version: '1.8.0'
implementation group: 'org.apache.shiro', name: 'shiro-web', version: '1.8.0'
}
本筋は,Apache Shiro
の依存。データベースでID/PWを取得するのでH2 Database
と、コネクションプールのHikariCP
を依存に加えている。
2.2 DBの準備
事前にデータベースで、ユーザ名、パスワード、ロールをもつテーブル作っておく。ここでは、簡易にするためロールを同一テーブルにしているが、ロールは複数設定できるので別テーブルにしても良い。
CREATE TABLE User (
name VARCHAR(128) PRIMARY KEY,
passwordHash VARCHAR(128),
role VARCHAR(128)
);
3. Apache Shiroの設定と実行
3.1 shiro.iniの作成
クラスパスのshiro.ini
という設定ファイルで制御する。今回の例では、データベース(H2)にあるユーザ名とパスワードのハッシュで認証と、iniファイルに平文で書いたユーザ名・パスワードでの認証を行う。
[main]
# iniファイルによる認証設定 だがデフォルトでオンになるので不要
# iniRealm = org.apache.shiro.realm.text.IniRealm
# iniRealm.resourcePath = classpath:shiro.ini
# データベースによる認証設定
# ID/PWを問い合わせるHikariCPのデータソースを設定
ds = com.zaxxer.hikari.HikariDataSource
ds.username = sa
ds.password = ""
ds.driverClassName = org.h2.Driver
ds.jdbcUrl = jdbc:h2:./tmp/testdb;MODE=MySQL
# デフォルトのパスワードサービス(SHA256でハッシュ化する)
passwordService = org.apache.shiro.authc.credential.DefaultPasswordService
passwordMatcher = org.apache.shiro.authc.credential.PasswordMatcher
passwordMatcher.passwordService = $passwordService
# JDBCで認証情報取得する設定。SQL文を書いておく。
dbRealm = org.apache.shiro.realm.jdbc.JdbcRealm
dbRealm.dataSource = $ds
dbRealm.authenticationQuery = SELECT passwordHash FROM User WHERE name = ?
dbRealm.userRolesQuery = SELECT role from User WHERE name = ?
dbRealm.credentialsMatcher = $passwordMatcher
# このように2つのレルムを並べて、どちらかで認証することも可能。
securityManager.realms = $dbRealm,$iniRealm
# iniファイルには平文でユーザ名=パスワード,ロールを書ける。
[users]
admin = admin,adminRole
# ロールとバーミッションの設定
[roles]
adminRole = *
[main]セクションで複数のレルムの設定を行う。[users]と[roles]セクションには、ユーザとロール、パーミッションを平文で直接書けるので、DBに接続したくないテスト時などはここに書いておくと良い。(逆に運用時はiniのレルムは書かない方が良いと思う)
3.2 スタンドアローンアプリの場合
import org.apache.shiro.env.BasicIniEnvironment;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.UsernamePasswordToken;
public class App{
public static void main(String... args) throws IOException {
final var env = new BasicIniEnvironment("classpath:shiro.ini");
final var sm = env.getSecurityManager();
SecurityUtils.setSecurityManager(sm);
final var subject = SecurityUtils.getSubject();
final var username = "admin";
final var password = "admin";
try {
subject.login(new UsernamePasswordToken(username,password));
}catch(AuthenticationException e){
System.out.println("ログイン失敗");
}
if (subject.isAuthenticated()) {
System.out.println("認証OK、処理実行");
} else {
System.out.println("未認証");
}
}
}
スタンドアロンアプリ(複数ユーザが同時に利用しないアプリ)の場合は、上記のような形。
Subject
がユーザを表すもので、login
メソッドへUsernamePasswordToken
を渡すことで認証する。失敗するとAuthenticationException
のサブクラス例外をスローする。
ログイン失敗理由をあまり詳しくユーザに説明する必要はないので、細かくキャッチする必要はない。
4.SparkFrameworkに組み入れる
これがやや面倒臭かった。SparkFramworkはREST APIなんかを簡単に作れるフレームワークで、Jettyアプリケーションサーバが内蔵されおり、Servletの上で動作する。
Shiroを入れるためにはイベントリスナーやフィルターを登録する必要があるが、SparkFrameworkがセットアップするJettyサーバに割り込めないので、自分で内蔵Jettyサーバーをセットアップする必要があった。
4.1 Jettyサーバをセットアップする
WEB-INF/web.xml
を用意し、WARとしてJettyコンテナにのせてもいいのだが、せっかくの内蔵サーバーなので、Javaで設定を書くことにする。
import org.eclipse.jetty.server.*;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
import org.eclipse.jetty.webapp.WebAppContext;
import org.eclipse.jetty.servlet.FilterHolder;
import org.eclipse.jetty.servlet.DefaultServlet;
import org.apache.shiro.web.servlet.ShiroFilter;
import org.apache.shiro.web.env.EnvironmentLoaderListner;
import javax.servlet.DispatcherType;
import spark.servlet.SparkFilter;
import java.util.EnumSet;
public final class JettyServer {
public static void start(){
//サーバの設定
final var threadPool = new QueuedThreadPool(8,4,10000);
final var server = new Server(threadPool); //サーバ
final var httpConfig = new HttpConfiguration();
final var httpConnectionFactory = new HttpConnectionFactory(httpConfig);
final var serverConnector = new ServerConnector(server,httpConnectionFactory);
serverConnector.setPort(4567); //ポート
final ServerConnector[] connectors = {serverConnector};
server.setConnectors(connectors);
//WebAppContext作成
final var context = new WebAppContext();
context.setContextPath("/");
//URLリライトセッションの無効化
context.setInitParameter("org.eclipse.jetty.servlet.SessionIdPathParameterName","none");
//Apache Shiroの設定
final var shiroFilterName = ShiroFilter.class.getCanonicalName();
context.addFilter(shiroFilterName, "/*", EnumSet.of(
DispatcherType.REQUEST,
DispatcherType.INCLUDE,
DispatcherType.ERROR,
DispatcherType.FOWRARD,
DispatcherType.ASYNC));
context.addEventListner(new EnvironmentLoaderListner());
//Spark Frameworkの追加
final var sparkFilter = new FilterHolder();
sparkFilter.setName("SparkFilter");
sparkFilter.setClassName = SparkFilter.class.getCanonicalName();
//SparkApplicationクラスを指定する。
sparkFilter.setInitParameter("applicationClass",
MyApplication.class.getCanonicalName());
context.addFilter(sparkFilter,"/*",EnumSet.of(DispatcherType.REQUEST));
//サーバにWebbAppContextをセット
context.setResourceBase("./src/main/resources");
server.setHandler(context);
}
}
長くなったが流れは以下。
- Jettyサーバを作成
- サーバーコネクターを作成し、Jettyサーバにセット
- Webアプリケーションコンテキストを作成
- URLリライトセッションを無効化
- コンテキストのフィルターにApacheShiroフィルターを追加(これで、Shiroの認証認可が走る)
- ApacheShiroが設定ファイルを読むイベントリスナーを登録
- コンテキストのフィルターにSparkFrameworkのフィルターを追加(これでSparkFrameworkが使えるようになる)
- SparkFrameworkアプリケーションのクラス名を指定
- JettyサーバーにWebアプリケーションコンテキストをセット
Sparkframeworkより前にApache Shiroのフィルターを追加する。
4.2 Spark Applicationの作成
内蔵Jettyサーバーを使わない場合は、Spark Applicationクラスを作成する必要がある。先程のSpark Filterの設定時に、applicationClass
として、そのクラスを設定する。
import spark.servlet.SparkApplication;
import static spark.Spark.*;
import spark.TemplateEngine;
import spark.template.thymeleaf.ThymeleafTemplateEngine;
public class MyApplication implements SparkApplication {
@Override
public void init() {
staticFileLocation("/public");
//テンプレートエンジン。本来はDIとかで突っ込みThymeleafには依存しないようにする
final var engine = new ThymeleafTemplateEngine();
//loginはGET/POST共に、ログインページを表示するように
get("/login",(req,res) -> {
final var subject = SecurityUtils.getSubject();
final var isAuth = subject.isAuthenticated();
return new ModelAndView(Map.of("isAuth",isAuth),"login");
},engine);
post("/login",(req,res) -> {
final var subject = SecurityUtils.getSubject();
final var isAuth = subject.isAuthenticated();
return new ModelAndView(Map.of("isAuth",isAuth),"login");
},engine);
get("/", (req,res) -> new ModelAndView(
Map.of("username",req.raw().getRemoteUser()),"index"),engine);
get("/adminonly",(req,res) -> "管理者専用です"); //管理者ロール専用URL
get("/unauthorized"),(req,res) -> "未認可です!"); //未認可の時のURL
}
@Override
public void destory() {
//アプリケーション終了時の処理などを記述する。
}
}
/login
はログイン画面が表示されるようにしておく。認証が必要なURLとして/
(コンテキストルート)を用意。認証認可が必要なURLとして/adminonly
を用意。
4.3 shiro.iniの設定
shiro.ini
に以下の設定を追加する。
[main]
# Realm設定は略
# セッションマネージャ(デフォルトこれなので明示的に書く必要はないが)
sessionManager = org.apache.shiro.web.session.mgt.ServletContainerSessionManager
securityManager.sessionManager = $sessionManager
# ログインするURL
authc.loginUrl = /login
# ログイン成功時の遷移先
authc.successUrl = /
# ログイン時のパラメータ名
authc.usernameParam = username
authc.passwordParam = password
authc.rememberMeParam = rememberMe
# ログアウト時のリダイレクト先
logout.redirectUrl = /login
# 未認可の時の遷移先
roles.unauthorizedUrl = /unauthorized
[urls]
# URLに認証フィルタを設定する
/ = authc
/login = authc
/logout = logout
/adminonly = authc,roles[admin]
[users]
admin = adminpass,admin,user
user1 = user1pass,user
[roles]
admin = *
user = readOnly
主なフィルタ。他にもいくつかShiroが準備してくれている。
フィルタ名 | 機能 |
---|---|
anno | 誰でもアクセスできる |
authc | フォーム認証を要する(RememberMeはダメ) |
user | 認証済み or RememberMeで記憶したユーザ |
logout | ログアウトする |
roles | 認可を要する。ロール別のアクセス制御 |
4.4 ログインページ作る
Thymeleafのテンプレートを作成。
<html lang="ja">
<head>
<meta charset="utf-8">
</head>
<body>
<th:block th:if="${isAuth != true}">
<form action="/login" method="post">
ユーザ名:<input type="text" name="username"><br>
パスワード:<input type="password" name="password"><br>
RmemeberMe:<input type="checkbox" name="rememberMe" value="false"><br>
<input type="submit" value="ログイン">
</form>
</th:block>
<th:block th:if="${isAuth == true}">
既にログイン済みです。ログアウトする場合はこちら<br>
<a href="/logout">ログアウト</a>
</th:block>
</body>
</html>
ユーザ名とパスワード、rememberMeのname
属性は、shiro.ini
で設定した値と合わせる。
4.5 ホームページ作る
<html lang="ja">
<head><meta charset="utf-8"></head>
<body>
ようこそ<span th:text="${username}"></span>さん
</form>
</body>
</html>
認証ページ。
5.実行する
public class App {
public static void main(String... args){
JettyServer.start();
}
}
5.1 認証の確認
- 不正なユーザID・PWでログインする ⇨
/login
にリダイレクトする - 未認証のまま
/
にアクセスする ⇨/login
にリダイレクトする - 正規のユーザID・PWでログインする ⇨
/
にリダイレクトする - ログアウト(
/logout
に遷移)した後、/
にアクセスする ⇨/login
にリダイレクトする
5.2 認可の確認
- user1でログインし、
/adminonly
にアクセスする ⇨/unauthorized
にリダイレクトする - adminでログインし、
/adminonly
にアクセスする ⇨ アクセスできる。
6.セッションについて
6.1 セッションマネージャについて
Apache Shiroでは、Webアプリにおけるセッションマネージャが2つ用意されている
- DefaultWebSessionManager
- Apache Shiroのネイティブのセッション管理機能を用いる。
- セッションの有効期限などは、独自に設定する。
- Servletコンテナ(JettyとかTomcat)に依存しないので、移植性が高い
- (と言っているが、そもそもServletコンテナ以外でApache Shiroを使うのかは疑問。PlayFrameworkとか?うーん・・・)
- ServletContainerSessionManager
- サーブレットコンテナのセッション管理機能を用いる。
- デフォルトはこれで、要はServletでいつも使っているセッション
- セッションの有効期限なども、サーブレットコンテナ側の設定に依る。(Jettyならデフォルト30分)
6.2 URLリライティングの無効化(セッションハイジャック緩和策)
ServletContanerSessionManagerを利用する場合、Jettyのセッションについては、initパラメータで以下のようにURLの書き換えを無効にしなければ、うまく動作しなかった。
これはURLのリライティングを無効化しているのだが、今時Cookie受け入れないブラウザも少ないと思うし、urlにセッションIDが入るのは、セッションハイジャックの危険性も高まるので、原則無効でよいと思う。
context.setInitParameter("org.eclipse.jetty.servlet.SessionIdPathParameterName","none");
6.3 固定セッション攻撃緩和策
残念なことに、Apache Shiroのログイン機能(ログインフィルタ)は、認証前のセッションIDを認証成功後も引き継ぐ。
固定セッション攻撃は、攻撃者がなんらかの方法で他人に固定のセッションIDを使ってログインさせることで、セッションハイジャックを行う攻撃方法。
対策としてはIPAの「安全なウェブサイトの作り方」によると、以下の通りなので、要は「ログインが成功したら、セッションID新しくせよ」ということ。
ログイン成功後に、新しくセッションを開始する。
ウェブアプリケーションによっては、ユーザがログインする前の段階(例えばサイトの閲覧を開始した時点)でセッションIDを発行してセッションを開始し、そのセッションをログイン後も継続して使用する実装のものがあります。しかしながら、この実装はセッションIDの固定化攻撃に対して脆弱な場合があります。このような実装を避け、ログインが成功した時点から新しいセッションを開始する(新しいセッションIDでセッション管理をする)ようにします。また、新しいセッションを開始する際には、既存のセッションIDを無効化します(*3)。こうすることにより、利用者が新しくログインしたセッションに対し、悪意のある人は事前に手に入れたセッションIDではアクセスできなくなります。
対処方法としては、ログイン後にセッションを開始する、あるいはログイン後にセッションIDを変更する。
Google先生に「Apache Shiro Session Fixation Attack」でお尋ねしたところ、5年ほど前にJIRAのイシューに上がっているものの、旧セッション属性をコピーして新セッションを開始する独自フィルタの例が記載されていた。
なんだか、厄介そうだったのだが、Servlet3.1からはセッションIDを変更する機能があるので、それを用いてみることにする。
ということで、既存のFormAuthenticationFilter
を少し変更して、独自の認証フィルタを作る。
といっても、ログイン後にセッションIDを変更するだけ。(changeSessionId()
はServlet3.1以降の機能)
6.3.1 独自のフォーム認証フィルタを作成
package my.filter;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.authc.AuthenticationToken;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
public class MyFormAuthFilter extends FormAuthenticationFilter{
@Override
protected boolean onLoginSuccess(
AuthenticationToken token,
Subject subject,
ServletRequest request,
ServletResponse response) throws Exception{
final var httpRequest = (HttpRequest) request;
httpRequest.changeSessionId(); //セッションID変更する
return super.onLoginSuccess(token,subject,request,response);
}
}
ShiroのForm認証フィルタを継承しonLoginSuccess
メソッドをオーバライドして、changeSessionId()
を差し込む。
6.3.2 shiro.iniの設定
[main]
myauthc = my.filter.MyFormAuthFilter
myauthc.loginUrl = /login
myauthc.successUrl = /
myauthc.usernameParam = username
myauthc.passwordParam = password
myauthc.rememberMeParam = rememberMe
# (中略)
[urls]
/ = myauthc
/login = myauthc
/success = myauthc
/adminonly = myauthc,roles[admin]
/logout = logout
独自フィルタをmyauthc
として定義し、Shiroが用意しているauthc
の代わりに使うだけ。
7.終わりに
-
Spring Security
と比較しようかなと思ったが力尽きた。Springを使うなら、Spring Security
使えばいい。 -
Apache Shiro
は比較的使いやすいかと思うが、情報量は少ない印象。 -
shiro.ini
に設定が集約されているので、見通しは良いし、テスト時は平文で認証できるのは楽。