kagamihogeの日記

kagamihogeの日記です。

thymeleaf-extras-springsecurityつかう

Thymeleafコアには含まれない、spring-securityとThymeleafを結び付けて使うためのThymeleaf Extrasモジュールの基本的な使い方に関するメモ。基本的に、このエントリは https://github.com/thymeleaf/thymeleaf-extras-springsecurity のドキュメントを基にしている。このエントリ中で単に「ドキュメント」と書く場合、このURLを指す。

なおACL関連は準備が面倒なので省略している。

基本的な使い方

spring-bootでSpring MVCを想定して進める。pom.xmlthymeleaf-extras-springsecurity[3|4|5]を追加する。

pom.xml

 <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.0.RELEASE</version>
        <relativePath />
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>13</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity5</artifactId>
        </dependency>
    </dependencies>

両ライブラリのversionを合わせる必要があるが余り心配しなくても良い

Spring Securityとthymeleaf-extras-springsecurity[3|4|5]のバージョンは合わせる必要があるが、spring-bootの場合はあまり神経質になる必要無い。その理由はspring-boot側にversion指定があるため。たとえば、spring-boot-2.2.0ではspring-security-coreは5.2.0なのでthymeleaf-extras-springsecurity5を使う必要があるが、誤って以下のように4を指定したとする。

     <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity4</artifactId>
        </dependency>

上記のように誤った指定をした場合、mavenで以下のようにビルドエラーとなる*1

[ERROR] 'dependencies.dependency.version' for org.thymeleaf.extras:thymeleaf-extras-springsecurity4:jar is missing. @ line 43, column 15

java

起動用クラス。

import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;

@SpringBootApplication
public class App {
    public static void main(String[] args) {
        new SpringApplicationBuilder(App.class).web(WebApplicationType.SERVLET).run(args);
    }
}

サンプル表示用に適当な認証・認可を返す認証プロバイダを作る。自前のAuthenticationProviderを用意してAuthenticationprincipalとかsetDetailsとかにサンプル用の適当な名前をつめる。

import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collection;

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;

@Component
public class MyAuthenticationProvider implements AuthenticationProvider {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Collection<? extends GrantedAuthority> authorities = Arrays.asList(
                new SimpleGrantedAuthority("AUTH001"),
                new SimpleGrantedAuthority("AUTH002"),
                new SimpleGrantedAuthority("ADMIN"),
                new SimpleGrantedAuthority("ROLE_ADMIN"));
        MyUsernamePasswordAuthenticationToken token = new MyUsernamePasswordAuthenticationToken("kagami", "hoge", authorities);
        MyDetail details = new MyDetail();
        details.hogeValue = "hogeValue";
        details.hogeList = Arrays.asList("aaa", "bbb", "ccc");
        token.setDetails(details);
        token.setSampleLocalDateTime(LocalDateTime.now());
        return token;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return true;
    }
}

サンプル表示用のdetailクラス。

public class MyDetail {
    String hogeValue;
    List<String> hogeList;
    // getter/setter省略
}

以降のサンプルコードは上記のjavaを前提とする。

html

ログイン後の画面/src/main/resources/templates/index.htmlにあれこれ表示する。

<!doctype html>
<html xmlns:th="http://www.thymeleaf.org"
    xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8" />
<title>Hello Thymeleaf</title>
</head>
<body>
    <div th:text="${#authentication.principal}">
      Authentication.getPrincipal()
    </div>
    <div th:text="${#authentication.details.hogeValue}">
      Authentication.getDetails().getHogeValue()
    </div>
    <div th:text="${#authentication.sampleLocalDateTime}">
      MyUsernamePasswordAuthenticationToken.getSampleLocalDateTime()
    </div>
</body>
</html>

xmlのNamespace

<html xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

https://github.com/thymeleaf/thymeleaf-extras-springsecurity によると、namespaceは全バージョンで共通。無かったり間違っててもテンプレートの処理に支障は無いが、IDEの補間とかに影響が出るかもしれない、と書いてある。

使い方

ドキュメントによると、expression utility objectsというSpring Securityのオブジェクトを使うものと、attributesという属性を使うものがある。両者に本質的な差は無く、基本的には同じ事を異なる書き方で書ける、というだけ。ただし、両者でやれる事に若干差はある。

Spring Securityのオブジェクト

#authenticationで認証、#authorizationで認可、のオブジェクトにアクセスできる。基本的には、Thymeleafの他機能とかSpELとかと組み合わせて使用する。

