REST(JAX-RS, Jersey)を使ったシステムで認証を導入するには


今回はRESTによるシステム作るとき、認証をどのように実現するかについて書きます。以下のやり方だとセッションを張るので、それRESTじゃないじゃん、と言われるかも知れませんが・・・。(RESTで認証するならクライアント側からトークンを送ってリクエストごとに認証するかどうか判断するのが妥当かもしれません。)とにかく最善の実装ではない可能性はありますが、書いてみます。

Jerseyなんかを使うと簡単にWebアプリケーションまたはWebサービスを作ることができます。

EclipseでMavenプロジェクトを作成する時に、「jersey-quickstart-webapp」を選択するだけでOKです。

jersey-quickstart-webapp

今回はこれをもとに、認証ありのリソースと認証なしのリソースを返却するWebサービスを作成します。

Jerseyを使ったRESTシステム

まずはそのリソースを返却するコードを用意します。

package com.tsukaby.jerseyauth;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("public")
public class PublicService {

  @GET
  @Path("info")
  @Produces(MediaType.APPLICATION_JSON)
  public Information getIt() {
    return new Information("Title", "Public Information.");
  }
}
package com.tsukaby.jerseyauth;

import javax.annotation.security.RolesAllowed;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

@Path("private")
public class PrivateService {

  @GET
  @Path("info")
  @RolesAllowed({ "HogeRole" })
  @Produces(MediaType.APPLICATION_JSON)
  public Information getIt() {
    return new Information("Title", "Private Information.");
  }
}
package com.tsukaby.jerseyauth;

public class Information {
  private String subject;
  private String content;

  public Information() {
  }

  public Information(String subject, String content) {
    this.subject = subject;
    this.content = content;
  }

  public String getSubject() {
    return subject;
  }

  public void setSubject(String subject) {
    this.subject = subject;
  }

  public String getContent() {
    return content;
  }

  public void setContent(String content) {
    this.content = content;
  }

