SecurityContextとセッションでアクセス制御を行う

【前提条件】

[環境]
[設定など]

コンテキストパスは「/」で動作させています。

【参考サイト】

Jersey
Jersey

【概要】

JAX-RSでHTTPセッションの情報を元に権限チェックする方法です。
javax.ws.rs.core.SecurityContextを使用します。

Google先生で検索するとweb.xmlに権限情報を追加するというのは
かなり引っかかるのですが、なかなか独自でと言うのが引っかかりませんでした。

ログインユーザごとに権限をチェックしたいと言う場合、
一般的にはDBにアクセスしてと言うやり方をすると思うので、web.xmlを使うのは無理があります。

今回はDBで認証した情報をセッションに設定、
そのセッションを元にSecurityContextで権限チェックを行う方法について書きます。

(試行錯誤しながらやった結果なので、他に良いやり方がありそうな気が・・・)

【SecurityContextによる権限チェックとは】

下記のようなアノテーションをつけると
SecurityContextの情報から権限があるかをチェックしてくれるようです。

@PermitAll

@RolesAllowed("Admin")

詳細はjavax.annotation.securityパッケージ配下のJavaDocを参照して下さい。

【独自のSecurityContext】

まずは独自のSecurityContextを作成します。

package jp.ne.glory.web.framework.filter;

import java.security.Principal;
import javax.servlet.http.HttpSession;
import javax.ws.rs.core.SecurityContext;

public class SessionSecurityContext implements SecurityContext {

    private final Principal principal;
    private final String roleName;

    public SessionSecurityContext(final HttpSession session) {

        this.principal = (Principal) session.getAttribute("Princpal");
        this.roleName = (String) session.getAttribute("Role");
    }

    @Override
    public Principal getUserPrincipal() {

        return principal;
    }

    @Override
    public boolean isUserInRole(String role) {

        return role.equals(roleName);
    }

    @Override
    public boolean isSecure() {

        return false;
    }

    @Override
    public String getAuthenticationScheme() {

        return "OriginalForm";
    }
}

コンストラクタでセッションを受け取り、必要な情報(Principalとロール名)を設定します。
今回はサンプルなので単一のロール名のみですが、通常はListとかで受け取ると思います。

SecurityContextではいくつかのメソッドを実装する必要があります。

getUserPrincipalメソッドではPrincipalのオブジェクトを返します。
今回はセッションに設定した内容を返しています。
(Principalオブジェクトが何をしているか良くわかっていないのですが、認証した情報を保持するオブジェクトっぽいです)

isUserInRoleメソッドが今回重要になる箇所です。
パラメータで渡されたroleを保持しているかを判定するメソッドです。
RolesAllowedで設定したロール名と比較する時に使用されるようです。

isSecureメソッドは通信がセキュアかを判定するものです。
今回はHTTP通信なのでfalseです。

getAuthenticationSchemeメソッドは認証方法のスキーマを返却します。
web.xmlを使用した場合はBASIC、DIGESTなどが返却されます。
今回は独自のものなのでOriginalFormとしてみました。
(ここら辺は何かルールがあったりするかもしれません)

【Filter】

続いて作成したSecurityContextを設定するためのFilterを作成します。

package jp.ne.glory.web.framework.filter;

import java.io.IOException;
import javax.annotation.Priority;
import javax.servlet.http.HttpSession;
import javax.ws.rs.Priorities;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.core.SecurityContext;
import jp.ne.glory.infra.certify.CertifyTarget;

@Priority(Priorities.AUTHENTICATION)
@CertifyTarget
public class SessionSecurityFilter implements ContainerRequestFilter {

    private HttpSession session;

    public SessionSecurityFilter(final HttpSession session) {

        this.session = session;
    }

    @Override
    public void filter(final ContainerRequestContext requestContext) throws IOException {

        final SecurityContext context = new SessionSecurityContext(session);
        requestContext.setSecurityContext(context);
    }
}

やっていることはContainerRequestContext#setSecurityContextに
先ほど作成したSecurityContextのオブジェクトを設定しているだけです。

PriorityにはPriorities.AUTHENTICATIONを設定します。
Priorities.AUTHORIZATIONより小さい値であれば良いです。

CertifyTargetアノテーションはFilterのNameBinding用のアノテーションです。

package jp.ne.glory.infra.certify;

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
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CertifyTarget {

}

【Feature】

FilterにProviderアノテーションをつければ想定どおり動くかなと思ったのですが、
Feature経由ではないと上手く動かなかったのでFeatureを作成します。
(上手くいかなかった件は後述します)

package jp.ne.glory.web.framework.filter;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.servlet.http.HttpSession;
import javax.ws.rs.container.DynamicFeature;
import javax.ws.rs.container.ResourceInfo;
import javax.ws.rs.core.FeatureContext;

@ApplicationScoped
public class SessionSecurityFeature implements DynamicFeature {

    @Inject
    private HttpSession session;

    @Override
    public void configure(ResourceInfo resourceInfo, FeatureContext context) {
        context.register(new SessionSecurityFilter(session));
    }
}

HttpSessionをFeatureContext#registerでコンポーネントを登録しているだけです。

【Application】

アプリケーションの設定部分です。
今回はJerseyを使っているので、Jerseyのorg.glassfish.jersey.server.ResourceConfigを使います。

package jp.ne.glory.web;

import javax.ws.rs.ApplicationPath;
import jp.ne.glory.web.framework.filter.SessionSecurityFeature;
import jp.ne.glory.web.framework.thymeleaf.ThymeleafViewProcessor;
import org.glassfish.jersey.filter.LoggingFilter;
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature;
import org.glassfish.jersey.server.mvc.MvcFeature;