authenticationオブジェクト

これは、例えば以下のように使用する。

<div th:text="${#authentication.principal}">
  Authentication.getPrincipal()
</div>
<div th:text="${#authentication.details.hogeValue}">
  Authentication.getDetails().getHogeValue()
</div>
<div th:text="${#authentication.sampleLocalDateTime}">
  MyUsernamePasswordAuthenticationToken.getSampleLocalDateTime()
</div>

このエントリでは認証プロバイダでUsernamePasswordAuthenticationTokenを拡張したMyUsernamePasswordAuthenticationTokenを返しているので、そのプロパティにもアクセスできる*2

authorizationオブジェクト

これは、基本的には、th:ifth:unlessと組み合わせて画面表示の分岐処理に使用する。

<div th:if="${#authorization.expression('hasRole(''ADMIN'')')}">
  ADMIN roleがある。
</div>
<div th:unless="${#authorization.expression('hasAuthority(''NOT-HAS-AUTH'')')}">
  NOT-HAS-AUTH authorityが無い。
</div>

この#authorizationの実際のクラスはorg.thymeleaf.extras.springsecurity[3|4|5].auth.Authorizationなので、使用可能なメソッドはそちらを確認する。とはいえ、spring-securityのビルトイン関数を呼ぶためのexpression以外は余り使う機会は無い気がするが、複雑な処理を記述する場合にはgetAuthentication()で認証オブジェクトにアクセスしたり、IExpressionContext getContext()が重要なケースが出てきたりするかもしれない。

ビルトイン関数については後述する。

属性

上述のように、#authenticationオブジェクトとec:authentication属性と書く事に基本的な差は無い。こちらの書き方の方が記述的で分かりやすい、ような気がする。

sec:authentication

以下は、authenticationオブジェクトの基本的な使い方のコード例と同一の出力結果となる。

<div sec:authentication="principal">
  Authentication.getPrincipal()
</div>
<div sec:authentication="details.hogeValue">
  Authentication.getDetails().getHogeValue()
</div>
<div sec:authentication="sampleLocalDateTime">
  MyUsernamePasswordAuthenticationToken.getSampleLocalDateTime()
</div>

sec:authorize

基本的には、th:if + #authorization.expression(...)エイリアスと考えて良い。認証や認可が何らかの条件の時だけ要素を出す、が良くある使い方になる。

<div sec:authorize="hasAuthority('ADMIN')">
  ADMINのauthorityがある。
</div>

<div sec:authorize="principal == 'principal'">
  principalが'principal'と一致する。
</div>

SpELの他のオブジェクト、たとえば#vars, #httpSession、と組み合わせて使う場合は以下のようにする。いま、/sampleが以下のようなコードとして、

   @GetMapping("/sample")
    public ModelAndView hoge(HttpSession session) {
        session.setAttribute("name1", "ADMIN");

        ModelAndView model = new ModelAndView("index");
        model.addObject("name", "ADMIN");
        
        return model;
    }

sessionやModelAndViewの値とsec:authorizeを組み合わせるには以下のようにする。

<div sec:authorize="${hasAuthority(#vars.name)}">
  ModelAndViewのnameの値(=ADMIN)のauthorityがある
</div>
<div sec:authorize="${hasAuthority(#httpSession.getAttribute('name1'))}">
  HttpSessionのname1の値(=ADMIN)のauthorityがある
</div>

sec:authorize-url

ユーザが指定のURLにアクセス可能かどうか、をチェックする。configが以下のようになっている、とする。

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests() //
     .antMatchers("/").permitAll() //
     .antMatchers("/admin").hasAuthority("ADMIN") //
     .antMatchers(HttpMethod.POST, "/admin-post-only").hasAuthority("ADMIN") //
     .anyRequest().authenticated();

   http
     .formLogin()
     .and()
     .httpBasic();
    }
}
<div sec:authorize-url="/admin">
  /adminにアクセス可能な認可がある。
</div>

HTTPメソッドも指定できる。なお未指定時のデフォルトはGET

<div sec:authorize-url="POST /admin-post-only">
  POST /admin-post-onlyにアクセス可能な認可がある。
</div>

Spring Securityのビルトイン関数

https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#el-common-built-in に一覧がある。また、対応クラスはorg.springframework.security.access.expression.SecurityExpressionRootなので必要に応じてソースも参照する。

以下は関数一覧の抄訳。その後に使用例のサンプルコードも示す。

