90-5.URIの値によってアクセス制御(認可処理)をするサンプル(Security5.x系)
概要
前回の記事では、マルチテナントのWEBサイトをイメージして、ログイン情報にテナントIDを追加するサンプルを見ました。
今回は、自身が所属するテナントIDの画面だけを閲覧可能にするサンプルを作ってみようと思います。
例えば、テナント①が、テナント②の画面を見れないようにアクセス制御(認可処理)します。
【補足】
前回の記事のコードは全てそのまま流用します。こちらの記事では前回の記事のコードは記載しませんのでご注意ください。
【注意点(脆弱性)】
SpringSecurity 5.0.0には、認証を回避できてしまう脆弱性(cve-2018-1199)があるので、 5.0.1以上にしてください。
このサンプルも、脆弱性対策された5.0.1以上を使用することを前提にコードを作成しています。
(ちなみに、この脆弱性を正確に把握していませんが、URLパスパラメタにエンコードしたピリオドやスラッシュを含めることで成り立つもののようです)
目標
まずは、ゴールを示します。
【目指すアクセス制御】
①ログインユーザの権限がROLE_ADMINの場合、どのテナントの画面も閲覧可能
②ログインユーザの権限がROLE_TENANTの場合、自身が所属するテナントIDの画面のみ閲覧可能
上記を実現することを目指しますが、SpringSecurityを用いた実装方法はいくつか考えられます。
思いつく中で一番、応用が効きそうなものを紹介しようと思います。
【設計】
URIにテナントIDを含めるルールにして、それを基に制御することを考えます。
そのため、まずはURIの設計を示します。
・URIの設計
/member/テナントID/**
WEBサイトは全て上記のルールにのっとって作るものとします。
実際の所、先頭のmemberは何でも良いように実装します。
・アクセス制御のルール
例えば、アクセスするURIが/member/1/input.html の場合、管理者権限(ROLE_ADMIN)を持つユーザは閲覧可能です。
テナント権限(ROLE_TENANT)のユーザは、自身のテナントIDが1の場合のみ、閲覧可能です。
・応用
テナントIDに加えて、テナントに所属する会員のIDをURIに含めて、会員同士で他の会員の情報を閲覧できないようにすることもできると思います。
テナントID以外もURIに含めることで同じようにアクセス制御ができ、便利です。
・その他
前回の記事のコードは全て流用します。ただし、この記事ではコードを示さないので前回の記事からコピペしてください。
特に、ログイン情報である TenantAuthenticationToken は確認しておいた方が理解しやすいです。
このクラスはテナントIDを保持しており、取得できるようになっています。
実際のサンプル
自作のテナントのアクセス制御(test/TenantUriAccessCheckFilter.java)
package test;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;
public class TenantUriAccessCheckFilter extends GenericFilterBean{
final static GrantedAuthority ROLE_ADMIN = new SimpleGrantedAuthority("ROLE_ADMIN");
final static GrantedAuthority ROLE_TENANT = new SimpleGrantedAuthority("ROLE_TENANT");
List<String> uriList;
String[] includeExts;
public int vote(Authentication authentication, String uri) {
//ログインしていなければチェックしない
if(!(authentication instanceof TenantAuthenticationToken)){
return 0;
}
TenantAuthenticationToken auth = (TenantAuthenticationToken)authentication;
//Uriテンプレートにマッチするかをチェックする。
Map<String, String> params = matches(uri);
if(params == null) return 0;
//マッチしたUriの権限チェックをしていく
String uri_tenantId = params.get("tenantId");
//ログイン情報から値を取得
int tenantId = auth.getTenantId();
//管理者の場合はどの画面も閲覧OK
if(authentication.getAuthorities().contains(ROLE_ADMIN)){
return 0;
}
//URLのテナントIDがログインユーザのテナントIDと一致するか?
if(!uri_tenantId.equals("" + tenantId)){
throw new AccessDeniedException("Access is denied");
}
return 0;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest)request;
String uri = obtainPath(req);
if(matchExt(uri)){
SecurityContext ctx = SecurityContextHolder.getContext();
if(ctx != null && ctx.getAuthentication() != null){
vote(ctx.getAuthentication(), uri);
}
}
chain.doFilter(request, response);
}
protected String obtainPath(HttpServletRequest req){
String pathInfo = req.getPathInfo();
if(pathInfo == null) pathInfo = "";
return req.getServletPath() + pathInfo;
}
/**
* マッチしたUriTemplateを返す。マッチするものがなかった場合はnullを返す
* @param uri [in]
* @return
*/
protected Map<String, String> matches(String uri){
AntPathMatcher m = new AntPathMatcher();
for(String path : this.uriList){
try{
Map<String, String> map = m.extractUriTemplateVariables(path, uri);
return map;
}catch(Exception e){
return null;
}
}
return null;
}
protected boolean matchExt(String path){
if(this.includeExts == null) return true;
String pathExt = StringUtils.getFilenameExtension(path);
if(!StringUtils.hasLength(pathExt)) return false;
pathExt = pathExt.toLowerCase().trim();
for(String ext : this.includeExts){
if(ext.equals(pathExt)) return true;
}
return false;
}
/**チェックしたいURIテンプレートを設定する。
* マッチした場合、このフィルタが有効になり、権限チェックが行われる。
* @param uriList
*/
public void setUriList(List<String> uriList) {
this.uriList = uriList;
}
/**
* このフィルタでチェックするURIの拡張子。スピード性能を高くするために指定する
* @param includeExts
*/
public void setIncludeExts(String[] includeExts) {
this.includeExts = includeExts;
}
}
このクラスは、Spring Securityのフィルタクラスです。
メインは先頭のメソッドです。
【処理の概要】
このクラスにはチェック対象となるURIテンプレートをAntパス形式で、複数設定できるようになっています。
例:
/member/{tenantId}/**
/web/{tenantId}/**
上記を設定した場合、例えば、以下のURIにアクセスした場合にマッチして、アクセス制御のチェックが実施されます。
・/member/1/ref.html
・/member/12/buy/input.html
・/web/111/profile/search/input.html
など
チェック処理では、URIテンプレートから{tenantId}を取得して、取得した値とログイン情報(Authentication)のテナントIDを比較することで実装しています。
これらの処理のポイントは以下です。
・URIテンプレートから{tenantId}を取得する方法
実はすでにSpringが持っていて、AntPathMatcherというクラスで実装されています。
これを使うと、上記のように簡単に取得できます。
・ログイン情報(Authentication)からテナントIDを取得する方法
前回の記事で書いています。自作のAuthenticationを作成し、その中に認証処理時にテナントIDを保存します。
これにより、簡単にテナントIDを取得できます。
これらにより、簡単にvote()メソッドが実装でき、特に説明もいらないかと思います。
SpringSecurity設定ファイル(test/spring-security.cml)
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:util="http://www.springframework.org/schema/util"
xmlns:aop="http://www.springframework.org/schema/aop"
xmlns:sec="http://www.springframework.org/schema/security"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/util
http://www.springframework.org/schema/util/spring-util.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop.xsd
http://www.springframework.org/schema/security
http://www.springframework.org/schema/security/spring-security.xsd">
<!-- JSPタグでSpringSecurity式を使用したい場合に以下を定義する -->
<bean id="webexpressionHandler"
class="org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler"/>
<!-- SpringSecurity5のメイン設定
-->
<sec:http request-matcher="ant" auto-config="false" use-expressions="false"
entry-point-ref="loginUrlAuthenticationEntryPoint" >
<sec:access-denied-handler error-page="/error/403.html"/>
<!-- 認証なしで表示する画面の設定(これらを設定しないとどこにもアクセスできないWEBになります) -->
<sec:intercept-url pattern="/error/**" access="IS_AUTHENTICATED_ANONYMOUSLY"/>
<sec:intercept-url pattern="/login.html" access="IS_AUTHENTICATED_ANONYMOUSLY"/>
<sec:intercept-url pattern="/include/**" access="IS_AUTHENTICATED_ANONYMOUSLY"/>
<!-- 認証後(ログイン後)に表示する画面のロール設定 -->
<sec:intercept-url pattern="/**" access="IS_AUTHENTICATED_FULLY"/>
<!-- ログイン設定(コメントアウトする) -->
<!--sec:form-login login-processing-url="/j_spring_security_check"
login-page="/login.html" default-target-url="/top.html"
always-use-default-target="false"
authentication-failure-url="/login.html?error=true" /-->
<!-- ログアウト設定 -->
<sec:logout logout-url="/logout" invalidate-session="true"/>
<sec:anonymous granted-authority="ROLE_ANONYMOUS"/>
<!-- HTTPヘッダで可能なセキュリティ対策
ここで設定できる内容は基本的にはApacheの設定ファイルの記述でも解決可能なことです。
アプリ側で対策するか、Apache側で対策するかは決めの問題です。
-->
<sec:headers>
<!-- クリックジャッキング対策 -->
<sec:frame-options policy="SAMEORIGIN" />
</sec:headers>
<!-- フィルタの挿入位置を指定する(既存フィルタと入れ替え)。 -->
<sec:custom-filter ref="tenantUsernamePasswordAuthenticationFilter"
position="FORM_LOGIN_FILTER"/>
<sec:custom-filter ref="tenantUriAccessCheck"
after="FILTER_SECURITY_INTERCEPTOR" />
</sec:http>
<!-- SpringSecurity5からPasswordEncoderが必要になったらしい。
本当は、org.springframework.security.crypto.bcrypt.BCryptPasswordEncoderクラス使用すべき。
使用する場合は、DBに保存するPWも暗号化しておくこと。
-->
<bean id="passwordEncoder"
class="org.springframework.security.crypto.password.NoOpPasswordEncoder" />
<!-- エントリポイント。自作Filter使用の時は記述必要。sec:httpタグの entry-point-ref属性に設定するbean -->
<bean id="loginUrlAuthenticationEntryPoint" class="org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint">
<constructor-arg value="/login.html" />
</bean>
<!-- 自作フィルタ。自作の入力用の認証情報を作成するためのフィルタ。 -->
<bean id="tenantUsernamePasswordAuthenticationFilter" class="test.TenantUsernamePasswordAuthenticationFilter" >
<property name="authenticationManager" ref="authenticationManager" />
<property name="authenticationFailureUrl" value="/login.html?error=true" />
<property name="defaultTargetUrl" value="/top.html" />
<property name="alwaysUseDefaultTargetUrl" value="false" />
<property name="filterProcessesUrl" value="/j_spring_security_check" />
</bean>
<!-- 認証処理 -->
<sec:authentication-manager alias="authenticationManager">
<!-- 認証手形発行所 -->
<sec:authentication-provider ref="tenantAuthenticationProvider" />
</sec:authentication-manager>
<!-- 自作のログイン認証&Credencial作成処理(認証手形発行所) -->
<bean id="tenantAuthenticationProvider" class="test.TenantAuthenticationProvider" />
<!-- 自作アクセス権限チェック用フィルタ。URI上のテナントIDとメンバーIDがアクセスOKかどうかをチェックする -->
<bean id="tenantUriAccessCheck" class="test.TenantUriAccessCheckFilter">
<property name="uriList">
<list>
<value>/member/{tenantId}/**</value>
<value>/tenant/{tenantId}/**</value>
</list>
</property>
<property name="includeExts" value="html" />
</bean>
</beans>
前回の記事からの追記部分は赤字にしました。
前回の記事を読んでいただくとわかるかと思いますが、自作のフィルタをFilterSecurityInterceptorクラスの後ろに挿入しています。
デフォルトで登録されているフィルタと登録順は、最初の記事をお読みください。
【説明】
<tenantUriAccessCheckについて>
・uriListプロパティ
listでチェック対象のURIを設定します。
上記では、/member/テナントID、/tenant/テナントID、で始まるURIの場合に自作フィルタが働くように設定しています。
それ以外は無視されます。
・includeExtsプロパティ
対象のURIの拡張子を設定します。
上記では.html拡張子の時に自作フィルタが働くように設定しています。nullや設定しない場合は、すべての拡張子が対象です。
このプロパティの目的は、処理対象を絞り、PCの負荷を減らすことです。
というのは、このフィルタを通るアクセスはControllerで処理されるURIだけではなく、css、jpegなどの画像、など、
静的コンテンツも含まれます。1画面に画像が100個あれば、100回このフィルタの処理が走るので、余計な負荷がかかります。
画像はたいていの場合はテナントに無関係に、誰でもアクセスしてよいことが多いのでこのような拡張子の機能をつけました。
<sec:custom-filterタグについて>
次に、sec:custom-filterタグですが、前回の記事と違って、既存のフィルタを入れ替えるのではなく、後ろに挿入しています。
なぜFilterSecurityInterceptorクラスの後ろにしているのかを補足しておきます。
まずは、最初の記事でこのクラスの位置を確認してみてください。
補足(自作フィルタの設定位置の理由):
自作フィルタは、AccessDeniedExceptionを受け取ってアクセス認可エラー処理をしてくれるRequestExceptionTranslationFilterの後ろに設定しました。
このため、自作クラスTenantUriAccessCheckFilterでアクセスしてはいけないURIだった場合に、AccessDeniedExceptionを投げれば
自動的にアクセス認可エラー処理をしてくれます。
RequestExceptionTranslationFilterの後ろなら、FilterSecurityInterceptorの前でも良かったのですが、
なんとなく、FilterSecurityInterceptorの処理の方が軽そうで、先にエラーになれば後続の少し重い自作フィルタが処理される可能性が低くなるかと思い、後ろにしました。
動作確認用のController(test/TenantController.java)
@Controller
public class TenantController {
@RequestMapping(value="/member/{tenantId}/info")
@ResponseBody
public String loginAdmin() {
return "<html>Test</html>";
}
}
このControllerクラスは、動作確認用です。
例えば、ログイン後に、以下のようにアクセスして、動作確認してみてください。
テナントIDが閲覧不可の場合は、403エラーが出るはずです。
・http://localhost:8080/test/member/1/info.html
testや、.htmlの部分はweb.xmlの設定によっても違うので、各自の設定に合わせてアクセスしてみてください。
1の部分はテナントIDですので、ログインユーザ自身のテナントIDに合わせて値を変えて試してみてください。
動作確認するときは、ログイン情報の権限に、ROLE_ADMINやROLE_TENANTを設定しておくことをお忘れなく。
うまく動作しましたでしょうか?
うまくいかない場合は、まずは前回の記事だけで動作させてうまく動くか見てから、本記事の内容を試してみてください。
Created Date: 2018/08/04