@ApplicationPath("/")
public class ApplicationSetting extends ResourceConfig {

    public ApplicationSetting() {

        // ここの3行はJerseyMVCとThymeleafの設定関連です
        packages(this.getClass().getPackage().getName());
        register(ThymeleafViewProcessor.class);
        register(MvcFeature.class);

        register(SessionSecurityFeature.class);
        register(RolesAllowedDynamicFeature.class);
    }
}

先ほど作成したSessionSecurityFeatureとRolesAllowedDynamicFeatureを登録します。

RolesAllowedDynamicFeatureはJerseyが提供する権限チェック用のFeatureです。
権限チェックを行うFilterを登録してくれます。
権限チェックを行うFilterはRolesAllowedアノテーションがあるかなどを判定し、
アクセス権限がない場合、javax.ws.rs.ForbiddenExceptionをスローします。

登録されるFilterの優先度はPriorities.AUTHORIZATIONになっています。
そのため今回自作したFilterはPriorities.AUTHORIZATIONより小さい値である必要があります。

【ExceptionMapper】

ForbiddenExceptionがスローされると403エラー画面が出るので、
ForbiddenExceptionが起きた場合の処理を実装します。

package jp.ne.glory.web.framework.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;

@Provider
public class ForbiddenExceptionMapper implements ExceptionMapper<ForbiddenException> {

    @Override
    public Response toResponse(ForbiddenException exception) {

        return Response.ok("You have no TestRole authority.").build();
    }

}

「権限がありません」と言うメッセージを返却します。
(403 Forbiddenを200 OKにするのは良くないですがサンプルコードなので。)

【アクセス制御するリソース】

アクセス制御するリソースは下記のようにします。

package jp.ne.glory.web.admin;

import javax.annotation.security.RolesAllowed;
import javax.enterprise.context.RequestScoped;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
import jp.ne.glory.infra.certify.CertifyTarget;

@Path("/admin")
@RequestScoped
@CertifyTarget
@RolesAllowed("TestRole")
public class Top {

    @GET
    public Response view() {

        return Response.ok("Welcome! TestRole User!").build();
    }
}

RolesAllowedを使って「TestRole」を持つ場合にだけアクセスできるようにしています。
RolesAllowedの値は配列で受け渡せますが、今回は単一のロールなのでStringです。

アクセスチェック制御対象なので独自に作成したCertifyTargetもつけます。

【セッション作成部分】

続いてセッションを作成するリソースです。

package jp.ne.glory.web;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
import jp.ne.glory.infra.certify.CertifyPrincipal;

@Path("/sample")
public class SessionSample {

    @GET
    public Response createSession(@Context final HttpServletRequest servletRequest) {

        final HttpSession session = servletRequest.getSession(true);
        session.setAttribute("Principal", new CertifyPrincipal());
        session.setAttribute("Role", "TestRole");

        return Response.ok("Session created!").build();
    }
}

セッションに必要なPrincipalとロール名を設定しています。
Principalは独自に作成したクラスのオブジェクトを設定します。

[Principal]
package jp.ne.glory.infra.certify;

import java.security.Principal;

public class CertifyPrincipal implements Principal {

    @Override
    public String getName() {
        return "test";
    }
}

getNameメソッドではログインしたユーザ名やグループ名を返すべきなのだろうなぁと思うのですが、
今回はかなり適当な値を返却しています。

【試してみる】

と言うことで動きを確認してみます。

ブラウザを立ち上げて、
http://localhost:8080/admin」にアクセスすると「You have no TestRole authority.」と表示されます。
権限がないのでForbiddenExceptionが発生し、ForbiddenExceptionMapperの結果が返却されていることがわかります。

続いて「http://localhost:8080/sample」にアクセスすると「Session created!」と表示されます。
これでセッションに情報が設定されました。

もう一度「http://localhost:8080/admin」にアクセスすると「Welcome! TestRole User!」と表示されます。
Top#viewの処理が実行されたことがわかります。

無事にアクセス制御できていますね。

【まとめ】

セッションとSecurityContextを組み合わせて権限チェックを行う方法を試してみました。

アノテーションをつけるだけで権限チェックが行えるので、
権限の種類が多いと言うアプリケーションの場合には非常に有効だと思います。

今回のサンプルはほとんどJAX-RS仕様になるので、特定のフレームワークへの依存もほとんどないと思います。
RolesAllowedDynamicFeatureを使った箇所はJerseyに依存していますが。

今回はRolesAllowedDynamicFeatureを使用しましたが、
独自のFeatureを作ることもできるのでかなりカスタマイズしやすそうです。

【Featureを作った理由】

本当は書きのようなコードにしたかったのですが、

@ApplicationPath("/")
public class ApplicationSetting extends ResourceConfig {

    /**
     * コンストラクタ.<br>
     * アプリケーションの設定を行う。
     */
    public ApplicationSetting() {

        packages(this.getClass().getPackage().getName());

        register(ThymeleafViewProcessor.class);

        register(MvcFeature.class);

        register(LoggingFilter.class);
        register(SessionSecurityFilter.class);
        register(RolesAllowedDynamicFeature.class);
    }
}

ResourceConfigから登録したFilterと
Feature経由で登録したFilterで実行される順番が異なったため、
Feature経由で登録するようにしました。

上記のコードでも
RolesAllowedDynamicFeatureのFilter => SessionSecurityFilter
と言う実行順序でした。

原因がわからなかったのですが、Featureでやったらできました。
Feature経由でも標準から外れたやり方ではないと思います。