Jersey MVCでレルム認証する(Jerseyも基本的には方法は同じ!)
Jersey MVCでレルム認証する(Jerseyも基本的には方法は同じ!)
Java EEの認証・認可機能といえばレルムですね。
今回は、Jersey MVCでレルムを使ってみました。MVCじゃないJerseyでも方法は基本的に同じです。
https://github.com/MasatoshiTada/JerseyMVC_JDBCRealm
やりたいこと
- ログイン機能をレルムで作る。
- 未ログイン時に、ログイン時でないと見れない画面のURLを直接指定した場合、401エラー画面に強制遷移する。
- 複数のロールを用意して、権限のないユーザーが管理者ロール専用の画面に遷移しようとしたら、403エラー画面に強制遷移する。
GlassFishのレルム設定
Java EEでレルムを利用する際は、アプリケーションサーバー側に設定が必要です。
今回、僕はPayara Web ML 4.1.1.154を使っています。
Payaraの設定方法はGlassFishと全く一緒です。寺田さんの下記のブログに手順が詳しく書いてあります。
http://yoshio3.com/2013/12/10/glassfish-jdbc-realm-detail/
依存ライブラリの追加
pom.xmlに依存性を追加します。最低限、javaee-web-apiとjersey-mvc-jsp(MVCでないならばjersey-server)があればOK。
それ以外に、特別な依存性は必要ありません。
<dependencies> <dependency> <groupId>javax</groupId> <artifactId>javaee-web-api</artifactId> <version>7.0</version> <scope>provided</scope> </dependency> <!-- Jersey MVC + Jersey MVC JSP --> <dependency> <groupId>org.glassfish.jersey.ext</groupId> <artifactId>jersey-mvc-jsp</artifactId> <scope>provided</scope> <!-- 不要な依存性を除外 --> <exclusions> <exclusion> <groupId>javax.servlet</groupId> <artifactId>servlet-api</artifactId> </exclusion> </exclusions> </dependency> </dependencies>
ResourceConfigサブクラスの作成
ここが1つ目のポイントです。
RolesAllowedDynamicFeature
クラスの登録が必要になります。
これが無いと、リソースメソッドに@RolesAllowed
付けても無視されます。
これは、JerseyでもJersey MVCでも同じです。
package com.example.rest; import javax.ws.rs.ApplicationPath; import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature; import org.glassfish.jersey.server.mvc.jsp.JspMvcFeature; @ApplicationPath("api") public class MyResourceConfig extends ResourceConfig { public MyResourceConfig() { register(JspMvcFeature.class); property(JspMvcFeature.TEMPLATE_BASE_PATH, "/WEB-INF/views/"); packages(true, this.getClass().getPackage().getName()); register(RolesAllowedDynamicFeature.class); // コレが必要! } }
web.xmlの作成
レルムの利用に必要な設定を記述します。
レルムでの認証方法は、BASIC認証・FORM認証・DIGEST認証・SSL相互認証の4種類があります。
最初は、Java EE標準のFORM認証を使っていたのですが、FORM認証は基本的にはサーブレット・JSPのことしか考慮されていないものなので、Jersey MVCだと使いづらかったです(^^;
なので方針を変えて、設定は「BASIC」にしておいて、ログイン処理自体は自前で実装(後述)することにしました。
<?xml version="1.0" encoding="UTF-8"?> <web-app version="3.1" xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"> <welcome-file-list> <welcome-file>index</welcome-file> </welcome-file-list> <security-constraint> <!-- 保護対象のURL --> <web-resource-collection> <web-resource-name>Sample System</web-resource-name> <url-pattern>/*</url-pattern> </web-resource-collection> <!-- HTTPS化 --> <user-data-constraint> <transport-guarantee>CONFIDENTIAL</transport-guarantee> </user-data-constraint> </security-constraint> <login-config> <auth-method>BASIC</auth-method> <!-- Payaraに設定したレルム名 --> <realm-name>jerseyMvc</realm-name> </login-config> <!-- ロールの一覧 --> <security-role> <description/> <role-name>ADMINISTRATOR</role-name> </security-role> <security-role> <description/> <role-name>MANAGER</role-name> </security-role> <security-role> <description/> <role-name>OPERATOR</role-name> </security-role> </web-app>
ウェルカムファイルからログイン画面にリダイレクトするサーブレットの作成
web.xmlにウェルカムファイルを指定しましたが、Jersey MVCコントローラー経由のパスは指定できません。
なので、一旦サーブレットでリクエストを受け取って、ログイン画面にリダイレクトします。
package com.example.servlet; import java.io.IOException; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @WebServlet("/index") public class RedirectServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.sendRedirect("./api/login"); } }
ログイン画面の作成
FORM認証ではないので、j_security_check
やj_username
やj_password
は必要ありません。いたって普通のJSPです。
今回は、Jersey MVCの相対パスを使うので、このJSPはsrc/main/webapp/WEB-INF/views/com/example/rest/resource/LoginResourceフォルダに作成します。
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@page contentType="text/html" pageEncoding="UTF-8"%> <!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>ログイン画面</title> </head> <body> <c:if test="${not empty param['retry']}"> <p>ログインIDまたはパスワードが違います。</p> </c:if> <form action="./login" method="post"> ログインID:<input type="text" name="loginId"><br> パスワード:<input type="password" name="password"><br> <input type="submit" value="ログイン"> </form> </body> </html>
ログイン用コントローラーの作成
先ほどのJSPからのリクエストパラメータで、ログインIDとパスワードを受け取り、ログイン処理を行います。
ログイン処理は、HttpServletRequest#login()メソッドを使います。
あんまりサーブレットAPIに依存させたくないんですが、レルムでログインするには仕方がないですね・・・。
ログインに成功したらインデックス画面にリダイレクトし、失敗したらログイン画面に戻ります。
package com.example.rest.resource; import java.net.URI; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.FormParam; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.core.Context; import javax.ws.rs.core.Response; import javax.ws.rs.core.SecurityContext; import javax.ws.rs.core.UriInfo; import org.glassfish.jersey.server.mvc.Viewable; @Path("login") public class LoginResource { @Context private SecurityContext securityContext; @Context private UriInfo uriInfo; @Context private HttpServletRequest httpServletRequest; @GET public Viewable index() { return new Viewable("login"); } @POST public Response login(@FormParam("loginId") String loginId, @FormParam("password") String password) { // 未ログインならばログイン処理を行う if (securityContext.getUserPrincipal() == null) { System.out.println("ログイン処理開始..."); try { // コンテナにログイン httpServletRequest.login(loginId, password); System.out.println("ログイン処理成功!!"); } catch (ServletException ex) { System.out.println("ログイン失敗"); // ログイン失敗時はログイン画面に戻る URI loginPage = uriInfo.getBaseUriBuilder() .path(this.getClass()) .queryParam("retry", Boolean.TRUE) .build(); return Response.status(Response.Status.SEE_OTHER) .location(loginPage) .build(); } } // ログイン成功時はインデックス画面に遷移 URI indexPage = uriInfo.getBaseUriBuilder() .path(SecretResource.class) .path("index") .build(); return Response.status(Response.Status.FOUND) .location(indexPage) .build(); } }
ログイン後のインデックス画面
<%@page contentType="text/html" pageEncoding="UTF-8"%> <!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>インデックス画面</title> </head> <body> <h1>ようこそ、${pageContext.request.userPrincipal.name}さん!</h1> <a href="./admin">管理者専用画面へ</a> <hr> <form action="${pageContext.request.contextPath}/api/logout" method="post"> <input type="submit" value="ログアウト"> </form> </body> </html>
管理者専用画面
<%@page contentType="text/html" pageEncoding="UTF-8"%> <!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>管理者専用画面</title> </head> <body> <h1>管理者専用画面です。</h1> <a href="./index">インデックス画面へ</a> </body> </html>
ログイン後の画面用コントローラーの作成
ここで、セキュリティアノテーションを使います。
インデックス画面は全ロールに許可するために@PermitAll
、管理者専用画面は管理者のみ許可するために@RolesAllowed
を付加します。
@Authenticated
は自作アノテーションなのですが、後ほど説明します。
package com.example.rest.resource; import com.example.rest.filter.binding.Authenticated; import javax.annotation.security.PermitAll; import javax.annotation.security.RolesAllowed; import javax.ws.rs.GET; import javax.ws.rs.Path; import org.glassfish.jersey.server.mvc.Viewable; @Path("secret") @Authenticated // ログインチェック用のフィルターが適用される。メソッドに個別につけてもOK public class SecretResource { @GET @Path("index") @PermitAll // 全ロールに許可 public Viewable index() { return new Viewable("index"); } @GET @Path("admin") @RolesAllowed("ADMINISTRATOR") // 管理者のみ許可 public Viewable admin() { return new Viewable("admin"); } }
ログインチェック用のフィルター作成
サーブレットフィルターではなく、JAX-RSフィルターです。
まず、このフィルターを適用する部分を決めるためのアノテーションを作成します。
package com.example.rest.filter.binding; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import javax.ws.rs.NameBinding; @NameBinding // これがポイント! @Documented @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface Authenticated {}
@NameBinding
を付加しているのがポイントです。
そして、フィルターにはこのアノテーションを付加します。
package com.example.rest.filter; import com.example.rest.filter.binding.Authenticated; import java.io.IOException; import javax.annotation.Priority; import javax.ws.rs.NotAuthorizedException; import javax.ws.rs.Priorities; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.container.ContainerRequestFilter; import javax.ws.rs.core.SecurityContext; import javax.ws.rs.ext.Provider; @Provider @Authenticated // このアノテーションが付いているメソッドにのみ、このフィルターが適用される @Priority(Priorities.AUTHENTICATION) // 優先度をAUTHENTICATION(=1000)にする public class AuthenticationFilter implements ContainerRequestFilter { @Override public void filter(ContainerRequestContext requestContext) throws IOException { SecurityContext securityContext = requestContext.getSecurityContext(); // ログインしてなかったら401例外をスロー if (securityContext.getUserPrincipal() == null) { throw new NotAuthorizedException("not login"); } } }
もう1つのポイントは、@Priority(Priorities.AUTHENTICATION)
です。
JAX-RSフィルターは、複数のフィルターが定義されていた場合の実行順序を指定できます。
Priorities.AUTHENTICATION
は「1000」という整数値です。
@Priority
に指定した整数値が小さいものから先に、フィルターが実行されます。
最初に設定クラスを作成した時、RolesAllowedDynamicFeature
クラスを登録しました。
このクラスを登録すると、@RolesAllowed
による権限チェックを行うJAX-RSフィルターも登録されます。
(この権限チェックフィルターは、権限なしの場合は403例外(ForbiddenException)をスローします。)
で、この権限チェックフィルターのPriorityは、Priorities.AUTHORIZATION
(=2000)が指定されているんですね。
ログインチェック(=認証)は、権限チェック(=認可)よりも先に行うべきです。
なので、ログインチェックフィルターの優先度はPriorities.AUTHENTICATION
(=1000)にしています。
(ちなみに、@Priotity
アノテーションを付加しない場合、デフォルトではPriorities.USER
(=5000)となります)
ExceptionMapperの作成
ログインチェックフィルターと権限チェックフィルターで発生させる、NotAuthorizedExceptionとForbiddenExceptionをキャッチするExceptionMapperを作ります。
それぞれ、401.jspと403.jspにフォワードします。
package com.example.rest.exception.mapper; import javax.ws.rs.NotAuthorizedException; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; import org.glassfish.jersey.server.mvc.Viewable; @Provider public class NotAuthorizedExceptionMapper implements ExceptionMapper<NotAuthorizedException> { @Override public Response toResponse(NotAuthorizedException exception) { return Response.status(exception.getResponse().getStatusInfo()) .entity(new Viewable("/error/401")).build(); } }
<%@page contentType="text/html" pageEncoding="UTF-8"%> <!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>401エラー</title> </head> <body> <p>ログインしていません。</p> <a href="${pageContext.request.contextPath}/api/login">ログイン画面へ</a> </body> </html>
package com.example.rest.exception.mapper; import javax.ws.rs.ForbiddenException; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; import org.glassfish.jersey.server.mvc.Viewable; @Provider public class ForbiddenExceptionMapper implements ExceptionMapper<ForbiddenException> { @Override public Response toResponse(ForbiddenException exception) { return Response.status(exception.getResponse().getStatusInfo()) .entity(new Viewable("/error/403")).build(); } }
<%@page contentType="text/html" pageEncoding="UTF-8"%> <!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>403エラー</title> </head> <body> <p>アクセス権がありません。</p> <a href="${pageContext.request.contextPath}/api/secret/index">インデックス画面へ</a> </body> </html>
ログアウト用コントローラーの作成
package com.example.rest.resource; import java.net.URI; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.core.Context; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; @Path("logout") public class LogoutResource { @Context private HttpServletRequest httpServletRequest; @Context private UriInfo uriInfo; @POST public Response logout() throws ServletException { // ログアウト httpServletRequest.logout(); // セッション破棄 HttpSession session = httpServletRequest.getSession(); if (session != null) { session.invalidate(); } // ログイン画面にリダイレクト URI loginPage = uriInfo.getBaseUriBuilder() .path(LoginResource.class) .build(); return Response.status(Response.Status.SEE_OTHER) .location(loginPage) .build(); } }
実行
管理者権限でログインした場合
普通にアクセス可能です。
管理者以外でログインした場合
管理者専用画面にアクセスすると、「アクセス権がありません。」というエラー画面に遷移します。
未ログイン状態でURLを直接していいた場合
インデックス画面・管理者専用画面ともに、「ログインしていません。」というエラー画面に遷移します。
まとめ
- ResourceConfigサブクラスに
RolesAllowedDynamicFeature
の登録が必要 - コントローラーメソッドには
@RolesAllowed
などで権限を指定する - ログインチェックフィルターには
@Priority(Priorities.AUTHENTICATION)
を指定する