Jersey (JAX-RS) のアクセス制御

今回は Jersey (JAX-RS) で作った WebAPI でアクセス制御を行う方法について書きます。

JAX-RSJ2EE の仕様である以上、アクセス制御をロール単位で行う点に変わりはありません。とはいえ、アプローチの方法は複数あります。主に 3 つです。

1. web.xml (など) で設定する
2. アクセス制御用のフィルタを使う
3. SecurityContext を元に自分でロジックを書く

上記 3 つやり方についてサンプルを元に解説します。

サンプル

まずは web.xml です。

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" 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-app_2_5.xsd">
	<display-name>study-jersey-auth</display-name>
	<session-config>
		<session-timeout>30</session-timeout>
	</session-config>
	
	<servlet>
		<servlet-name>Jersey Auth Test Servlet</servlet-name>
		<servlet-class>com.sun.jersey.spi.container.servlet.ServletContainer</servlet-class>
		<init-param>
			<param-name>com.sun.jersey.config.property.packages</param-name>
			<param-value>study.jersey.auth.resource</param-value>
		</init-param>
		<init-param>
			<param-name>com.sun.jersey.spi.container.ResourceFilters</param-name>
			<param-value>com.sun.jersey.api.container.filter.RolesAllowedResourceFilterFactory</param-value>
		</init-param>
	</servlet>
	<servlet-mapping>
		<servlet-name>Jersey Auth Test Servlet</servlet-name>
		<url-pattern>/*</url-pattern>
	</servlet-mapping>
	
	<security-constraint>
		<web-resource-collection>
			<web-resource-name>Sample WebAPI</web-resource-name>
			<url-pattern>/*</url-pattern>
		</web-resource-collection>
		<auth-constraint>
			<role-name>AUTH_USER</role-name>
		</auth-constraint>
	</security-constraint>

	<login-config>
		<auth-method>BASIC</auth-method>
		<realm-name>BASIC Auth Realm</realm-name>
	</login-config>

	<security-role>
		<role-name>AUTH_USER</role-name>
	</security-role>
</web-app>

後ろの方の タグ以下が (1) のアクセス制御の設定です。これはサーブレットの開発で一般的なアクセス制御のやり方なので、ここで詳しく説明する必要もないと思います。コンテキストパス以下全てで、アクセスするのに AUTH_USER ロールが必要な設定にしています。

サーブレット "com.sun.jersey.spi.container.ResourceFilters" で指定されている "com.sun.jersey.api.container.filter.RolesAllowedResourceFilterFactory" が (2) のやり方を実現するためのフィルタです。

サンプルのリソースです。

package study.jersey.auth.resource;

import javax.annotation.security.PermitAll;
import javax.annotation.security.RolesAllowed;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.SecurityContext;

@Path("/test")
public class AuthTestResource {
	
	@Context
	private SecurityContext securityContext;

	// 実行に "TEST_ROLE" ロールが必要
	@RolesAllowed({"TEST_ROLE"})
	@Path("/rolesallowed")
	@GET
	public Response needAuth() {
		return Response.status(200).entity("rolesallowed").build();
	}

	// 実行に別途特別なロールは不要
	@PermitAll
	@Path("/permitall")
	@GET
	public Response noGuard() {
		return Response.status(200).entity("permitall").build();
	}
	
	@Path("/manual")
	@GET
	public Response manual() {
		// Inject された SecurityContext で判断する
		if (!this.securityContext.isUserInRole("TEST_ROLE")) {
			return Response.status(403).build();
		}
		return Response.status(200).entity("manual").build();
	}
}

(2) のやり方では、リソースやメソッドをアノテーションで修飾することで、アクセス制御を行います。needAuth() メソッドを修飾している @RolesAllowed アノテーションには、この API の実行に必要なロールを列挙します。noGuard() メソッドを修飾している @PermitAll アノテーションはロールが不要なことを示しますが、(1) のやり方で設定したロールは必要とされる点に注意してください。アノテーションは前述したフィルタが読み取って処理します。

manual() メソッドでは (3) のやり方をとっています。JAX-RS の仕様ではアクセス制御に関わるデータを保持するインスタンスとして SecurityContext が定義されています。(3) のやり方では、それを元に自分で制御するロジックを書きます。

今回、プロジェクトは Maven で作って、サーブレットコンテナは Jetty を使いました。Jetty には Maven 用のプラグインがあるので、インストールなどの手間がいらなくて便利です。

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>

	<groupId>study.jersey.auth</groupId>
	<artifactId>study-jersey-auth</artifactId>
	<packaging>war</packaging>
	<version>1.0-SNAPSHOT</version>

	<name>study-jersey-auth</name>
	<url>http://maven.apache.org</url>

	<dependencies>
		<dependency>
			<groupId>javax.annotation</groupId>
			<artifactId>jsr250-api</artifactId>
			<version>1.0</version>
			<scope>provided</scope>
		</dependency>
		<dependency>
			<groupId>javax.servlet</groupId>
			<artifactId>servlet-api</artifactId>
			<version>2.5</version>
			<scope>provided</scope>
		</dependency>
		<dependency>
			<groupId>javax.servlet.jsp</groupId>
			<artifactId>jsp-api</artifactId>
			<version>2.1</version>
			<scope>provided</scope>
		</dependency>
		<dependency>
			<groupId>com.sun.jersey</groupId>
			<artifactId>jersey-server</artifactId>
			<version>1.11</version>
		</dependency>
		<dependency>
			<groupId>com.sun.jersey</groupId>
			<artifactId>jersey-servlet</artifactId>
			<version>1.11</version>
		</dependency>
		<dependency>
			<groupId>com.sun.jersey</groupId>
			<artifactId>jersey-json</artifactId>
			<version>1.11</version>
		</dependency>
		<dependency>
			<groupId>junit</groupId>
			<artifactId>junit</artifactId>
			<version>4.10</version>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>com.sun.jersey</groupId>
			<artifactId>jersey-client</artifactId>
			<version>1.11</version>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-compiler-plugin</artifactId>
				<version>2.3.2</version>
				<configuration>
					<source>1.6</source>
					<target>1.6</target>
				</configuration>
			</plugin>
			<plugin>
				<groupId>org.mortbay.jetty</groupId>
				<artifactId>jetty-maven-plugin</artifactId>
				<version>8.1.0.v20120127</version>
				<configuration>
					<jettyXml>src/test/resources/jetty.xml</jettyXml>
				</configuration>
			</plugin>
		</plugins>
	</build>
</project>

Jetty は mvn jetty:run で起動できます。

今回、認証を使うので別途 Jetty の設定が必要でした。まずは jetty.xml です。

<?xml version="1.0"?>
<!DOCTYPE Configure PUBLIC "-//Jetty//Configure//EN" "http://www.eclipse.org/jetty/configure.dtd">

<Configure id="Server" class="org.eclipse.jetty.server.Server">

    <Call name="addBean">
      <Arg>
        <New class="org.eclipse.jetty.security.HashLoginService">
          <Set name="name">BASIC Auth Realm</Set>
          <Set name="config">src/test/resources/realm.properties</Set>
          <Set name="refreshInterval">0</Set>
        </New>
      </Arg>
    </Call>

</Configure>

レルム (ユーザ名、パスワード、ロール) を記述している realm.properties です。

bothowner:testpassword,AUTH_USER,TEST_ROLE
authonly:testpassword,AUTH_USER
testonly:testpassword,TEST_ROLE

設定ファイルを設置したら、サーブレットが正しく起動できることを確認した上で、ブラウザなどから各レルムを入力した際にそれぞれの API がどう実行されるかを確認します。手動でやるのも良いですが、説明が面倒ですし、ここは Jersey Client を使って単体テストを書いて確認してしまいましょう。

package study.jersey.auth.resource;

import org.junit.Test;

import com.sun.jersey.api.client.Client;
import com.sun.jersey.api.client.ClientResponse;
import com.sun.jersey.api.client.WebResource;
import com.sun.jersey.api.client.config.ClientConfig;
import com.sun.jersey.api.client.config.DefaultClientConfig;
import com.sun.jersey.api.client.filter.HTTPBasicAuthFilter;
import junit.framework.Assert;

public class AuthTestResourceTest {
	
	// BASIC認証に対応したHTTPクライアントを取得する
	private static Client getBasicAuthClient(String username, String password) {
		ClientConfig config = new DefaultClientConfig();
		Client client = Client.create(config);
		client.addFilter(new HTTPBasicAuthFilter(username, password));
		return client;
	}
	
	// BASIC認証に対応したHTTPクライアントを使ってリソースを取得する
	private static WebResource getBasicAuthWebResource(String uri, String username, String password) {
		Client client = getBasicAuthClient(username, password);
		WebResource resource = client.resource(uri);
		return resource;
	}
	
	@Test
	public void testRolesAllowedBothOwner() {
		String uri = "http://localhost:8080/test/rolesallowed";
		String username = "bothowner";
		String password = "testpassword";
		WebResource resource = getBasicAuthWebResource(uri, username, password);
		ClientResponse response = resource.get(ClientResponse.class);
		// ロールとして AUTH_USER, TEST_ROLE 両方があるので OK
		Assert.assertEquals(response.getStatus(), 200);
	}
	
	@Test
	public void testRolesAllowedAuthOnly() {
		String uri = "http://localhost:8080/test/rolesallowed";
		String username = "authonly";
		String password = "testpassword";
		WebResource resource = getBasicAuthWebResource(uri, username, password);
		ClientResponse response = resource.get(ClientResponse.class);
		// ロールとして AUTH_USER はあるが TEST_ROLE がないので NG
		Assert.assertEquals(response.getStatus(), 403);
	}
	
	@Test
	public void testRolesAllowedTestOnly() {
		String uri = "http://localhost:8080/test/rolesallowed";
		String username = "testonly";
		String password = "testpassword";
		WebResource resource = getBasicAuthWebResource(uri, username, password);
		ClientResponse response = resource.get(ClientResponse.class);
		// ロールとして TEST_ROLE はあるが AUTH_USER がないので NG
		Assert.assertEquals(response.getStatus(), 403);
	}
	
	
	@Test
	public void testPermitAllBothOwner() {
		String uri = "http://localhost:8080/test/permitall";
		String username = "bothowner";
		String password = "testpassword";
		WebResource resource = getBasicAuthWebResource(uri, username, password);
		ClientResponse response = resource.get(ClientResponse.class);
		// AUTH_USER をもっているので OK
		Assert.assertEquals(response.getStatus(), 200);
	}

	@Test
	public void testPermitAllAuthOnly() {
		String uri = "http://localhost:8080/test/permitall";
		String username = "authonly";
		String password = "testpassword";
		WebResource resource = getBasicAuthWebResource(uri, username, password);
		ClientResponse response = resource.get(ClientResponse.class);
		// AUTH_USER しかもっていないユーザでも実行できる
		Assert.assertEquals(response.getStatus(), 200);
	}
	
	@Test
	public void testPermitAllTestOnly() {
		String uri = "http://localhost:8080/test/permitall";
		String username = "testonly";
		String password = "testpassword";
		WebResource resource = getBasicAuthWebResource(uri, username, password);
		ClientResponse response = resource.get(ClientResponse.class);
		// TEST_ROLE しかもっていないユーザには実行できない
		Assert.assertEquals(response.getStatus(), 403);
	}
	
	@Test
	public void testManualBothOwner() {
		String uri = "http://localhost:8080/test/manual";
		String username = "bothowner";
		String password = "testpassword";
		WebResource resource = getBasicAuthWebResource(uri, username, password);
		ClientResponse response = resource.get(ClientResponse.class);
		// やっていることは /test/rolesallowed と同じなので両方のロールが必要
		Assert.assertEquals(response.getStatus(), 200);
	}
	
}

BASIC認証を使う場合は、Client に HTTPBasicAuthFilter フィルタを追加すれば OK です。テストを実行する際には、サーブレットを起動しておくことをお忘れなく。

まとめ

Jersey (JAX-RS) で作った WebAPI にアクセス制御がかけられるようになりました。

(1) のやり方で、web.xml にパスとメソッドと必要なロールをだーっと書いていくこともできますが、リソースとの食い違いが起きやすそうですし、何より面倒です。このやり方は、全体で共通のロールを必要とさせる時など、大きな単位で制御するときに使うべきだと思います。

(2) のやり方では、リソースのすぐ近くにアクセス制御のコードを置ける上に、ビジネスロジックとは分離できるので、コードの見通しが良いと思います。通常はこのやり方を取るのがおすすめです。フィルタは JAX-RS 1.1 の仕様に含まれていないため、今回のコードは Jersey 以外の実装では動きませんが、同様のものは他の実装でも恐らく作れると思います。

大抵は (2) で事足りますが、どうしてもできないような複雑なパターンもたまにあるので、そのときは (3) のやり方を取ると良いと思います。とはいえ、そんな複雑な体系は不具合の元なので、設計に問題がある可能性を疑った方が良いかもしれないですが。このやり方だとビジネスロジックの中にアクセス制御のロジックが混ざって、コードの見通しが悪くなりがちです。