Expression Description
hasRole([role]) 現在のprincipalがそのroleを持つ場合にtrueを返します。デフォルトでは、引数のroleの先頭が'ROLE'で開始しない場合'ROLE'を追加します。この挙動はDefaultWebSecurityExpressionHandlerdefaultRolePrefixでカスタマイズ可能です。
hasAnyRole([role1,role2]) 現在のprincipalがroleのいずれか1つ以上のrole(カンマ区切りの文字列リスト)を持つ場合にtrueを返します。デフォルトでは、引数のroleの先頭が'ROLE'で開始しない場合'ROLE'を追加します。この挙動はDefaultWebSecurityExpressionHandlerdefaultRolePrefixでカスタマイズ可能です。
hasAuthority([authority]) 現在のprincipalがauthorityを持つ場合にtrueを返します。
hasAnyAuthority([authority1,authority2]) 現在のprincipalがいずれか1つ以上のauthorities(カンマ区切りの文字列リスト)を持つ場合にtrueを返します。
principal 現在のユーザを表すprincipalオブジェクト。
authentication SecurityContextから取得する現在のAuthentication
permitAll 常にtrue
denyAll 常にfales
isAnonymous() 現在のprincipalがanonymousの場合にtrueを返します。
isRememberMe() 現在のprincipalがremember-meの場合にtrueを返します。
isAuthenticated() ユーザが非anonymousの場合にtrueを返します。
isFullyAuthenticated() ユーザが非anonymous and 非remember-meの場合にtrueを返します。
hasPermission(Object target, Object permission) ユーザがtargetにpermissionのアクセス権を持つ場合にtrueを返します。例:hasPermission(domainObject, 'read')
hasPermission(Object targetId, String targetType, Object permission) ユーザがtargetにpermissionsのアクセス権を持つ場合にtrueを返します。hasPermission(1, 'com.example.domain.Message', 'read')

hasRole([role])

   <div th:if="${#authorization.expression('hasRole(''ROLE_ADMIN'')')}">
        ADMIN roleがある。
    </div>
    <div th:if="${#authorization.expression('hasRole(''ADMIN'')')}">
        ADMIN roleがある。
    </div>

hasAnyRole([role1,role2])

   <div th:if="${#authorization.expression('hasAnyRole(''ADMIN'', ''HOGE'')')}">
        ADMIN, HOGE のいずれかのroleがある。
    </div>

hasAuthority([authority])

   <div th:if="${#authorization.expression('hasAuthority(''AUTH001'')')}">
        AUTH001のauthorityがある。
    </div>

hasAuthorityのAND

詳細はSpring Expression Language(SpEL式)だがandを取ればよい。

   <div th:if="${#authorization.expression('hasAuthority(''AUTH001'') and hasAuthority(''AUTH002'')')}">
        AUTH001 and AUTH002 のauthorityがある。
    </div>

hasAnyAuthority([authority1,authority2])

   <div th:if="${#authorization.expression('hasAnyAuthority(''AUTH001'', ''HOGE'')')}">
        AUTH001, HOGE のいずれかのauthorityがある。
    </div>

principal

   <div th:if="${#authorization.expression('principal == ''kagami''')}">
        principalがkagami
    </div>

authentication

   <div th:if="${#authorization.expression('authentication.details.hogeValue == ''hogeValue''')}">
        Authentication.getDetails().getHogeValue()がhogeValue
    </div>

permitAll

<div th:text="${#authorization.expression('permitAll')}">常にtrue</div>

denyAll

<div th:text="${#authorization.expression('denyAll')}">常にfalse</div>

isAnonymous()

   <div th:text="${#authorization.expression('isAnonymous()')}">anonymousユーザならtrue</div>

isRememberMe()

   <div th:text="${#authorization.expression('isRememberMe()')}">remember-meユーザならtrue</div>

isAuthenticated()

<div th:text="${#authorization.expression('isAuthenticated()')}">not anonymousユーザならtrue</div>

isFullyAuthenticated()

<div th:text="${#authorization.expression('isFullyAuthenticated()')}">not anonymous and not remember-meユーザならtrue</div>

*1:spring-boot-dependencies/pom.xml が正しいという前提。まぁでもそう簡単におかしな指定はせんやろ…

*2:ただし、追加プロパティはdetailsで持つのが一般的なので、UsernamePasswordAuthenticationTokenの拡張クラスを作ることは余り無いと思う