Java EE アドベントカレンダー3日目です。昨日はHASUNUMA Kenjiさんの[Presentation] Introduction to JCA and MDB (ja)でした。
はじめに
さて今年はついにJavaEE 8がリリースされましたね! CDI 2.0やServlet4あたりが目玉でしょうか? この辺はきっと誰かが解説してくれると思うので、私は個人的に気になってるJava EE Security API(JSR 375) に関して話したいと思います。
今回使ったサンプルは下記より入手できます。
https://github.com/koduki/example-javaee8-security_basic
そもそも何?
Java EE Security API(JSR 375)はその名と通りセキュリティ、特に認証と認可をよりシンプルによりポータブルにするための仕様です。
ほとんどのWebアプリケーションではログイン機能やアカウントによるアクセス制御があります。この辺の機能は誰が作っても大差ないのでFWにやって欲しいところですよね?
RailsであればDeviseとかがそういった機能を提供しますし、PHPのSymfonyあたりはFWの標準機能として取り込まれていました。
しかし、JavaEEにはJACCとかJASPICとかベンダー独自実装とかは以前からあったものの複雑だったり独自仕様でドキュメント少なかったりしたので、ほとんどの人が認証/認可周りは独自で実装していたのが実情じゃないかと思われます。
またサーブレットやJSFやCDIやEJB...といった様々なコンポーネントで独自にセキュリティ周りが実装されてたため、それを統合管理する仕様がないという状態でもありました。
こういった事情を加味してJSR375は「統一管理」「シンプル」「ポータブル」をキーワードにApache ShiroなどのOSSライブラリも参考にしつつ作られた仕様です。
何が出来るの?
主なポイントとしては以下の3点です。
- 各コンポーネントへの統一したアクセス
- アノテーションによるアクセス制御
- 認証機能に対する統一アクセス
各コンポーネントへの統一したアクセス
まず、今までそれぞれごとに独立していた認証機能が統一するためのSecurityContextが導入されました。
たとえば、今まではアカウントの取得でもコンポーネント毎に以下のような方法がありました。
- Servlet - HttpServletRequest#getUserPrincipal, HttpServletRequest#isUserInRole
- EJB - EJBContext#getCallerPrincipal, EJBContext#isCallerInRole
- JAX-WS - WebServiceContext#getUserPrincipal, WebServiceContext#isUserInRole
- JAX-RS - SecurityContext#getUserPrincipal, SecurityContext#isUserInRole
- JSF - ExternalContext#getUserPrincipal, ExternalContext#isUserInRole
- CDI - @Inject Principal
- WebSockets - Session#getUserPrincipal
これらへのアクセスがSecurityContextでまとめられています。既存の各処理のメソッドI/Fはそのままですがバックエンド等が変わってるようです。
またJavaEE8は全体的にCDIに統合されているので、例えばサーブレッドであっても
public class MyServlet extends HttpServlet {
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String webName = request.getUserPrincipal().getName();
としてログインユーザを確認していた部分を
public class MyServlet extends HttpServlet {
@Inject
private SecurityContext securityContext;
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String webName = securityContext.getCallerPrincipal().getName();
とSecurityContext経由で取ることもできます。
アノテーションによるアクセス制御
アノテーションによってアクセス制御を簡単に行う事が出来ます。
たとえば、fooロールを持ったユーザだけアクセスできるサーブレットは下記のように作れます。
@ServletSecurity(@HttpConstraint(rolesAllowed = "foo"))
public class Servlet extends HttpServlet {
後述するAuthenticationMechanismを適切に設定していれば上記の設定をしていくだけでログインページに飛ばしたり、認証後やエラー時のフローを簡単に定義することが出来ます。
認証機能に対する統一アクセス
3点目は認証機能に対する統一対する統一したアクセスです。Identity Storeという形でこれを提供します。
DB, LDAP, またはカスタム認証機能を利用することが出来ます。
新規のWebアプリケーションならRDBをバックエンドに持つことが多いでしょうし、エンタープライズ系システムならActiveDirectoryなどのLDAPを使うことも多いでしょう。
これらの2つに関してはアノテーションで設定を書くだけで利用できます。アノテーションに対してEL式も使えるのでハードコーディングだけではなく設定ファイルや環境変数から取得など今風の利用も問題なくできます。
また、社内独自の認証APIやOAuthやSAMLなどの標準的な認証APIを使いたいことも多いと思います。その場合もカスタムのIdentity StoreやAuthenticationMechanismを作る事で対応できます。
実際、「Java EE 8 の Security API で OpenID Connect する」という記事で比較的複雑な遷移を伴って認証がされるOpenID Connectに対応された事例も書かれています。
社内APIもこれを参考にすれば大抵は対応できそうです。記事でも言及されてる通り、OAuthtとか一般的な認証機能は標準かそれに近い形でサポートが欲しくはありますけど。
構成要素
少し説明が重複してしまいますが、主に直接利用するコンポーネントは以下の3つです。
Authentication Mechanism
認証およびその後の制御フローを司るのがAuthentication Mechanismです。
主に提供されている@BasicAuthenticationMechanismDefinitionでBASIC認証をかけるか、HttpAuthenticationMechanismを継承しカスタムフォーム等を利用する形になりそうです。
HttpAuthenticationMechanismと@LoginToContinue等を組合わせる事で一般的なログイン制御をさくりと作れそうな感じがします。
使いわけとしては簡易な社内システムやAPIはBASIC認証で、ログイン画面が必要な場合はカスタムフォームとかですね。
Identity Store
Identity Storeではどのような方法で認証するかという機能を提供します。
たとえば、バックエンドをRDBにする場合は@DatabaseIdentityStoreDefinitionを使って以下のように定義できます。
@DatabaseIdentityStoreDefinition(
dataSourceLookup = "${Config.getDataSourceName()}", // "java:global/MyAppDataSource"
callerQuery = "select password from caller where name = ?",
groupsQuery = "select group_name from caller_groups where caller_name = ?",
hashAlgorithm = Pbkdf2PasswordHash.class,
priorityExpression = "#{100}",
hashAlgorithmParameters = {
"Pbkdf2PasswordHash.Iterations=3072",
"${applicationConfig.dyna}"
} // just for test / example
)
アノテーションの中にはEL式も書けるので、この例のように設定ファイルや外部から読み込むクラスを作ってそこから設定値を取得することもできます。
SecurityContext
SecurityContextは上記の2つの機能に統一してアクセスするための仕組みです。前述しているように認証機構へのアクセス方法は多岐にわたり複雑化しています。
SecurityContextにより以下のことが容易にできるようになります。
- getCallerPrincipal : Caller(ユーザ)を取得します。getNameでさらにStringとして取得
- isCallerInRole : 引数に渡したロールがログイン済みのもの持っているかを確認
実装例
では、超簡単な実装例を示します。今回はBASIC認証で。
まずは、GlassFish5をインストールしてNetBeansと連携させます。NetBeans8.2でMavenプロジェクトを作るとEE7になっているのでEE8に修正しましょう。ついでにJavaも1.8で指定しておきます。
@@ -18,7 +18,7 @@
<dependency>
<groupId>javax</groupId>
<artifactId>javaee-web-api</artifactId>
- <version>7.0</version>
+ <version>8.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
@@ -30,8 +30,8 @@
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<configuration>
- <source>1.7</source>
- <target>1.7</target>
+ <source>1.8</source>
+ <target>1.8</target>
<compilerArguments>
<endorseddirs>${endorsed.dir}</endorseddirs>
</compilerArguments>
まずはターゲットとなるサーブレッドを作ります。記載は省略しますが合わせてbeans.xmlとApplicationConfig.javaも空で作成
@WebServlet("/myservlet")
public class MyServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
@Inject
private SecurityContext securityContext;
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
response.getWriter().write("This is a servlet \n");
String webName = null;
if (securityContext.getCallerPrincipal() != null) {
webName = securityContext.getCallerPrincipal().getName();
}
response.getWriter().write("web username: " + webName + "\n");
response.getWriter().write("web user has role \"admin\": " + securityContext.isCallerInRole("admin") + "\n");
response.getWriter().write("web user has role \"users\": " + securityContext.isCallerInRole("users") + "\n");
response.getWriter().write("web user has role \"guest\": " + securityContext.isCallerInRole("guest") + "\n");
}
}
アクセスすると以下のような結果になります。
$ curl -L http://localhost:8080/example-javaee8-security_basic/myservlet
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 137 100 137 0 0 9133 0 --:--:-- --:--:-- --:--:-- 9133
This is a servlet
web username: null
web user has role "admin": false
web user has role "users": false
web user has role "guest": false
BASIC認証
まず、このページをAdminだけしかアクセスできないようにします。
サーブレッドに@ServletSecurityを指定してロールを限定します。
@WebServlet("/myservlet")
@ServletSecurity(@HttpConstraint(rolesAllowed = "admin"))
public class MyServlet extends HttpServlet {
.
.
.
再度アクセスしてみると、401エラーになったことが確認できます。
$ curl -IL http://localhost:8080/example-javaee8-security_basic/myservlet
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 1090 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
HTTP/1.1 401 Unauthorized
Server: GlassFish Server Open Source Edition 5.0
このままだとアクセスできないのでBASIC認証を追加します。ApplicationConfig.javaに@BasicAuthenticationMechanismDefinitionを追加します。
@BasicAuthenticationMechanismDefinition(
realmName = "test realm"
)
@ApplicationScoped
@Named
public class ApplicationConfig {
}
またBasicAuthenticationMechanismDefinitionのバックエンドとなるIdentityStoreを作ります。
@ApplicationScoped
public class TestIdentityStore implements IdentityStore {
public CredentialValidationResult validate(UsernamePasswordCredential usernamePasswordCredential) {
if (usernamePasswordCredential.compareTo("admin", "password")) {
return new CredentialValidationResult("admin", new HashSet<>(asList("admin", "users")));
}
return INVALID_RESULT;
}
}
パスワードもハードコーディングの超シンプル仕様です。良い子は本番で真似しちゃダメですよ?
こちらをデプロイして実行してみます。
$ curl -LI -u admin:password http://localhost:8080/example-javaee8-security_basic/myservlet
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 137 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
HTTP/1.1 200 OK
Server: GlassFish Server Open Source Edition 5.0
$ curl -u admin:password http://localhost:8080/example-javaee8-security_basic/myservlet
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 137 100 137 0 0 4419 0 --:--:-- --:--:-- --:--:-- 8562
This is a servlet
web username: admin
web user has role "admin": true
web user has role "users": false
web user has role "guest": false
401にならずに適切に結果が返ってきてる事が分かります。securityContextの結果もちゃんと拾えていますね。
データベースの利用
ハードコーディングではあんまりなのでバックエンドをRDBに変えてみます。
まずは利用するデータベースが必要です。GlassFish5を利用していればDerbyが付いてくるのでそちらを利用します。
以下のようなコードでDBを初期化してデータソースを作成します。
本来は管理ツールとか作るべきでしょうが、今回はパスワードもハッシュ化して格納します。検証しやすいように先ほどとはパスワードも変えておきました。
@DataSourceDefinition(
name = "java:global/MyAppDataSource",
minPoolSize = 0,
initialPoolSize = 0,
className = "org.apache.derby.jdbc.ClientDataSource",
user = "APP",
password = "APP",
databaseName = "myapp",
properties = {"connectionAttributes=;create=true"}
)
@Singleton
@Startup
public class DatabaseSetup {
@Resource(lookup = "java:global/MyAppDataSource")
private DataSource dataSource;
@Inject
private Pbkdf2PasswordHash passwordHash;
@PostConstruct
public void init() {
Map<String, String> parameters = new HashMap<>();
parameters.put("Pbkdf2PasswordHash.Iterations", "3072");
parameters.put("Pbkdf2PasswordHash.Algorithm", "PBKDF2WithHmacSHA512");
parameters.put("Pbkdf2PasswordHash.SaltSizeBytes", "64");
passwordHash.initialize(parameters);
// executeUpdate(dataSource, "DROP TABLE caller");
// executeUpdate(dataSource, "DROP TABLE caller_groups");
executeUpdate(dataSource, "CREATE TABLE caller(name VARCHAR(64) PRIMARY KEY, password VARCHAR(255))");
executeUpdate(dataSource, "CREATE TABLE caller_groups(caller_name VARCHAR(64), group_name VARCHAR(64))");
executeUpdate(dataSource, "INSERT INTO caller VALUES('admin', '" + passwordHash.generate("secret1".toCharArray()) + "')");
executeUpdate(dataSource, "INSERT INTO caller_groups VALUES('admin', 'admin')");
}
@PreDestroy
public void destroy() {
try {
executeUpdate(dataSource, "DROP TABLE caller");
executeUpdate(dataSource, "DROP TABLE caller_groups");
} catch (Exception ex) {
ex.printStackTrace();
// silently ignore, concerns in-memory database
}
}
private void executeUpdate(DataSource dataSource, String query) {
try (Connection connection = dataSource.getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(query)) {
statement.executeUpdate();
}
} catch (SQLException ex) {
throw new IllegalStateException(ex);
}
}
}
つづいて、先ほど作ったTestIdentityStoreを削除して、代わりにApplicationConfig.javaに@DatabaseIdentityStoreDefinitionを追加します。
@BasicAuthenticationMechanismDefinition(
realmName = "test realm"
)
@DatabaseIdentityStoreDefinition(
dataSourceLookup = "java:global/MyAppDataSource",
callerQuery = "select password from caller where name = ?",
groupsQuery = "select group_name from caller_groups where caller_name = ?",
hashAlgorithm = Pbkdf2PasswordHash.class,
priorityExpression = "#{100}",
hashAlgorithmParameters = {
"Pbkdf2PasswordHash.Iterations=3072",
"${applicationConfig.dyna}"
} // just for test / example
)
@ApplicationScoped
@Named
public class ApplicationConfig {
public String[] getDyna() {
return new String[]{"Pbkdf2PasswordHash.Algorithm=PBKDF2WithHmacSHA512", "Pbkdf2PasswordHash.SaltSizeBytes=64"};
}
}
データソースは先ほどDatabaseSetup.javaで作成したものを利用します。
パスワードのハッシュアルゴリズムも同じくPBKDF2を利用して、受け取ったパスワードをDBに登録されてるのと同じハッシュ値になるようにしてあります。
こちらを実行してみます。
$ curl -u admin:secret1 http://localhost:8080/example-javaee8-security_basic/myservlet
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 137 100 137 0 0 4419 0 --:--:-- --:--:-- --:--:-- 4419
This is a servlet
web username: admin
web user has role "admin": true
web user has role "users": false
web user has role "guest": false
DBに登録した新パスワードで認証ができましたね?
このように認証機構もバックエンドが切り離されているので簡単に実装を変更することが出来ます。
まとめ
まだ、調べきれてないところも多いですが、Java EE Security API(JSR 375) を触ってみました。
簡単なAPI向けの認証機能なら今日の内容でもサクッと作れそうで便利です。
今後、LDAP連携やカスタムフォーム連携とかより実用的な部分も試して記事にしてみたいですね。
それではHappyHacking!