  @Override
  public String toString() {
    return "Information [subject=" + subject + ", content=" + content + "]";
  }

}

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">
  <servlet>
    <servlet-name>Jersey Web Application</servlet-name>
    <servlet-class>org.glassfish.jersey.servlet.ServletContainer</servlet-class>
    <init-param>
      <param-name>jersey.config.server.provider.packages</param-name>
      <param-value>com.tsukaby.jerseyauth</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
  </servlet>
  <servlet-mapping>
    <servlet-name>Jersey Web Application</servlet-name>
    <url-pattern>/webapi/*</url-pattern>
  </servlet-mapping>

  <security-constraint>
    <web-resource-collection>
      <web-resource-name>SampleResource</web-resource-name>
      <url-pattern>/webapi/private/*</url-pattern>
      <http-method>GET</http-method>
      <http-method>POST</http-method>
      <http-method>PUT</http-method>
      <http-method>DELETE</http-method>
    </web-resource-collection>
    <auth-constraint>
      <role-name>HogeRole</role-name>
    </auth-constraint>
  </security-constraint>
  <security-role>
    <role-name>HogeRole</role-name>
  </security-role>
  <login-config>
    <auth-method>FORM</auth-method>
    <realm-name>User Form Auth</realm-name>
    <form-login-config>
      <form-login-page>/login.html</form-login-page>
      <form-error-page>/error.html</form-error-page>
    </form-login-config>
  </login-config>

</web-app>

今回はFORM認証なので、login.htmlとerror.htmlも用意します。BASIC認証やDIGEST認証でも良いと思いますが、ウィンドウが簡素なので、FORM認証を選択します。自由度高いですし。

login.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>ログイン画面</title>
</head>
<body>
  <h1>ログイン画面</h1>
  <form method="POST" action="j_security_check" name="loginform">
    ユーザー名:<input type="text" name="j_username"><br/>
    パスワード:<input type="password" name="j_password"><br/>
    <input type="submit" value="login">
  </form>
</body>
</html>

error.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>ログイン失敗</title>
</head>
<body>
ログイン失敗

<a href="/JerseyAuth">TOP</a>

</body>
</html>

最後にtomcat-user.xmlに以下を追記します。

<role rolename="HogeRole"/>
<user username="user1" password="password" roles="HogeRole"/>

ブラウザでの動作検証

上記の状態でサーバを起動し、ブラウザからリソースにアクセスします。まずはpublicにアクセスします。 http://localhost:8080/JerseyAuth/webapi/public/info

認証なしで、以下のデータが返却されます。

{"content":"Public Information.","subject":"Title"}

次はprivateにアクセスします。 http://localhost:8080/JerseyAuth/webapi/private/info

FORMの認証画面が表示されました。ちゃんとアクセス制限が効いています。ここでuser1/passwordを入力し、ログインすると、以下のデータが返却されます。

{"content":"Private Information.","subject":"Title"}

後はクライアント側で何とかすれば認証ありでリソースを使うことができます。

jQueryで$.getJsonを使ってデータを取れば良いと思います。HTMLも認証ありのディレクトリに突っ込んでおけばいいですし、HTMLページを介さないで直接上記リソースにアクセスされても認証があるので大丈夫でしょう。

ブラウザ以外(Javaコード)での動作検証

クライアントがブラウザでない、例えばAndroidとかでも大した違いはありません。ここではApache HttpComponentsのHttpClientを使って、Android側から通常のJavaコードでリソースにアクセスすることを考えます。JerseyClientというものもあって、そっちも便利なんですがCookie周りなど状態に関する扱いが不便なので、今回はApacheの方を利用します。

では実際にコードを作成してアクセスしてみます。ここではMainクラスに書いて通常のJavaアプリケーションとして動作させますが、Androidの場合は適宜AsyncTaskLoaderとかに書いてください。

package com.tsukaby.jerseyauthclient;

import net.arnx.jsonic.JSON;

import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.util.EntityUtils;

import com.tsukaby.jerseyauth.Information;

public class MainPublic {

  public static void main(String[] args) throws Exception {
    DefaultHttpClient httpclient = new DefaultHttpClient();
    HttpGet httpget = new HttpGet("http://localhost:8080/JerseyAuth/webapi/public/info");

    HttpResponse response = httpclient.execute(httpget);
    HttpEntity entity = response.getEntity();

    String json = EntityUtils.toString(entity);
    EntityUtils.consume(entity);
    System.out.println(json);

    // Javaオブジェクトに変換
    Information info = JSON.decode(json, Information.class);
    System.out.println(info);
  }

}

実行結果(publicなリソース)

{"content":"Public Information.","subject":"Title"}
Information [subject=Title, content=Public Information.]

実に簡単ですね。ライブラリ万歳です。JSON.decodeはJSONICというライブラリで、これを使ってJSONの文字列をJavaオブジェクトに変換しています。publicのリソースは問題なく取得できました。ちなみにentityは内部にStreamを持っているので、必ずCloseしてください。でないとリークします。Closeが面倒だという人はEntityUtils.consumeを実行しましょう。内部でCloseしてくれています。

さて、問題はprivate、つまり認証ありの方です。

package com.tsukaby.jerseyauthclient;

import java.util.ArrayList;
import java.util.List;

import net.arnx.jsonic.JSON;

import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;

import com.tsukaby.jerseyauth.Information;

public class MainPrivate {

  public static void main(String[] args) throws Exception {
    DefaultHttpClient httpclient = new DefaultHttpClient();
    // ターゲットのリソース
    HttpGet httpget = new HttpGet("http://localhost:8080/JerseyAuth/webapi/private/info");

    // 1回目のアクセス Cookie取得など(ログイン画面)
    HttpResponse response = httpclient.execute(httpget);
    EntityUtils.consume(response.getEntity());

    // FORMのパラメータ
    List<NameValuePair> formParams = new ArrayList<NameValuePair>();
    formParams.add(new BasicNameValuePair("j_username", "user1"));
    formParams.add(new BasicNameValuePair("j_password", "password"));
    UrlEncodedFormEntity urlCode = new UrlEncodedFormEntity(formParams);

    // 認証に必要なPOST先
    HttpPost httpost = new HttpPost("http://localhost:8080/JerseyAuth/webapi/private/j_security_check");
    httpost.setEntity(urlCode);

    // POSTで認証
    response = httpclient.execute(httpost);
    EntityUtils.consume(response.getEntity());

    // 認証されたのでリソースを取得
    response = httpclient.execute(httpget);
    String json = EntityUtils.toString(response.getEntity());
    EntityUtils.consume(response.getEntity());
    System.out.println(json);

    // Javaオブジェクトに変換
    Information info = JSON.decode(json, Information.class);
    System.out.println(info);
  }
}

実行結果(privateなリソース、認証なし)

{"content":"Private Information.","subject":"Title"}
Information [subject=Title, content=Private Information.]

なんと残念なことに3回GETまたはPOSTを発行する必要があるようです。自分のやり方がまずい気もします。初めのGETはサーバ側のセッション作成をログイン後にすれば減らせるかも・・・?

1回目のGETでCookieつまりJSESSIONIDを貰い、2回目でj_security_checkに対してログインをリクエストし、3回目で希望のリソースを取得します。2回目ではHTTP 302が返ってきており、ヘッダのLocationにアクセス先が記述されています。(上記ではもう分かり切ってるのでLocationの値を取ることはしていません。)302が返ってくるために、3回目で再度GETする必要がある、ということです。ここでようやくHTTP 200 OKでJSON文字列が取れます。

以上で終了です。FORM認証は上記のように3回リクエストする必要があったり、パスワード管理にハッシュとストレッチングが使えるか謎だったりで、まだまだ調べなくてはいけませんが・・・。とりあえずRESTで認証が可能なことが分かりました。